diff --git a/README.md b/README.md index 35ac01e..b665f80 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ It provides the core building blocks of an on-call system: - access groups and RBAC-style group roles; - teams and on-call rotations; - alert intake routes with per-route tokens; -- Alertmanager, AWS SNS/Cloud watch, Grafana, Zabbix, Sentry, LibreNMS, Datalog, RMON and generic webhook/PagerDuty Event API v2 integrations; +- Alertmanager, AWS SNS/Cloud watch, Grafana, Zabbix, Sentry, LibreNMS, Datadog, RMON, +Uptime Kuma and generic webhook/PagerDuty Event API v2 integrations; - Mattermost, Slack, Telegram, Discord, Microsoft Teams, email, webhook, and voice-call notifications; - profile-level browser/PWA push notifications; - profile notification rules for browser push, email, and voice-call follow-up; @@ -111,12 +112,13 @@ IncidentRelay includes Swagger/OpenAPI documentation and personal API tokens wit | Source | Endpoint | |-----------------|--------------------------------------------| | Alertmanager | `POST /api/integrations/alertmanager` | -| Datalog | `POST /api/integrations/datalog` | +| Datadog | `POST /api/integrations/datadog` | | Grafana | `POST /api/integrations/grafana` | | RMON | `POST /api/integrations/rmon` | | Zabbix | `POST /api/integrations/zabbix` | | Sentry | `POST /api/integrations/sentry/` | | LibreNMS | `POST /api/integrations/librenms` | +| Uptime Kuma | `POST /api/integrations/uptime-kuma` | | Generic webhook | `POST /api/integrations/webhook` | ### Notification channels @@ -163,7 +165,7 @@ Read more: [Docker installation](docs/getting-started/docker.md) ### Kubernetes (Helm) -A Helm chart lives in `helm/incidentrelay`. It deploys the web UI plus the scheduler and Telegram workers, renders the application config from values into a Secret, and wires up the `/healthz` and `/readyz` probes. +A Helm chart lives in `helm/incidentrelay`. It deploys the web UI plus the scheduler, Telegram and Slack workers, renders the application config from values into a Secret, and wires up the `/healthz` and `/readyz` probes. ```bash helm install incidentrelay ./helm/incidentrelay \ @@ -187,6 +189,8 @@ helm upgrade --install incidentrelay ./helm/incidentrelay \ All settings from `incidentrelay.conf` are available under `config.*` in [values.yaml](helm/incidentrelay/values.yaml); you can also bring a pre-rendered config via `existingConfigSecret`. +Read more: [Kubernetes installation](docs/getting-started/kubernetes.md) + ### RedHat-like distributions from RPM repository Recommended for RHEL, Rocky Linux, AlmaLinux, and CentOS Stream. @@ -402,12 +406,14 @@ More examples: - [Alertmanager integration](docs/integrations/alertmanager.md) - [AWS SNS/Cloud watch](integrations/aws-sns-cloudwatch.md) +- [Datadog integration](docs/integrations/datadog.md) - [Grafana integration](docs/integrations/grafana.md) - [Datalog integration](docs/integrations/datadog.md) - [RMON integration](docs/integrations/rmon.md) - [Sentry integration](docs/integrations/sentry.md) - [LibreNMS integration](docs/integrations/librenms.md) - [Zabbix integration](docs/integrations/zabbix.md) +- [Uptime Kuma integration](docs/integrations/uptime-kuma.md) - [Generic webhook integration](docs/integrations/generic-webhook.md) --- diff --git a/app/__init__.py b/app/__init__.py index 69c4af0..9f746b9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,6 +7,7 @@ from app.i18n import register_i18n from app.middleware import enforce_api_authentication from app.views.admin_users_view import admin_users_bp +from app.views.audit_logs_view import audit_logs_bp from app.views.alerts_view import alerts_bp from app.views.auth_view import auth_bp from app.views.calendar_view import calendar_bp @@ -41,6 +42,10 @@ from app.views.matchers_view import matchers_bp from app.views.business_services.routes import business_services_bp from app.views.heartbeats_view import heartbeats_bp +from app.views.orchestrations_view import ( + orchestrations_bp, + orchestration_webhook_actions_bp, +) def create_app(log_role=None): @@ -107,6 +112,7 @@ def register_blueprints(flask_app): flask_app.register_blueprint(teams_bp, url_prefix="/api/teams") flask_app.register_blueprint(users_bp, url_prefix="/api/users") flask_app.register_blueprint(admin_users_bp, url_prefix="/api/admin/users") + flask_app.register_blueprint(audit_logs_bp, url_prefix="/api/admin/audit-logs") flask_app.register_blueprint(rotations_bp, url_prefix="/api/rotations") flask_app.register_blueprint(oncall_health_bp, url_prefix="/api/oncall-health") flask_app.register_blueprint(calendar_bp, url_prefix="/api/calendar") @@ -130,3 +136,8 @@ def register_blueprints(flask_app): flask_app.register_blueprint(priority_policies_bp, url_prefix="/api/priority-policies") flask_app.register_blueprint(business_services_bp) flask_app.register_blueprint(heartbeats_bp, url_prefix="/api/heartbeats") + flask_app.register_blueprint(orchestrations_bp, url_prefix="/api/event-orchestrations") + flask_app.register_blueprint( + orchestration_webhook_actions_bp, + url_prefix="/api/orchestration-webhook-actions", + ) diff --git a/app/api/openapi/endpoints/alerts.py b/app/api/openapi/endpoints/alerts.py index 674ccb8..f8f215e 100644 --- a/app/api/openapi/endpoints/alerts.py +++ b/app/api/openapi/endpoints/alerts.py @@ -671,40 +671,6 @@ def alert_comment_schema(): } -def alert_comment_user_schema(): - """Build alert comment user schema.""" - return { - "type": "object", - "nullable": True, - "properties": { - "id": {"type": "integer"}, - "username": {"type": "string", "nullable": True}, - "email": {"type": "string", "nullable": True}, - "display_name": {"type": "string", "nullable": True}, - }, - } - - -def alert_comment_schema(): - """Build alert comment response schema.""" - return { - "type": "object", - "properties": { - "id": {"type": "integer"}, - "group_id": {"type": "integer", "nullable": True}, - "alert_id": {"type": "integer", "nullable": True}, - "user_id": {"type": "integer", "nullable": True}, - "user": alert_comment_user_schema(), - "body": {"type": "string"}, - "created_at": date_time_property("Comment creation timestamp in UTC."), - "updated_at": date_time_property("Comment update timestamp in UTC."), - "edited": { - "type": "boolean", - "description": "True when updated_at is later than created_at.", - }, - }, - "additionalProperties": True, - } def alert_comment_create_request_schema(): @@ -750,132 +716,6 @@ def delete_alert_comment_response_schema(): } -ALERT_COMMENT_CREATE_SCHEMA = { - "type": "object", - "required": ["body"], - "properties": { - "body": { - "type": "string", - "minLength": 1, - "maxLength": 5000, - "description": "Comment text. Leading and trailing whitespace is trimmed.", - "example": "Investigating disk usage on host1.", - }, - }, -} - - -ALERT_COMMENT_UPDATE_SCHEMA = { - "type": "object", - "required": ["body"], - "properties": { - "body": { - "type": "string", - "minLength": 1, - "maxLength": 5000, - "description": "New comment text. Leading and trailing whitespace is trimmed.", - "example": "Root cause found: log rotation failed.", - }, - }, -} - - -ALERT_COMMENT_DELETE_SCHEMA = { - "type": "object", - "properties": { - "deleted": {"type": "boolean", "example": True}, - "id": {"type": "integer", "example": 42}, - }, -} - - -ALERT_COMMENT_PATHS = { - "/api/alerts/{alert_id}/comments": { - "get": { - "tags": ["alerts"], - "summary": "List alert comments", - "description": ( - "Returns comments attached to an alert group. " - "The alert_id path parameter is the alert group id used by the alert details endpoint." - ), - "operationId": "listAlertComments", - "parameters": [path_param("alert_id", "Alert group id.")], - "responses": { - "200": response( - "List of alert comments.", - {"type": "array", "items": alert_comment_schema()}, - ), - "403": response("Access denied."), - "404": response("Alert group not found."), - }, - }, - "post": { - "tags": ["alerts"], - "summary": "Create alert comment", - "description": ( - "Creates a human comment on an alert group and appends a commented event " - "to the alert event history. Requires team responder, team manager, or global admin access." - ), - "operationId": "createAlertComment", - "parameters": [path_param("alert_id", "Alert group id.")], - "requestBody": json_body( - "Comment properties.", - ALERT_COMMENT_CREATE_SCHEMA, - ), - "responses": { - "201": response("Alert comment created.", alert_comment_schema()), - "400": response("Validation error."), - "403": response("Access denied."), - "404": response("Alert group not found."), - }, - }, - }, - "/api/alerts/{alert_id}/comments/{comment_id}": { - "put": { - "tags": ["alerts"], - "summary": "Update alert comment", - "description": ( - "Updates an existing alert group comment and appends a comment_updated event " - "to the alert event history. Requires team responder, team manager, or global admin access." - ), - "operationId": "updateAlertComment", - "parameters": [ - path_param("alert_id", "Alert group id."), - path_param("comment_id", "Alert comment id."), - ], - "requestBody": json_body( - "Updated comment properties.", - ALERT_COMMENT_UPDATE_SCHEMA, - ), - "responses": { - "200": response("Alert comment updated.", alert_comment_schema()), - "400": response("Validation error."), - "403": response("Access denied."), - "404": response("Alert group or comment not found."), - }, - }, - "delete": { - "tags": ["alerts"], - "summary": "Delete alert comment", - "description": ( - "Soft-deletes an alert group comment and appends a comment_deleted event " - "to the alert event history. Deleted comments are hidden from the list endpoint." - ), - "operationId": "deleteAlertComment", - "parameters": [ - path_param("alert_id", "Alert group id."), - path_param("comment_id", "Alert comment id."), - ], - "responses": { - "200": response("Alert comment deleted.", ALERT_COMMENT_DELETE_SCHEMA), - "403": response("Access denied."), - "404": response("Alert group or comment not found."), - }, - }, - }, -} - - def tags(): """Return OpenAPI tags.""" diff --git a/app/api/openapi/endpoints/heartbeats.py b/app/api/openapi/endpoints/heartbeats.py index 9ad0f2b..8daa1ed 100644 --- a/app/api/openapi/endpoints/heartbeats.py +++ b/app/api/openapi/endpoints/heartbeats.py @@ -1,26 +1,9 @@ +from app.api.openapi.common import json_body, path_param, query_param, response + def tags(): return [{"name": "Heartbeats", "description": "Dead-man-switch checks that page when expected pings stop."}] -def path_param(name, description): - return {"name": name, "in": "path", "required": True, "description": description, "schema": {"type": "integer", "minimum": 1}} - - -def query_param(name, description, schema=None): - return {"name": name, "in": "query", "required": False, "description": description, "schema": schema or {"type": "string"}} - - -def json_body(description, schema, required=True): - return {"required": required, "description": description, "content": {"application/json": {"schema": schema}}} - - -def response(description, schema=None): - item = {"description": description} - if schema: - item["content"] = {"application/json": {"schema": schema}} - return item - - HEARTBEAT_INSTANCE_SCHEMA = { "type": "object", "properties": { diff --git a/app/api/openapi/endpoints/integrations.py b/app/api/openapi/endpoints/integrations.py index 93e3164..a948766 100644 --- a/app/api/openapi/endpoints/integrations.py +++ b/app/api/openapi/endpoints/integrations.py @@ -791,9 +791,10 @@ def tags(): "name": "integrations", "description": ( "Incoming alert endpoints for Alertmanager, Grafana, RMON, " - "Amazon SNS and CloudWatch, Zabbix, LibreNMS, generic webhooks " - "and Sentry. Alertmanager, Grafana, RMON, Zabbix, LibreNMS " - "and generic webhooks use route intake tokens. Sentry uses a " + "Amazon SNS and CloudWatch, Zabbix, LibreNMS, Uptime Kuma, " + "generic webhooks and Sentry. Alertmanager, Grafana, RMON, " + "Zabbix, LibreNMS, Uptime Kuma and generic webhooks use route " + "intake tokens. Sentry uses a " "route-specific webhook signature. Amazon SNS requests are " "verified using the SNS signature and the exact Topic ARN " "configured for the route." @@ -2164,6 +2165,143 @@ def paths(): } + uptime_kuma_body = { + "type": "object", + "description": ( + "Standard Uptime Kuma Webhook notification payload. Uptime Kuma " + "sends monitor, heartbeat and msg fields when the default JSON " + "request body is used." + ), + "additionalProperties": True, + "properties": { + "heartbeat": { + "type": "object", + "nullable": True, + "additionalProperties": True, + "properties": { + "monitorID": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ], + "description": "Stable Uptime Kuma monitor id.", + }, + "status": { + "oneOf": [ + {"type": "integer", "enum": [0, 1, 2, 3]}, + {"type": "string"}, + ], + "description": ( + "Uptime Kuma status: 0 DOWN, 1 UP, 2 PENDING, " + "3 MAINTENANCE." + ), + }, + "msg": { + "type": "string", + "nullable": True, + "description": "Monitor check result message.", + }, + "ping": { + "type": "number", + "nullable": True, + "description": "Observed latency in milliseconds.", + }, + "time": { + "type": "string", + "nullable": True, + }, + "localDateTime": { + "type": "string", + "nullable": True, + }, + }, + }, + "monitor": { + "type": "object", + "nullable": True, + "additionalProperties": True, + "properties": { + "id": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ], + }, + "name": {"type": "string", "nullable": True}, + "type": {"type": "string", "nullable": True}, + "url": {"type": "string", "nullable": True}, + "hostname": {"type": "string", "nullable": True}, + "port": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ], + "nullable": True, + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": True, + "properties": { + "name": {"type": "string"}, + "value": { + "type": "string", + "nullable": True, + }, + }, + }, + }, + }, + }, + "msg": { + "type": "string", + "nullable": True, + "description": "Human-readable Uptime Kuma notification text.", + }, + "severity": { + "type": "string", + "nullable": True, + "description": ( + "Optional IncidentRelay severity override for custom bodies." + ), + }, + "labels": { + "type": "object", + "additionalProperties": True, + "description": "Optional additional IncidentRelay labels.", + }, + "event_link": { + "type": "string", + "nullable": True, + "description": ( + "Optional link to the Uptime Kuma monitor for custom bodies." + ), + }, + }, + "example": { + "heartbeat": { + "monitorID": 42, + "status": 0, + "msg": "Connection refused", + "ping": None, + "time": "2026-07-27 12:00:00", + }, + "monitor": { + "id": 42, + "name": "Production API", + "type": "http", + "url": "https://api.example.com", + "tags": [ + {"name": "team", "value": "sre"}, + {"name": "severity", "value": "critical"}, + ], + }, + "msg": "Production API is DOWN", + }, + } + + return { "/api/integrations/alertmanager": { "post": { @@ -2298,6 +2436,30 @@ def paths(): "responses": incoming_alert_responses("Zabbix alert accepted."), } }, + + "/api/integrations/uptime-kuma": { + "post": { + "tags": ["integrations"], + "summary": "Receive Uptime Kuma monitor notifications", + "description": ( + "Receives the standard JSON body produced by the Uptime Kuma " + "Webhook notification provider. The route intake token must " + "belong to a route with source=uptime_kuma. DOWN and PENDING " + "events are normalized to firing; UP and MAINTENANCE events " + "are normalized to resolved. Monitor id is used as the stable " + "deduplication key so recovery updates the existing alert." + ), + "operationId": "receiveUptimeKumaNotification", + "security": [{"bearerAuth": []}], + "requestBody": json_body( + "Standard Uptime Kuma Webhook payload.", + uptime_kuma_body, + ), + "responses": incoming_alert_responses( + "Uptime Kuma notification accepted." + ), + }, + }, "/api/integrations/sentry/{route_id}": { "post": { "tags": ["integrations"], diff --git a/app/api/openapi/endpoints/maintenance_windows.py b/app/api/openapi/endpoints/maintenance_windows.py index 4da64dd..dfb23b3 100644 --- a/app/api/openapi/endpoints/maintenance_windows.py +++ b/app/api/openapi/endpoints/maintenance_windows.py @@ -157,6 +157,12 @@ def maintenance_window_schema(): ), "occurrence": maintenance_window_occurrence_schema(), "enabled": {"type": "boolean"}, + "apply_to_existing": {"type": "boolean"}, + "reactivate_on_end": {"type": "boolean"}, + "reconciled_at": date_time_property( + "Last lifecycle reconciliation timestamp.", + nullable=True, + ), "deleted": {"type": "boolean"}, "cancelled_by_id": {"type": "integer", "nullable": True}, "cancelled_at": date_time_property( @@ -252,6 +258,24 @@ def maintenance_window_request_schema(): "type": "boolean", "default": True, }, + "apply_to_existing": { + "type": "boolean", + "default": False, + "description": ( + "Apply this window to matching unresolved alert groups that " + "already existed when the occurrence started. Not supported " + "for suppress_incident." + ), + }, + "reactivate_on_end": { + "type": "boolean", + "default": True, + "description": ( + "Release affected alert groups when the window ends, is disabled, " + "or is cancelled. Deletion and scope/behavior changes always release " + "effects that no longer belong to the window." + ), + }, "scopes": { "type": "array", "minItems": 1, diff --git a/app/api/openapi/endpoints/oncall_health.py b/app/api/openapi/endpoints/oncall_health.py index 9c4d449..ce1b987 100644 --- a/app/api/openapi/endpoints/oncall_health.py +++ b/app/api/openapi/endpoints/oncall_health.py @@ -1,3 +1,5 @@ +from app.api.openapi.common import path_param, response + HEALTH_STATUSES = [ "ok", "warning", @@ -12,20 +14,6 @@ ] -def path_param(name, description): - """Build an integer path parameter.""" - return { - "name": name, - "in": "path", - "required": True, - "description": description, - "schema": { - "type": "integer", - "minimum": 1, - }, - } - - def repeated_id_query_param(name, description): """Build a repeated integer query parameter.""" return { @@ -47,22 +35,6 @@ def repeated_id_query_param(name, description): } -def response(description, schema=None): - """Build a JSON response.""" - item = { - "description": description, - } - - if schema: - item["content"] = { - "application/json": { - "schema": schema, - }, - } - - return item - - def bearer_security(): """Return bearer authentication requirement.""" return [{"bearerAuth": []}] diff --git a/app/api/openapi/endpoints/orchestrations.py b/app/api/openapi/endpoints/orchestrations.py new file mode 100644 index 0000000..c383c79 --- /dev/null +++ b/app/api/openapi/endpoints/orchestrations.py @@ -0,0 +1,728 @@ +"""OpenAPI documentation for Event Orchestration control-plane APIs.""" + +from app.api.openapi.common import ERROR_SCHEMA, json_body, path_param, query_param, response +from app.services.integrations.normalizers.registry import SUPPORTED_NORMALIZER_SOURCES +from app.services.orchestration.actions import ( + EVENT_ACTIONS, + FAILURE_MODES, + PROCESS_DISPOSITIONS, + SUPPORTED_ACTION_TYPES, +) +from app.services.orchestration.conditions import SUPPORTED_OPERATORS + + +ORCHESTRATION_MODES = ["active", "shadow", "disabled"] +ORCHESTRATION_COMPATIBILITY_MODES = ["legacy", "hybrid", "orchestration"] +ORCHESTRATION_SCOPES = ["global", "service"] +ORCHESTRATION_VERSION_STATUSES = ["draft", "published", "archived"] +ORCHESTRATION_PROCESSING_MODES = [ + "continue", + "stop", + "evaluate_children", + "children_then_continue", +] + +ACTOR_SCHEMA = { + "type": "object", + "nullable": True, + "properties": { + "id": {"type": "integer", "minimum": 1}, + "username": {"type": "string"}, + "display_name": {"type": "string", "nullable": True}, + "label": {"type": "string"}, + }, + "additionalProperties": False, +} + +PERMISSION_MAP_SCHEMA = { + "type": "object", + "properties": { + key: {"type": "boolean"} + for key in ( + "view", + "create", + "edit", + "publish", + "delete", + "simulate", + "replay", + "view_executions", + "manage_actions", + ) + }, + "additionalProperties": False, +} + +CONDITION_SCHEMA = { + "type": "object", + "required": ["field", "operator"], + "properties": { + "field": { + "type": "string", + "description": ( + "Field reference such as event.severity, labels.environment, " + "variables.region, raw.payload or route.id." + ), + "example": "labels.environment", + }, + "operator": { + "type": "string", + "enum": sorted(SUPPORTED_OPERATORS), + "example": "equals", + }, + "value": { + "description": ( + "Expected JSON value. Operators such as exists, not_exists, " + "is_true and is_false do not require it." + ), + "nullable": True, + }, + }, + "additionalProperties": False, +} + +CONDITION_GROUP_SCHEMA = { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "all": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "description": "All child nodes must match (logical AND).", + }, + "any": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "description": "At least one child node must match (logical OR).", + }, + "none": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "description": "No child node may match (logical NOT).", + }, + }, + "additionalProperties": False, +} + +ACTION_SCHEMA = { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": sorted(SUPPORTED_ACTION_TYPES), + "description": "Deterministic built-in action type.", + "example": "set_severity", + }, + "value": { + "nullable": True, + "description": "Literal value used by set/extraction actions.", + }, + "template": { + "type": "string", + "description": "Restricted template rendered from orchestration context.", + }, + "field": {"type": "string"}, + "source": {"type": "string"}, + "name": {"type": "string"}, + "pattern": {"type": "string"}, + "group": {"type": "integer", "minimum": 0}, + "path": {"type": "string"}, + "separator": {"type": "string"}, + "index": {"type": "integer"}, + "route_id": {"type": "integer", "minimum": 1}, + "team_id": {"type": "integer", "minimum": 1}, + "service_id": {"type": "integer", "minimum": 1}, + "escalation_policy_id": {"type": "integer", "minimum": 1}, + "notification_policy_id": {"type": "integer", "minimum": 1}, + "priority_policy_id": {"type": "integer", "minimum": 1}, + "action_id": {"type": "integer", "minimum": 1}, + "event_action": {"type": "string", "enum": sorted(EVENT_ACTIONS)}, + "group_key": {"type": "string"}, + "dedup_key": {"type": "string"}, + "window_seconds": {"type": "integer", "minimum": 0, "maximum": 86400}, + "seconds": {"type": "integer", "minimum": 1, "maximum": 604800}, + "reason": {"type": "string", "maxLength": 8192}, + "retrigger": {"type": "string"}, + "failure_mode": {"type": "string", "enum": sorted(FAILURE_MODES)}, + "disposition": {"type": "string", "enum": sorted(PROCESS_DISPOSITIONS)}, + }, + "additionalProperties": True, + "description": ( + "One safe built-in mutation, routing, extraction, disposition or queued " + "webhook action. Arbitrary shell, Python and container execution are not supported." + ), +} + +RULE_SCHEMA = { + "type": "object", + "required": ["name", "condition_tree", "actions"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "enabled": {"type": "boolean", "default": True}, + "condition_tree": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "actions": { + "type": "array", + "maxItems": 128, + "items": {"$ref": "#/components/schemas/OrchestrationAction"}, + }, + "processing_mode": { + "type": "string", + "enum": ORCHESTRATION_PROCESSING_MODES, + "default": "continue", + }, + "children": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationRule"}, + }, + }, + "additionalProperties": False, +} + +DEFINITION_SCHEMA = { + "type": "object", + "required": ["schema_version", "rules"], + "properties": { + "schema_version": {"type": "integer", "enum": [1], "default": 1}, + "rules": { + "type": "array", + "maxItems": 512, + "items": {"$ref": "#/components/schemas/OrchestrationRule"}, + }, + }, + "additionalProperties": False, +} + +VERSION_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "orchestration_id": {"type": "integer"}, + "version_number": {"type": "integer", "minimum": 1}, + "status": {"type": "string", "enum": ORCHESTRATION_VERSION_STATUSES}, + "definition_hash": {"type": "string", "nullable": True}, + "definition": {"$ref": "#/components/schemas/OrchestrationDefinition"}, + "comment": {"type": "string", "nullable": True}, + "created_by_id": {"type": "integer", "nullable": True}, + "created_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "updated_by_id": {"type": "integer", "nullable": True}, + "updated_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "published_by_id": {"type": "integer", "nullable": True}, + "published_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "published_at": {"type": "string", "format": "date-time", "nullable": True}, + }, + "additionalProperties": False, +} + +ORCHESTRATION_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "uid": {"type": "string", "format": "uuid"}, + "group_id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string", "nullable": True}, + "scope": {"type": "string", "enum": ORCHESTRATION_SCOPES}, + "service_id": {"type": "integer", "nullable": True}, + "enabled": {"type": "boolean"}, + "mode": {"type": "string", "enum": ORCHESTRATION_MODES}, + "compatibility_mode": { + "type": "string", + "enum": ORCHESTRATION_COMPATIBILITY_MODES, + }, + "active_version_id": {"type": "integer", "nullable": True}, + "active_version": {"$ref": "#/components/schemas/OrchestrationVersion"}, + "active_definition": {"$ref": "#/components/schemas/OrchestrationDefinition"}, + "draft": {"$ref": "#/components/schemas/OrchestrationVersion"}, + "created_by_id": {"type": "integer", "nullable": True}, + "created_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "permissions": {"$ref": "#/components/schemas/OrchestrationPermissions"}, + }, + "additionalProperties": False, +} + +CREATE_SCHEMA = { + "type": "object", + "required": ["group_id", "name"], + "properties": { + "group_id": {"type": "integer", "minimum": 1}, + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "scope": {"type": "string", "enum": ORCHESTRATION_SCOPES, "default": "global"}, + "service_id": {"type": "integer", "nullable": True, "minimum": 1}, + "compatibility_mode": { + "type": "string", + "enum": ORCHESTRATION_COMPATIBILITY_MODES, + "default": "legacy", + }, + }, + "additionalProperties": False, +} + +UPDATE_SCHEMA = { + "type": "object", + "minProperties": 1, + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "scope": {"type": "string", "enum": ORCHESTRATION_SCOPES}, + "service_id": {"type": "integer", "nullable": True, "minimum": 1}, + }, + "additionalProperties": False, +} + +DRAFT_SCHEMA = { + "type": "object", + "required": ["rules"], + "properties": { + "rules": { + "type": "array", + "maxItems": 512, + "items": {"$ref": "#/components/schemas/OrchestrationRule"}, + }, + "comment": {"type": "string", "nullable": True, "maxLength": 8192}, + }, + "additionalProperties": False, +} + +PUBLISH_SCHEMA = { + "type": "object", + "properties": { + "comment": {"type": "string", "nullable": True, "maxLength": 8192}, + "confirm_catch_all_drop": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, +} + +ROLLBACK_SCHEMA = { + "type": "object", + "required": ["version_id"], + "properties": { + "version_id": {"type": "integer", "minimum": 1}, + "comment": {"type": "string", "nullable": True, "maxLength": 8192}, + "confirm_catch_all_drop": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, +} + +RUNTIME_SCHEMA = { + "type": "object", + "required": ["mode"], + "properties": { + "mode": {"type": "string", "enum": ORCHESTRATION_MODES}, + "compatibility_mode": { + "type": "string", + "enum": ORCHESTRATION_COMPATIBILITY_MODES, + "default": "legacy", + }, + }, + "additionalProperties": False, +} + +SIMULATION_SCHEMA = { + "type": "object", + "properties": { + "source": {"type": "string", "enum": list(SUPPORTED_NORMALIZER_SOURCES)}, + "payload": {"nullable": True}, + "headers": {"type": "object", "additionalProperties": True, "default": {}}, + "normalized_event": {"type": "object", "additionalProperties": True, "nullable": True}, + "event_index": {"type": "integer", "minimum": 0, "maximum": 1000, "default": 0}, + "version_id": {"type": "integer", "minimum": 1, "nullable": True}, + "compare_with_active": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "description": ( + "Provide exactly one of normalized_event or payload. source is required " + "when payload is used." + ), +} + +REPLAY_SCHEMA = { + "type": "object", + "properties": { + "alert_ids": {"type": "array", "items": {"type": "integer", "minimum": 1}}, + "execution_ids": {"type": "array", "items": {"type": "integer", "minimum": 1}}, + "version_id": {"type": "integer", "minimum": 1, "nullable": True}, + "compare_with_active": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "description": "At least one alert id or execution id is required.", +} + +WEBHOOK_ACTION_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "group_id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string", "nullable": True}, + "url": {"type": "string", "format": "uri"}, + "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, + "body_template": {"type": "string", "nullable": True}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60}, + "retry_count": {"type": "integer", "minimum": 0, "maximum": 10}, + "private_network_policy": {"type": "string", "enum": ["deny", "allowlist"]}, + "enabled": {"type": "boolean"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + }, + "additionalProperties": False, + "description": "Secret headers are intentionally never included in responses.", +} + +WEBHOOK_ACTION_WRITE_PROPERTIES = { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "url": {"type": "string", "minLength": 1, "maxLength": 4096, "format": "uri"}, + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], + "default": "POST", + }, + "headers": { + "type": "object", + "writeOnly": True, + "additionalProperties": True, + "description": "Encrypted secret headers. Never returned by the API.", + }, + "body_template": {"type": "string", "nullable": True, "maxLength": 65536}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10}, + "retry_count": {"type": "integer", "minimum": 0, "maximum": 10, "default": 2}, + "private_network_policy": { + "type": "string", + "enum": ["deny", "allowlist"], + "default": "deny", + }, + "enabled": {"type": "boolean", "default": True}, +} + +WEBHOOK_ACTION_CREATE_SCHEMA = { + "type": "object", + "required": ["group_id", "name", "url"], + "properties": { + "group_id": {"type": "integer", "minimum": 1}, + **WEBHOOK_ACTION_WRITE_PROPERTIES, + }, + "additionalProperties": False, +} + +WEBHOOK_ACTION_UPDATE_SCHEMA = { + "type": "object", + "minProperties": 1, + "properties": WEBHOOK_ACTION_WRITE_PROPERTIES, + "additionalProperties": False, +} + +GENERIC_OBJECT_SCHEMA = {"type": "object", "additionalProperties": True} + + +def components(): + """Return reusable orchestration OpenAPI schemas.""" + return { + "OrchestrationActor": ACTOR_SCHEMA, + "OrchestrationPermissions": PERMISSION_MAP_SCHEMA, + "OrchestrationCondition": CONDITION_SCHEMA, + "OrchestrationConditionGroup": CONDITION_GROUP_SCHEMA, + "OrchestrationConditionTree": { + "oneOf": [ + {"$ref": "#/components/schemas/OrchestrationCondition"}, + {"$ref": "#/components/schemas/OrchestrationConditionGroup"}, + ] + }, + "OrchestrationAction": ACTION_SCHEMA, + "OrchestrationRule": RULE_SCHEMA, + "OrchestrationDefinition": DEFINITION_SCHEMA, + "OrchestrationVersion": VERSION_SCHEMA, + "EventOrchestration": ORCHESTRATION_SCHEMA, + "OrchestrationWebhookAction": WEBHOOK_ACTION_SCHEMA, + } + + +def tags(): + return [ + { + "name": "event-orchestrations", + "description": ( + "Versioned global and service event-processing rules. Group viewers can read; " + "group editors can create, edit, simulate, replay and publish; destructive and " + "webhook-action administration remains restricted to global administrators." + ), + }, + { + "name": "orchestration-webhook-actions", + "description": ( + "Reusable encrypted outbound webhook actions. Secret headers are write-only and " + "execution is asynchronous with SSRF protections." + ), + }, + ] + + +def _secured(operation): + operation["security"] = [{"bearerAuth": []}] + return operation + + +def _standard_errors(*statuses): + descriptions = { + 400: "Validation error.", + 401: "Valid JWT or API token is required.", + 403: "The principal lacks the required group orchestration permission.", + 404: "The orchestration, version or related resource was not found.", + 409: "The requested state transition conflicts with the current version state.", + } + return {str(status): response(descriptions[status], ERROR_SCHEMA) for status in statuses} + + +def _orchestration_id_params(): + return [path_param("orchestration_id", "Event orchestration id.")] + + +def paths(): + """Return all Event Orchestration OpenAPI path definitions.""" + orchestration_ref = {"$ref": "#/components/schemas/EventOrchestration"} + version_ref = {"$ref": "#/components/schemas/OrchestrationVersion"} + webhook_ref = {"$ref": "#/components/schemas/OrchestrationWebhookAction"} + + return { + "/api/event-orchestrations": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "List accessible event orchestrations", + "operationId": "listEventOrchestrations", + "parameters": [query_param("group_id", "Optional accessible group id.", {"type": "integer", "minimum": 1})], + "responses": { + "200": response("Accessible orchestrations.", { + "type": "object", + "properties": { + "items": {"type": "array", "items": orchestration_ref}, + "count": {"type": "integer"}, + }, + }), + **_standard_errors(401, 403), + }, + }), + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Create an event orchestration", + "description": "Creates a disabled orchestration and its initial editable draft.", + "operationId": "createEventOrchestration", + "requestBody": json_body("Orchestration metadata and scope.", CREATE_SCHEMA), + "responses": { + "201": response("Orchestration created.", orchestration_ref), + **_standard_errors(400, 401, 403, 404, 409), + }, + }), + }, + "/api/event-orchestrations/catalog": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get orchestration editor catalog", + "description": "Returns accessible teams, services, routes, policies, webhook actions, normalizer sources and effective permissions for one group.", + "operationId": "getEventOrchestrationCatalog", + "parameters": [query_param("group_id", "Group id.", {"type": "integer", "minimum": 1}, required=True)], + "responses": { + "200": response("Editor catalog.", GENERIC_OBJECT_SCHEMA), + **_standard_errors(400, 401, 403, 404), + }, + }), + }, + "/api/event-orchestrations/{orchestration_id}": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get an event orchestration", + "operationId": "getEventOrchestration", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Orchestration with active and draft definitions.", orchestration_ref), **_standard_errors(401, 403, 404)}, + }), + "patch": _secured({ + "tags": ["event-orchestrations"], + "summary": "Update orchestration metadata", + "operationId": "updateEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Metadata and optional scope change.", UPDATE_SCHEMA), + "responses": {"200": response("Updated orchestration.", orchestration_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + "delete": _secured({ + "tags": ["event-orchestrations"], + "summary": "Archive an event orchestration", + "description": "Global administrator permission is required. The orchestration is disabled and soft-deleted.", + "operationId": "deleteEventOrchestration", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Orchestration archived.", {"type": "object", "properties": {"deleted": {"type": "boolean"}, "id": {"type": "integer"}}}), **_standard_errors(401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/draft": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get or create an editable draft", + "operationId": "createEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Current editable draft.", version_ref), **_standard_errors(401, 403, 404, 409)}, + }), + "put": _secured({ + "tags": ["event-orchestrations"], + "summary": "Replace draft rules", + "description": "Saves the complete ordered rule tree and records the authenticated user as the last editor.", + "operationId": "saveEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Complete draft rule set.", DRAFT_SCHEMA), + "responses": {"200": response("Saved draft with definition and author metadata.", version_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/validate": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Validate the current draft", + "operationId": "validateEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Validation result.", GENERIC_OBJECT_SCHEMA), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/publish": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Publish the current draft", + "description": "Group editors and global administrators can publish. Published definitions are immutable and author metadata is retained.", + "operationId": "publishEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Optional publication comment and destructive catch-all confirmation.", PUBLISH_SCHEMA), + "responses": {"200": response("Published immutable version.", version_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/rollback": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Rollback by publishing a historical definition", + "description": "Creates a new immutable version; historical published versions are never modified.", + "operationId": "rollbackEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Historical version to copy and publish.", ROLLBACK_SCHEMA), + "responses": {"200": response("New published rollback version.", version_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/runtime": { + "patch": _secured({ + "tags": ["event-orchestrations"], + "summary": "Update orchestration runtime mode", + "description": "A published version is required before active or shadow mode can be enabled.", + "operationId": "updateEventOrchestrationRuntime", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Runtime and compatibility modes.", RUNTIME_SCHEMA), + "responses": {"200": response("Updated orchestration runtime.", orchestration_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/versions": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "List orchestration versions", + "operationId": "listEventOrchestrationVersions", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Version history.", {"type": "object", "properties": {"items": {"type": "array", "items": version_ref}}}), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/versions/{version_id}": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get one orchestration version", + "operationId": "getEventOrchestrationVersion", + "parameters": _orchestration_id_params() + [path_param("version_id", "Version row id.")], + "responses": {"200": response("Version including immutable definition.", version_ref), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/simulate": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Simulate an event against a draft or version", + "description": "Does not create alerts, mutate production state or execute webhooks.", + "operationId": "simulateEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Normalized event or raw integration payload.", SIMULATION_SCHEMA), + "responses": {"200": response("Deterministic simulation and explain trace.", GENERIC_OBJECT_SCHEMA), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/replay": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Replay stored events safely", + "description": "Re-evaluates stored alerts or executions without applying production side effects.", + "operationId": "replayEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Stored alert and/or execution ids.", REPLAY_SCHEMA), + "responses": {"200": response("Replay comparison results.", GENERIC_OBJECT_SCHEMA), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/executions": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "List orchestration executions", + "operationId": "listEventOrchestrationExecutions", + "parameters": _orchestration_id_params() + [ + query_param("limit", "Maximum execution rows.", {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}), + query_param("include_trace", "Set to 1 to include full explain traces.", {"type": "string", "enum": ["1"]}), + ], + "responses": {"200": response("Execution history.", GENERIC_OBJECT_SCHEMA), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/shadow-metrics": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get shadow comparison metrics", + "operationId": "getEventOrchestrationShadowMetrics", + "parameters": _orchestration_id_params() + [query_param("limit", "Optional number of recent executions to aggregate.", {"type": "integer", "minimum": 1})], + "responses": {"200": response("Shadow decision counters and differences.", GENERIC_OBJECT_SCHEMA), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/orchestration-webhook-actions": { + "get": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "List webhook actions for a group", + "operationId": "listOrchestrationWebhookActions", + "parameters": [query_param("group_id", "Group id.", {"type": "integer", "minimum": 1}, required=True)], + "responses": {"200": response("Webhook actions without secret headers.", {"type": "object", "properties": {"items": {"type": "array", "items": webhook_ref}}}), **_standard_errors(400, 401, 403)}, + }), + "post": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "Create a reusable webhook action", + "description": "Global administrator permission is required. Secret headers are encrypted and never returned.", + "operationId": "createOrchestrationWebhookAction", + "requestBody": json_body("Webhook configuration and write-only secret headers.", WEBHOOK_ACTION_CREATE_SCHEMA), + "responses": {"201": response("Webhook action created.", webhook_ref), **_standard_errors(400, 401, 403, 409)}, + }), + }, + "/api/orchestration-webhook-actions/{action_id}": { + "patch": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "Update a webhook action", + "operationId": "updateOrchestrationWebhookAction", + "parameters": [path_param("action_id", "Webhook action id.")], + "requestBody": json_body("Fields to update. Supplying headers replaces encrypted headers.", WEBHOOK_ACTION_UPDATE_SCHEMA), + "responses": {"200": response("Updated webhook action without secrets.", webhook_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + "delete": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "Delete a webhook action", + "operationId": "deleteOrchestrationWebhookAction", + "parameters": [path_param("action_id", "Webhook action id.")], + "responses": {"200": response("Webhook action soft-deleted.", {"type": "object", "properties": {"deleted": {"type": "boolean"}, "id": {"type": "integer"}}}), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/orchestration-webhook-actions/{action_id}/executions": { + "get": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "List webhook action executions", + "operationId": "listOrchestrationWebhookActionExecutions", + "parameters": [ + path_param("action_id", "Webhook action id."), + query_param("limit", "Maximum execution rows.", {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}), + ], + "responses": {"200": response("Redacted asynchronous execution results.", GENERIC_OBJECT_SCHEMA), **_standard_errors(401, 403, 404)}, + }), + }, + } diff --git a/app/api/openapi/endpoints/profile.py b/app/api/openapi/endpoints/profile.py index f4628f0..a1901fa 100644 --- a/app/api/openapi/endpoints/profile.py +++ b/app/api/openapi/endpoints/profile.py @@ -1,5 +1,6 @@ from app.api.schemas.roles import TEAM_ROLE_VALUES from app.api.openapi.common import response, path_param, json_body +from app.services.api_token_scopes import PROFILE_TOKEN_SCOPE_OPTIONS ERROR_SCHEMA = { @@ -86,6 +87,25 @@ "description": "Phone number for voice or SMS integrations.", "example": "+77001234567", }, + "timezone": { + "type": "string", + "nullable": True, + "description": "Preferred IANA timezone for calendar display.", + "example": "Asia/Almaty", + }, + "locale": { + "type": "string", + "nullable": True, + "enum": ["en", "de", "fr", "ru"], + "description": "Preferred interface language.", + "example": "ru", + }, + "theme": { + "type": "string", + "enum": ["system", "light", "dark"], + "description": "Preferred interface color theme.", + "example": "dark", + }, "telegram_user_id": { "type": "string", "nullable": True, @@ -95,7 +115,10 @@ "slack_user_id": { "type": "string", "nullable": True, - "description": "Slack user id used for direct notifications.", + "description": ( + "Slack user ID used to attribute interactive Slack " + "ACK/Resolve actions to an IncidentRelay user." + ), "example": "U012ABCDEF", }, "mattermost_user_id": { @@ -136,6 +159,18 @@ "description": "Groups available to the current user.", "items": GROUP_MEMBERSHIP_SCHEMA, }, + "available_token_scopes": { + "type": "array", + "readOnly": True, + "description": ( + "Personal API token scopes selectable by the current user. " + "Wildcard is returned for global admins only." + ), + "items": { + "type": "string", + "enum": list(PROFILE_TOKEN_SCOPE_OPTIONS), + }, + }, }, } @@ -159,6 +194,22 @@ "nullable": True, "example": "+77001234567", }, + "timezone": { + "type": "string", + "nullable": True, + "example": "Asia/Almaty", + }, + "locale": { + "type": "string", + "nullable": True, + "enum": ["en", "de", "fr", "ru"], + "example": "ru", + }, + "theme": { + "type": "string", + "enum": ["system", "light", "dark"], + "example": "dark", + }, "telegram_user_id": { "type": "string", "nullable": True, @@ -242,9 +293,17 @@ }, "scopes": { "type": "array", - "description": "Token scopes.", - "items": {"type": "string"}, - "example": ["alerts:read", "resources:read", "calendar:read"], + "description": "Token scopes. Multiple granular scopes can be combined.", + "items": { + "type": "string", + "enum": list(PROFILE_TOKEN_SCOPE_OPTIONS), + }, + "example": [ + "alerts:read", + "services:read", + "incidents:read", + "teams:read", + ], }, "group_id": { "type": "integer", @@ -333,10 +392,21 @@ }, "scopes": { "type": "array", - "description": "Token scopes.", - "items": {"type": "string"}, + "description": ( + "One or more token scopes. Prefer granular entity scopes; " + "resources:read/write are legacy aggregate scopes." + ), + "items": { + "type": "string", + "enum": list(PROFILE_TOKEN_SCOPE_OPTIONS), + }, "default": ["alerts:read"], - "example": ["alerts:read", "resources:read"], + "example": [ + "alerts:read", + "services:read", + "incidents:read", + "teams:read", + ], }, "days": { "type": "integer", diff --git a/app/api/openapi/endpoints/routes.py b/app/api/openapi/endpoints/routes.py index 19a816f..15c8d9b 100644 --- a/app/api/openapi/endpoints/routes.py +++ b/app/api/openapi/endpoints/routes.py @@ -1,67 +1,8 @@ -def path_param(name, description): - """ - Build an integer path parameter. - """ - - return { - "name": name, - "in": "path", - "required": True, - "description": description, - "schema": {"type": "integer", "minimum": 1}, - } - - -def query_param(name, description, schema=None, required=False): - """ - Build a query parameter. - """ - - return { - "name": name, - "in": "query", - "required": required, - "description": description, - "schema": schema or {"type": "string"}, - } - - -def json_body(description, schema, required=True): - """ - Build a JSON request body. - """ - - return { - "required": required, - "description": description, - "content": { - "application/json": { - "schema": schema - } - }, - } - - -def response(description, schema=None): - """ - Build a JSON response. - """ - - item = {"description": description} - - if schema: - item["content"] = { - "application/json": { - "schema": schema - } - } - - return item - +from app.api.openapi.common import json_body, path_param, query_param, response SOURCE_SCHEMA_WITH_SENTRY = { "type": "string", - "enum": ["alertmanager", "aws_sns", "grafana", "rmon", "zabbix", "webhook", "sentry", "librenms", "heartbeat"], + "enum": ["alertmanager", "aws_sns", "datadog", "grafana", "rmon", "zabbix", "webhook", "sentry", "librenms", "uptime_kuma", "heartbeat"], "description": "Incoming alert source type.", } diff --git a/app/api/openapi/endpoints/service_catalog.py b/app/api/openapi/endpoints/service_catalog.py index d14f638..5242da1 100644 --- a/app/api/openapi/endpoints/service_catalog.py +++ b/app/api/openapi/endpoints/service_catalog.py @@ -5,6 +5,8 @@ All paths still use the existing `services` Swagger tag. """ +from app.api.openapi.common import json_body, path_param, query_param, response + from app.api.schemas.limits import ( DESCRIPTION_MAX_LENGTH, NAME_MAX_LENGTH, @@ -75,43 +77,6 @@ ] -def path_param(name, description): - return { - "name": name, - "in": "path", - "required": True, - "description": description, - "schema": {"type": "integer", "minimum": 1}, - } - - -def query_param(name, description, schema=None, required=False): - return { - "name": name, - "in": "query", - "required": required, - "description": description, - "schema": schema or {"type": "string"}, - } - - -def json_body(description, schema, required=True): - return { - "required": required, - "description": description, - "content": {"application/json": {"schema": schema}}, - } - - -def response(description, schema=None): - item = {"description": description} - - if schema: - item["content"] = {"application/json": {"schema": schema}} - - return item - - ERROR_SCHEMA = { "type": "object", "properties": { diff --git a/app/api/openapi/endpoints/services.py b/app/api/openapi/endpoints/services.py index d9e7e9b..b819d37 100644 --- a/app/api/openapi/endpoints/services.py +++ b/app/api/openapi/endpoints/services.py @@ -1,3 +1,4 @@ +from app.api.openapi.common import json_body, path_param, query_param, response from app.api.openapi.endpoints.service_catalog import ( READINESS_STATE_SCHEMA, SERVICE_READINESS_RESPONSE_SCHEMA, @@ -129,55 +130,6 @@ # --------------------------------------------------------------------------- -def path_param(name, description): - """Build an integer path parameter.""" - return { - "name": name, - "in": "path", - "required": True, - "description": description, - "schema": {"type": "integer", "minimum": 1}, - } - - -def query_param(name, description, schema=None, required=False): - """Build a query parameter.""" - return { - "name": name, - "in": "query", - "required": required, - "description": description, - "schema": schema or {"type": "string"}, - } - - -def json_body(description, schema, required=True): - """Build a JSON request body.""" - return { - "required": required, - "description": description, - "content": { - "application/json": { - "schema": schema, - }, - }, - } - - -def response(description, schema=None): - """Build a JSON response.""" - item = {"description": description} - - if schema: - item["content"] = { - "application/json": { - "schema": schema, - }, - } - - return item - - ERROR_SCHEMA = { "type": "object", "properties": { diff --git a/app/api/openapi/endpoints/silences.py b/app/api/openapi/endpoints/silences.py index 889aaf2..74965ce 100644 --- a/app/api/openapi/endpoints/silences.py +++ b/app/api/openapi/endpoints/silences.py @@ -16,6 +16,22 @@ "starts_at": {"type": "string", "format": "date-time"}, "ends_at": {"type": "string", "format": "date-time"}, "created_by": {"type": "integer", "nullable": True}, + "apply_to_existing": { + "type": "boolean", + "default": False, + "description": ( + "When true, matching unresolved firing alerts are silenced " + "when this Silence becomes active." + ), + }, + "reactivate_on_end": { + "type": "boolean", + "default": True, + "description": ( + "When true, alerts affected by this Silence are reactivated " + "after it expires or is disabled, unless another Silence applies." + ), + }, "enabled": {"type": "boolean", "default": True}, }, } @@ -46,15 +62,22 @@ def paths(): "summary": "List silences", "description": "Returns silence rules. Optional team_id filters silences by team.", "operationId": "listSilences", - "parameters": [query_param("team_id", "Filter silences by team id.", {"type": "integer", "minimum": 1})], + "parameters": [ + query_param( + "team_id", + "Filter silences by team id.", + {"type": "integer", "minimum": 1}, + ) + ], "responses": {"200": response("List of silences.", {"type": "array", "items": SILENCE_SCHEMA})}, }, "post": { "tags": ["silences"], "summary": "Create silence", "description": ( - "Creates a silence rule for a team. Matching firing alerts are stored as silenced and notifications " - "are not sent while the silence is active." + "Creates a silence rule for a team. New matching alerts are stored as silenced. " + "Set apply_to_existing=true to also silence matching unresolved firing alerts. " + "Set reactivate_on_end=false to keep affected alerts silenced after the Silence ends." ), "operationId": "createSilence", "requestBody": json_body("Silence properties.", SILENCE_SCHEMA), @@ -73,7 +96,7 @@ def paths(): "put": { "tags": ["silences"], "summary": "Update silence", - "description": "Updates silence time range, matchers, reason and enabled flag.", + "description": "Updates Silence scope, time range, matchers and lifecycle behavior.", "operationId": "updateSilence", "parameters": [path_param("silence_id", "Silence id.")], "requestBody": json_body("Updated silence properties.", SILENCE_SCHEMA), @@ -82,10 +105,23 @@ def paths(): "delete": { "tags": ["silences"], "summary": "Disable silence", - "description": "Soft-deletes a silence rule by setting enabled=false.", + "description": ( + "Disables a Silence. Affected alerts are reactivated when " + "reactivate_on_end is enabled and no other Silence applies." + ), "operationId": "disableSilence", "parameters": [path_param("silence_id", "Silence id.")], "responses": {"200": response("Silence disabled.")}, }, }, + "/api/silences/{silence_id}/enable": { + "post": { + "tags": ["silences"], + "summary": "Enable silence", + "description": "Enables a Silence and immediately reconciles its configured lifecycle behavior.", + "operationId": "enableSilence", + "parameters": [path_param("silence_id", "Silence id.")], + "responses": {"200": response("Silence enabled.", SILENCE_SCHEMA)}, + }, + }, } diff --git a/app/api/openapi/endpoints/sso.py b/app/api/openapi/endpoints/sso.py index 4d03d51..0df6cef 100644 --- a/app/api/openapi/endpoints/sso.py +++ b/app/api/openapi/endpoints/sso.py @@ -1,17 +1,7 @@ +from app.api.openapi.common import json_body, path_param, response from app.api.schemas.roles import GROUP_ROLE_VALUES, GROUP_VIEWER_ROLE, TEAM_ROLE_VALUES, TEAM_VIEWER_ROLE -def path_param(name, description): - """Build integer path parameter.""" - return { - "name": name, - "in": "path", - "required": True, - "description": description, - "schema": {"type": "integer", "minimum": 1}, - } - - def slug_param(): """Build SSO provider slug path parameter.""" return { @@ -28,23 +18,6 @@ def slug_param(): } -def json_body(description, schema, required=True): - """Build JSON request body.""" - return { - "required": required, - "description": description, - "content": {"application/json": {"schema": schema}}, - } - - -def response(description, schema=None): - """Build OpenAPI response.""" - item = {"description": description} - if schema: - item["content"] = {"application/json": {"schema": schema}} - return item - - ERROR_SCHEMA = { "type": "object", "properties": { diff --git a/app/api/openapi/endpoints/users.py b/app/api/openapi/endpoints/users.py index 4fde61c..9d80d40 100644 --- a/app/api/openapi/endpoints/users.py +++ b/app/api/openapi/endpoints/users.py @@ -18,7 +18,7 @@ "email": {"type": "string", "format": "email", "nullable": True, "description": "User email address.", "example": "ivan@example.com"}, "phone": {"type": "string", "nullable": True, "description": "Phone number for voice integrations.", "example": "+77001234567"}, "telegram_user_id": {"type": "string", "nullable": True, "description": "Telegram user ID used for direct notifications.", "example": "123456789"}, - "slack_user_id": {"type": "string", "nullable": True, "description": "Slack user id used for direct notifications.", "example": "U012ABCDEF"}, + "slack_user_id": {"type": "string", "nullable": True, "description": "Slack user ID used to attribute interactive Slack ACK/Resolve actions to an IncidentRelay user.", "example": "U012ABCDEF"}, "mattermost_user_id": { "type": "string", "nullable": True, @@ -53,7 +53,7 @@ "email": {"type": "string", "format": "email", "nullable": True, "description": "User email address.", "example": "ivan@example.com"}, "phone": {"type": "string", "nullable": True, "maxLength": PHONE_MAX_LENGTH, "description": "Phone number for voice integrations.", "example": "+77001234567"}, "telegram_user_id": {"type": "string", "nullable": True, "maxLength": CONTACT_ID_MAX_LENGTH, "description": "Telegram user ID used for direct notifications.", "example": "123456789"}, - "slack_user_id": {"type": "string", "nullable": True, "maxLength": CONTACT_ID_MAX_LENGTH, "description": "Slack user id used for direct notifications.", "example": "U012ABCDEF"}, + "slack_user_id": {"type": "string", "nullable": True, "maxLength": CONTACT_ID_MAX_LENGTH, "description": "Slack user ID used to attribute interactive Slack ACK/Resolve actions to an IncidentRelay user.", "example": "U012ABCDEF"}, "mattermost_user_id": { "type": "string", "nullable": True, diff --git a/app/api/openapi/spec.py b/app/api/openapi/spec.py index bd1415b..e88be73 100644 --- a/app/api/openapi/spec.py +++ b/app/api/openapi/spec.py @@ -11,6 +11,7 @@ incidents, integrations, notification_center, + orchestrations, notification_rules, notification_policies, oncall_health, @@ -52,6 +53,7 @@ browser_push, notification_rules, notification_center, + orchestrations, groups, sso, services, @@ -65,10 +67,14 @@ def build_openapi_spec(): """Build the OpenAPI specification from endpoint modules.""" paths = {} tags = [] + schemas = {} for module in ENDPOINT_MODULES: paths.update(module.paths()) tags.extend(module.tags()) + component_builder = getattr(module, "components", None) + if component_builder is not None: + schemas.update(component_builder()) return { "openapi": "3.0.3", @@ -85,6 +91,7 @@ def build_openapi_spec(): "tags": tags, "paths": paths, "components": { + "schemas": schemas, "securitySchemes": { "bearerAuth": { "type": "http", diff --git a/app/api/schemas/base.py b/app/api/schemas/base.py index 650e92d..6a0eb38 100644 --- a/app/api/schemas/base.py +++ b/app/api/schemas/base.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field -from app.modules.common import as_utc_aware as common_as_utc_aware +from app.modules.common import as_utc_aware class ApiModel(BaseModel): @@ -29,8 +29,3 @@ class IdBody(ApiModel): JsonDict = Dict[str, Any] JsonList = List[Any] - - -def as_utc_aware(value): - """Treat naive datetimes as UTC and return aware UTC datetime.""" - return common_as_utc_aware(value) diff --git a/app/api/schemas/integrations.py b/app/api/schemas/integrations.py index 7c803b0..cd9eb95 100644 --- a/app/api/schemas/integrations.py +++ b/app/api/schemas/integrations.py @@ -231,6 +231,44 @@ def validate_not_empty(self): return self +class UptimeKumaWebhookSchema(ApiModel): + """Validate the standard Uptime Kuma Webhook payload.""" + + model_config = ConfigDict(extra="allow") + + heartbeat: Dict[str, Any] | None = None + monitor: Dict[str, Any] | None = None + msg: str | None = None + + # Optional IncidentRelay extensions for custom Uptime Kuma webhook bodies. + title: str | None = None + message: str | None = None + severity: str | None = None + status: str | int | None = None + team: str | None = None + labels: Dict[str, Any] = Field(default_factory=dict) + event_link: str | None = None + monitor_url: str | None = None + monitor_id: str | int | None = None + monitor_type: str | None = None + name: str | None = None + + @model_validator(mode="after") + def validate_not_empty(self): + if not ( + self.heartbeat + or self.monitor + or str(self.msg or "").strip() + or str(self.message or "").strip() + or str(self.title or "").strip() + ): + raise ValueError( + "Uptime Kuma webhook payload must contain heartbeat, monitor or msg" + ) + + return self + + class GenericWebhookSchema(ApiModel): """Validate generic or PagerDuty Events API v2-compatible payloads.""" diff --git a/app/api/schemas/maintenance_windows.py b/app/api/schemas/maintenance_windows.py index f8287db..45d5920 100644 --- a/app/api/schemas/maintenance_windows.py +++ b/app/api/schemas/maintenance_windows.py @@ -7,6 +7,7 @@ from app.api.schemas.base import ApiModel from app.modules.common import as_naive_datetime +from app.modules.common import utc_now MAINTENANCE_WINDOW_NAME_MAX_LENGTH = 255 @@ -107,6 +108,8 @@ class MaintenanceWindowBaseSchema(ApiModel): ends_at: datetime enabled: bool = True + apply_to_existing: bool = False + reactivate_on_end: bool = True scopes: list[MaintenanceWindowScopeSchema] = Field(min_length=1) @@ -143,6 +146,11 @@ def validate_time_range_and_rrule(self): if self.ends_at <= local_now: raise ValueError("ends_at must be in the future") + if self.behavior == "suppress_incident" and self.apply_to_existing: + raise ValueError( + "apply_to_existing is not supported for suppress_incident behavior" + ) + return self @@ -159,6 +167,8 @@ class MaintenanceWindowUpdateSchema(ApiModel): starts_at: datetime | None = None ends_at: datetime | None = None enabled: bool | None = None + apply_to_existing: bool | None = None + reactivate_on_end: bool | None = None scopes: list[MaintenanceWindowScopeSchema] | None = Field(default=None, min_length=1) @field_validator("starts_at", "ends_at") @@ -182,6 +192,11 @@ def validate_update_payload(self): if self.ends_at <= self.starts_at: raise ValueError("ends_at must be greater than starts_at") + if self.behavior == "suppress_incident" and self.apply_to_existing: + raise ValueError( + "apply_to_existing is not supported for suppress_incident behavior" + ) + if self.ends_at is not None: zone_name = self.timezone or "UTC" @@ -210,7 +225,7 @@ def normalize_ends_at(cls, value: datetime) -> datetime: @model_validator(mode="after") def validate_ends_at(self): - if self.ends_at <= datetime.utcnow(): + if self.ends_at <= utc_now(): raise ValueError("ends_at must be in the future") return self diff --git a/app/api/schemas/orchestrations.py b/app/api/schemas/orchestrations.py new file mode 100644 index 0000000..7929766 --- /dev/null +++ b/app/api/schemas/orchestrations.py @@ -0,0 +1,145 @@ +"""Request schemas for Event Orchestration control-plane APIs.""" + +from typing import Any, Dict, Literal + +from pydantic import Field, field_validator, model_validator + +from app.api.schemas.base import ApiModel +from app.services.integrations.normalizers.registry import ( + SUPPORTED_NORMALIZER_SOURCES, +) + + +class OrchestrationCreateSchema(ApiModel): + group_id: int = Field(ge=1) + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + scope: Literal["global", "service"] = "global" + service_id: int | None = Field(default=None, ge=1) + compatibility_mode: Literal["legacy", "hybrid", "orchestration"] = "legacy" + + @model_validator(mode="after") + def validate_scope(self): + if self.scope == "global" and self.service_id is not None: + raise ValueError("global orchestration cannot reference a service") + if self.scope == "service" and self.service_id is None: + raise ValueError("service-scoped orchestration requires service_id") + return self + + +class OrchestrationUpdateSchema(ApiModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + scope: Literal["global", "service"] | None = None + service_id: int | None = Field(default=None, ge=1) + + +class OrchestrationDraftSchema(ApiModel): + rules: list[Dict[str, Any]] = Field(default_factory=list, max_length=512) + comment: str | None = Field(default=None, max_length=8192) + + +class OrchestrationPublishSchema(ApiModel): + comment: str | None = Field(default=None, max_length=8192) + confirm_catch_all_drop: bool = False + + +class OrchestrationRollbackSchema(ApiModel): + version_id: int = Field(ge=1) + comment: str | None = Field(default=None, max_length=8192) + confirm_catch_all_drop: bool = False + + +class OrchestrationRuntimeSchema(ApiModel): + mode: Literal["active", "shadow", "disabled"] + compatibility_mode: Literal["legacy", "hybrid", "orchestration"] = "legacy" + + @property + def enabled(self) -> bool: + return self.mode != "disabled" + + +class OrchestrationSimulationSchema(ApiModel): + source: str | None = Field(default=None, max_length=128) + payload: Any | None = None + headers: Dict[str, Any] = Field(default_factory=dict) + normalized_event: Dict[str, Any] | None = None + event_index: int = Field(default=0, ge=0, le=1000) + version_id: int | None = Field(default=None, ge=1) + compare_with_active: bool = False + + @field_validator("source") + @classmethod + def validate_source(cls, value): + if value is None: + return None + source = value.strip().lower() + if source not in SUPPORTED_NORMALIZER_SOURCES: + raise ValueError("unsupported simulation source") + return source + + @model_validator(mode="after") + def validate_input_mode(self): + has_normalized = self.normalized_event is not None + has_payload = self.payload is not None + if has_normalized == has_payload: + raise ValueError( + "provide exactly one of normalized_event or payload" + ) + if has_payload and not self.source: + raise ValueError("source is required when payload is provided") + return self + + +class OrchestrationReplaySchema(ApiModel): + alert_ids: list[int] = Field(default_factory=list) + execution_ids: list[int] = Field(default_factory=list) + version_id: int | None = Field(default=None, ge=1) + compare_with_active: bool = False + + @field_validator("alert_ids", "execution_ids") + @classmethod + def validate_ids(cls, value): + result = [] + for raw in value: + item = int(raw) + if item < 1: + raise ValueError("ids must be greater than 0") + if item not in result: + result.append(item) + return result + + @model_validator(mode="after") + def require_inputs(self): + if not self.alert_ids and not self.execution_ids: + raise ValueError( + "at least one alert_id or execution_id is required" + ) + return self + + +class OrchestrationWebhookActionCreateSchema(ApiModel): + group_id: int = Field(ge=1) + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + url: str = Field(min_length=1, max_length=4096) + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "POST" + headers: Dict[str, Any] = Field(default_factory=dict) + body_template: str | None = Field(default=None, max_length=65536) + timeout_seconds: int = Field(default=10, ge=1, le=60) + retry_count: int = Field(default=2, ge=0, le=10) + private_network_policy: Literal["deny", "allowlist"] = "deny" + enabled: bool = True + + +class OrchestrationWebhookActionUpdateSchema(ApiModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + url: str | None = Field(default=None, min_length=1, max_length=4096) + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] | None = None + headers: Dict[str, Any] | None = None + body_template: str | None = Field(default=None, max_length=65536) + timeout_seconds: int | None = Field(default=None, ge=1, le=60) + retry_count: int | None = Field(default=None, ge=0, le=10) + private_network_policy: Literal["deny", "allowlist"] | None = None + enabled: bool | None = None diff --git a/app/api/schemas/profile.py b/app/api/schemas/profile.py index e87f7e9..2e41400 100644 --- a/app/api/schemas/profile.py +++ b/app/api/schemas/profile.py @@ -4,6 +4,8 @@ from pydantic import EmailStr, Field, field_validator from app.api.schemas.base import ApiModel +from app.i18n import normalize_locale +from app.ui_preferences import normalize_theme from app.api.schemas.limits import ( CONTACT_ID_MAX_LENGTH, DISPLAY_NAME_MAX_LENGTH, @@ -27,6 +29,8 @@ class ProfileUpdateSchema(ApiModel): max_length=PHONE_MAX_LENGTH, ) timezone: Optional[str] = Field(default=None, max_length=64) + locale: Optional[str] = Field(default=None, max_length=16) + theme: Optional[str] = Field(default=None, max_length=16) telegram_user_id: Optional[str] = Field( default=None, max_length=CONTACT_ID_MAX_LENGTH, @@ -68,6 +72,29 @@ def validate_timezone_field(cls, value): return value + @field_validator("locale") + @classmethod + def validate_locale_field(cls, value: str | None) -> str | None: + """Validate an optional supported interface locale.""" + if value is None: + return None + + normalized = normalize_locale(value) + if not normalized: + raise ValueError("unsupported locale") + + return normalized + + @field_validator("theme") + @classmethod + def validate_theme_field(cls, value: str | None) -> str: + """Validate the interface theme preference.""" + normalized = normalize_theme(value) + if value is not None and not normalized: + raise ValueError("unsupported theme") + + return normalized or "system" + class ProfileTokenCreateSchema(ApiModel): """Personal API token creation request.""" diff --git a/app/api/schemas/routes.py b/app/api/schemas/routes.py index 8ca42db..045cc8f 100644 --- a/app/api/schemas/routes.py +++ b/app/api/schemas/routes.py @@ -20,7 +20,7 @@ class RouteBaseSchema(ApiModel): team_id: int = Field(ge=1) name: str = Field(min_length=2, max_length=120) - source: str = Field(pattern=r"^(alertmanager|aws_sns|datadog|grafana|zabbix|webhook|sentry|librenms|rmon|heartbeat)$") + source: str = Field(pattern=r"^(alertmanager|aws_sns|datadog|grafana|zabbix|webhook|sentry|librenms|rmon|uptime_kuma|heartbeat)$") rotation_id: int | None = Field(default=None, ge=1) channel_ids: List[int] = Field(default_factory=list) notification_channel_mode: str = Field(default=ROUTE_ONLY, pattern=NOTIFICATION_CHANNEL_MODE_PATTERN) diff --git a/app/api/schemas/silences.py b/app/api/schemas/silences.py index c745349..0a9a6e4 100644 --- a/app/api/schemas/silences.py +++ b/app/api/schemas/silences.py @@ -1,10 +1,11 @@ from datetime import datetime from typing import Any, Dict -from pydantic import Field, model_validator +from pydantic import Field, field_validator, model_validator from app.api.schemas.base import ApiModel from app.api.schemas.limits import DESCRIPTION_MAX_LENGTH +from app.modules.common import as_utc_naive class SilenceCreateSchema(ApiModel): @@ -20,6 +21,14 @@ class SilenceCreateSchema(ApiModel): starts_at: datetime ends_at: datetime created_by: int | None = Field(default=None, ge=1) + apply_to_existing: bool = False + reactivate_on_end: bool = True + + @field_validator("starts_at", "ends_at") + @classmethod + def normalize_utc_datetime(cls, value: datetime) -> datetime: + """Normalize API timestamps to the naive UTC storage convention.""" + return as_utc_naive(value) @model_validator(mode="after") def validate_range(self): diff --git a/app/i18n.py b/app/i18n.py index 09cd0e2..8c985cf 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -15,6 +15,7 @@ SUPPORTED_LOCALES = { "en": "English", "de": "Deutsch", + "fr": "Français", "ru": "Русский", } @@ -36,7 +37,12 @@ def normalize_locale(value: str | None) -> str | None: def get_current_locale() -> str: - """Resolve locale from cookie, then Accept-Language, then fallback.""" + """Resolve locale from user preference, cookie, browser, then fallback.""" + user = getattr(request, "current_user", None) + user_locale = normalize_locale(getattr(user, "locale", None)) + if user_locale: + return user_locale + cookie_locale = normalize_locale(request.cookies.get(LOCALE_COOKIE_NAME)) if cookie_locale: return cookie_locale diff --git a/app/login.py b/app/login.py index 2fa4dd2..827d0fb 100644 --- a/app/login.py +++ b/app/login.py @@ -5,6 +5,7 @@ from werkzeug.security import check_password_hash, generate_password_hash from app.settings import Config +from app.modules.common import utc_now JWT_ALGORITHM = "HS256" @@ -62,14 +63,14 @@ def create_access_token(user): Create a JWT access token for a user. """ - expires_at = datetime.utcnow() + timedelta(minutes=Config.JWT_EXPIRE_MINUTES) + expires_at = utc_now() + timedelta(minutes=Config.JWT_EXPIRE_MINUTES) payload = { "sub": str(user.id), "username": user.username, "is_admin": bool(user.is_admin), "exp": expires_at, - "iat": datetime.utcnow(), + "iat": utc_now(), } token = jwt.encode(payload, Config.JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) diff --git a/app/middleware.py b/app/middleware.py index 9178fc0..7e11e39 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -6,19 +6,11 @@ from app.login import decode_access_token from app.modules.db import users_repo from app.settings import Config - - -PUBLIC_API_PATHS = { - "/api/auth/login", - "/api/auth/logout", - "/api/push/actions", -} - -PUBLIC_API_PREFIXES = ( - "/api/auth/sso/", - "/api/integrations/", - "/api/heartbeats/ping/", - "/api/version", +from app.services.api_token_scopes import ( + is_public_api_request, + is_public_calendar_feed_path as is_public_calendar_feed_request, + required_scopes_for_path, + token_has_scopes, ) @@ -80,100 +72,32 @@ def wrapper(*args, **kwargs): return wrapper -def api_auth_required_for_path(path): - """Return True when the API path must be protected.""" +def api_auth_required_for_path(path, method=None): + """Return True when global API authentication must protect the path.""" if not path.startswith("/api/"): return False - if path in PUBLIC_API_PATHS: - return False - - if is_public_calendar_feed_path(path): - return False - - for prefix in PUBLIC_API_PREFIXES: - if path.startswith(prefix): - return False - - return True - - -PUBLIC_CALENDAR_FEED_PREFIX = "/api/calendar/feeds/" - - -def is_public_calendar_feed_path(path): - """Return True for tokenized public ICS subscription URLs only. - - Management endpoints stay protected: - - /api/calendar/feeds - - /api/calendar/feeds//token - - Public endpoint: - - /api/calendar/feeds/.ics - """ - if request.method not in {"GET", "HEAD"}: - return False - - if not path.startswith(PUBLIC_CALENDAR_FEED_PREFIX): - return False + method = method or request.method + return not is_public_api_request(path, method) - rest = path[len(PUBLIC_CALENDAR_FEED_PREFIX):] - return bool(rest) and rest.endswith(".ics") and "/" not in rest +def is_public_calendar_feed_path(path, method=None): + """Compatibility wrapper for the tokenized public ICS feed check.""" + return is_public_calendar_feed_request(path, method or request.method) def required_scopes_for_request(): - """ - Return required API token scopes for the current request. + """Return configured API-token scopes for the current request. - JWT users do not use this. It is only checked for personal/API tokens. + None means the endpoint has no scope mapping and must fail closed for an + API-token principal. JWT users are unaffected by this mapping. """ - - path = request.path - method = request.method - - if path.startswith("/api/alerts"): - return ["alerts:read"] if method == "GET" else ["alerts:write"] - - if path.startswith("/api/profile"): - return ["profile:read"] if method == "GET" else ["profile:write"] - - if path.startswith("/api/calendar"): - return ["resources:read"] - - if path.startswith("/api/heartbeats"): - return ["resources:read"] if method == "GET" else ["resources:write"] - - if ( - path.startswith("/api/groups") - or path.startswith("/api/teams") - or path.startswith("/api/rotations") - or path.startswith("/api/routes") - or path.startswith("/api/channels") - or path.startswith("/api/silences") - or path.startswith("/api/users") - or path.startswith("/api/admin/users") - ): - return ["resources:read"] if method == "GET" else ["resources:write"] - - return [] + return required_scopes_for_path(request.path, request.method) def api_token_has_scopes(api_token, required_scopes): - """ - Return True when an API token has the required scopes. - """ - - if not required_scopes: - return True - - scopes = api_token.scopes or [] - - if "*" in scopes: - return True - - return all(scope in scopes for scope in required_scopes) - + """Return True when an API token has the required effective scopes.""" + return token_has_scopes(api_token.scopes or [], required_scopes) def load_api_token_principal(): """ @@ -196,8 +120,16 @@ def load_api_token_principal(): required_scopes = required_scopes_for_request() + if required_scopes is None: + return jsonify({ + "error": "API token access is not configured for this endpoint", + }), 403 + if not api_token_has_scopes(api_token, required_scopes): - return jsonify({"error": "Missing API token scope", "missing_scopes": required_scopes}), 403 + return jsonify({ + "error": "Missing API token scope", + "missing_scopes": required_scopes, + }), 403 return api_token diff --git a/app/migrations/20260522000001_rotation_layers.py b/app/migrations/20260522000001_rotation_layers.py index 4162c18..e0ff345 100644 --- a/app/migrations/20260522000001_rotation_layers.py +++ b/app/migrations/20260522000001_rotation_layers.py @@ -8,6 +8,7 @@ RotationLayerMember, RotationLayerRestriction, ) +from app.modules.common import utc_now db = init_database() @@ -40,7 +41,7 @@ def upgrade(): "handoff_weekday": rotation.handoff_weekday, "timezone": rotation.timezone, "enabled": rotation.enabled, - "created_at": datetime.utcnow(), + "created_at": utc_now(), }, ) diff --git a/app/migrations/20260530000001_oncall_shift_email_notifications.py b/app/migrations/20260530000001_oncall_shift_email_notifications.py index b516e44..c58d8f8 100644 --- a/app/migrations/20260530000001_oncall_shift_email_notifications.py +++ b/app/migrations/20260530000001_oncall_shift_email_notifications.py @@ -18,6 +18,7 @@ ) from app.modules.db.migrator import get_migrator from app.modules.db.models import BaseModel, Rotation, User +from app.modules.common import utc_now db = init_database() @@ -59,9 +60,9 @@ class OnCallShiftEmailNotificationMigration(BaseModel): status = CharField(default="pending", index=True) last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) sent_at = DateTimeField(null=True) - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_shift_email_notification" diff --git a/app/migrations/20260604000004_alert_groups_repair_and_backfill.py b/app/migrations/20260604000004_alert_groups_repair_and_backfill.py index 20b2faf..db6889c 100644 --- a/app/migrations/20260604000004_alert_groups_repair_and_backfill.py +++ b/app/migrations/20260604000004_alert_groups_repair_and_backfill.py @@ -19,6 +19,7 @@ AlertGroupMerge, AlertNotification, ) +from app.modules.common import utc_now db = init_database() @@ -194,7 +195,7 @@ def _backfill_alert_groups(): for alert in alerts: group_key = alert.group_key or alert.dedup_key or f"alert:{alert.id}" - now = datetime.utcnow() + now = utc_now() first_seen_at = alert.first_seen_at or now last_seen_at = alert.last_seen_at or first_seen_at status = alert.status or "firing" diff --git a/app/migrations/20260606000002_incident_management.py b/app/migrations/20260606000002_incident_management.py index af454b0..863bb41 100644 --- a/app/migrations/20260606000002_incident_management.py +++ b/app/migrations/20260606000002_incident_management.py @@ -17,6 +17,7 @@ IncidentStakeholder, MaintenanceWindowScope, ) +from app.modules.common import utc_now INCIDENT_TABLE_MODELS = [ @@ -236,7 +237,7 @@ def _seed_default_priorities(): if not _table_exists("incident_priority"): return - now = datetime.utcnow() + now = utc_now() placeholder = _placeholder() select_sql = ( diff --git a/app/migrations/20260719090000_event_orchestration_models.py b/app/migrations/20260719090000_event_orchestration_models.py new file mode 100644 index 0000000..c397e74 --- /dev/null +++ b/app/migrations/20260719090000_event_orchestration_models.py @@ -0,0 +1,39 @@ +"""Create Event Orchestration v1 control-plane tables.""" + +from app.db import init_database +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, +) + + +db = init_database() + + +def upgrade(): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + ], + safe=True, + ) + + +def downgrade(): + db.drop_tables( + [ + OrchestrationExecution, + OrchestrationIntakeToken, + EventOrchestrationRule, + EventOrchestrationVersion, + EventOrchestration, + ], + safe=True, + ) diff --git a/app/migrations/20260720090000_event_orchestration_runtime.py b/app/migrations/20260720090000_event_orchestration_runtime.py new file mode 100644 index 0000000..0a5ebb8 --- /dev/null +++ b/app/migrations/20260720090000_event_orchestration_runtime.py @@ -0,0 +1,95 @@ +"""Add runtime rollout state and persisted notification-policy overrides.""" + +from peewee import CharField, IntegerField, SqliteDatabase +from playhouse.migrate import SchemaMigrator, migrate + +from app.db import init_database +from app.migrations.introspection import get_columns, get_indexes, table_exists + + +db = init_database() +RUNTIME_COLUMNS = { + "event_orchestration": { + "compatibility_mode": lambda: CharField( + max_length=32, + default="legacy", + ), + }, + "alert_group": { + "notification_policy_id": lambda: IntegerField(null=True), + }, + "alert": { + "notification_policy_id": lambda: IntegerField(null=True), + }, +} +RUNTIME_INDEXES = ( + ("event_orchestration", ("group_id", "compatibility_mode", "enabled")), + ("alert_group", ("notification_policy_id",)), + ("alert", ("notification_policy_id",)), +) + + +def _columns(table): + if not table_exists(db, table): + return set() + return {column.name for column in get_columns(db, table)} + + +def _indexes(table): + if not table_exists(db, table): + return [] + return list(get_indexes(db, table)) + + +def _has_index(table, columns): + expected = tuple(columns) + return any(tuple(index.columns) == expected for index in _indexes(table)) + + +def upgrade(): + migrator = SchemaMigrator.from_database(db) + operations = [] + + for table, columns in RUNTIME_COLUMNS.items(): + if not table_exists(db, table): + continue + existing = _columns(table) + for name, field_factory in columns.items(): + if name not in existing: + operations.append( + migrator.add_column(table, name, field_factory()) + ) + + if operations: + migrate(*operations) + + for table, columns in RUNTIME_INDEXES: + if table_exists(db, table) and not _has_index(table, columns): + migrate(migrator.add_index(table, columns, unique=False)) + + +def downgrade(): + migrator = SchemaMigrator.from_database(db) + + for table, columns in reversed(tuple(RUNTIME_COLUMNS.items())): + if not table_exists(db, table): + continue + removed_columns = set(columns) + for index in _indexes(table): + if removed_columns.intersection(index.columns): + migrate(migrator.drop_index(table, index.name)) + + for table, columns in reversed(tuple(RUNTIME_COLUMNS.items())): + existing = _columns(table) + for name in reversed(tuple(columns)): + if name not in existing: + continue + if isinstance(db, SqliteDatabase): + operation = migrator.drop_column( + table, + name, + legacy=True, + ) + else: + operation = migrator.drop_column(table, name) + migrate(operation) diff --git a/app/migrations/20260721100000_event_orchestration_dispositions.py b/app/migrations/20260721100000_event_orchestration_dispositions.py new file mode 100644 index 0000000..a064e2c --- /dev/null +++ b/app/migrations/20260721100000_event_orchestration_dispositions.py @@ -0,0 +1,87 @@ +"""Add suppress/drop/pause persistence for Event Orchestration.""" + +from peewee import BooleanField, TextField, SqliteDatabase +from playhouse.migrate import SchemaMigrator, migrate + +from app.db import init_database +from app.migrations.introspection import get_columns, get_indexes, table_exists +from app.modules.db.models import PendingOrchestratedEvent + + +db = init_database() +COLUMNS = { + "alert_group": { + "orchestration_suppressed": lambda: BooleanField(default=False), + "orchestration_suppress_reason": lambda: TextField(null=True), + }, + "alert": { + "orchestration_suppressed": lambda: BooleanField(default=False), + "orchestration_suppress_reason": lambda: TextField(null=True), + }, +} +INDEXES = ( + ("alert_group", ("orchestration_suppressed",)), + ("alert", ("orchestration_suppressed",)), +) + + +def _columns(table): + if not table_exists(db, table): + return set() + return {column.name for column in get_columns(db, table)} + + +def _indexes(table): + if not table_exists(db, table): + return [] + return list(get_indexes(db, table)) + + +def _has_index(table, columns): + expected = tuple(columns) + return any(tuple(index.columns) == expected for index in _indexes(table)) + + +def upgrade(): + migrator = SchemaMigrator.from_database(db) + operations = [] + for table, columns in COLUMNS.items(): + if not table_exists(db, table): + continue + existing = _columns(table) + for name, factory in columns.items(): + if name not in existing: + operations.append(migrator.add_column(table, name, factory())) + if operations: + migrate(*operations) + + for table, columns in INDEXES: + if table_exists(db, table) and not _has_index(table, columns): + migrate(migrator.add_index(table, columns, unique=False)) + + db.create_tables([PendingOrchestratedEvent], safe=True) + + +def downgrade(): + if table_exists(db, PendingOrchestratedEvent._meta.table_name): + db.drop_tables([PendingOrchestratedEvent], safe=True) + + migrator = SchemaMigrator.from_database(db) + for table, columns in reversed(tuple(COLUMNS.items())): + if not table_exists(db, table): + continue + removed = set(columns) + for index in _indexes(table): + if removed.intersection(index.columns): + migrate(migrator.drop_index(table, index.name)) + + for table, columns in reversed(tuple(COLUMNS.items())): + existing = _columns(table) + for name in reversed(tuple(columns)): + if name not in existing: + continue + if isinstance(db, SqliteDatabase): + operation = migrator.drop_column(table, name, legacy=True) + else: + operation = migrator.drop_column(table, name) + migrate(operation) diff --git a/app/migrations/20260721110000_event_orchestration_webhooks.py b/app/migrations/20260721110000_event_orchestration_webhooks.py new file mode 100644 index 0000000..868e89b --- /dev/null +++ b/app/migrations/20260721110000_event_orchestration_webhooks.py @@ -0,0 +1,19 @@ +"""Add asynchronous outbound webhook actions for Event Orchestration.""" + +from app.db import init_database +from app.migrations.introspection import table_exists +from app.modules.db.models import AutomationExecution, OrchestrationWebhookAction + + +db = init_database() +MODELS = (OrchestrationWebhookAction, AutomationExecution) + + +def upgrade(): + db.create_tables(list(MODELS), safe=True) + + +def downgrade(): + for model in reversed(MODELS): + if table_exists(db, model._meta.table_name): + db.drop_tables([model], safe=True) diff --git a/app/migrations/20260801090000_silence_retroactive.py b/app/migrations/20260801090000_silence_retroactive.py new file mode 100644 index 0000000..32ca7db --- /dev/null +++ b/app/migrations/20260801090000_silence_retroactive.py @@ -0,0 +1,91 @@ +"""Add optional retroactive Silence application and persisted alert links.""" + +from peewee import BooleanField, DateTimeField, SqliteDatabase +from playhouse.migrate import migrate + +from app.db import init_database +from app.migrations.introspection import get_columns, get_indexes, table_exists +from app.modules.db.migrator import get_migrator +from app.modules.db.models import Silence, SilenceAlertApplication + + +db = init_database() +migrator = get_migrator(db) +SILENCE_COLUMNS = ( + ("apply_to_existing", lambda: BooleanField(default=False)), + ("reconciled_at", lambda: DateTimeField(null=True)), + ("updated_at", lambda: DateTimeField(null=True)), +) + + +def _column_names(table_name: str) -> set[str]: + if not table_exists(db, table_name): + return set() + return {column.name for column in get_columns(db, table_name)} + + +def _has_index(table_name: str, columns: tuple[str, ...]) -> bool: + if not table_exists(db, table_name): + return False + return any(tuple(index.columns) == columns for index in get_indexes(db, table_name)) + + +def upgrade() -> None: + table_name = Silence._meta.table_name + columns = _column_names(table_name) + operations = [] + + for column_name, field_factory in SILENCE_COLUMNS: + if column_name not in columns: + operations.append( + migrator.add_column( + table_name, + column_name, + field_factory(), + ) + ) + + if operations: + migrate(*operations) + + if table_exists(db, table_name): + ( + Silence.update(updated_at=Silence.created_at) + .where(Silence.updated_at.is_null(True)) + .execute() + ) + if not _has_index(table_name, ("reconciled_at",)): + migrate( + migrator.add_index( + table_name, + ("reconciled_at",), + unique=False, + ) + ) + + db.create_tables([SilenceAlertApplication], safe=True) + + +def downgrade() -> None: + db.drop_tables([SilenceAlertApplication], safe=True) + + table_name = Silence._meta.table_name + columns = _column_names(table_name) + removed_columns = {column_name for column_name, _ in SILENCE_COLUMNS} + if table_exists(db, table_name): + for index in get_indexes(db, table_name): + if removed_columns.intersection(index.columns): + migrate(migrator.drop_index(table_name, index.name)) + + for column_name, _ in reversed(SILENCE_COLUMNS): + if column_name not in columns: + continue + if isinstance(db, SqliteDatabase): + operation = migrator.drop_column( + table_name, + column_name, + legacy=True, + ) + else: + operation = migrator.drop_column(table_name, column_name) + migrate(operation) diff --git a/app/migrations/20260801100000_silence_reactivate_on_end.py b/app/migrations/20260801100000_silence_reactivate_on_end.py new file mode 100644 index 0000000..bcdc74f --- /dev/null +++ b/app/migrations/20260801100000_silence_reactivate_on_end.py @@ -0,0 +1,50 @@ +"""Add per-Silence automatic reactivation preference.""" + +from peewee import BooleanField, SqliteDatabase +from playhouse.migrate import migrate + +from app.db import init_database +from app.migrations.introspection import get_columns, table_exists +from app.modules.db.migrator import get_migrator +from app.modules.db.models import Silence + + +db = init_database() +migrator = get_migrator(db) +COLUMN_NAME = "reactivate_on_end" + + +def _column_names(table_name: str) -> set[str]: + if not table_exists(db, table_name): + return set() + return {column.name for column in get_columns(db, table_name)} + + +def upgrade() -> None: + table_name = Silence._meta.table_name + if COLUMN_NAME in _column_names(table_name): + return + + migrate( + migrator.add_column( + table_name, + COLUMN_NAME, + BooleanField(default=True), + ) + ) + + +def downgrade() -> None: + table_name = Silence._meta.table_name + if COLUMN_NAME not in _column_names(table_name): + return + + if isinstance(db, SqliteDatabase): + operation = migrator.drop_column( + table_name, + COLUMN_NAME, + legacy=True, + ) + else: + operation = migrator.drop_column(table_name, COLUMN_NAME) + migrate(operation) diff --git a/app/migrations/20260803000001_user_ui_preferences.py b/app/migrations/20260803000001_user_ui_preferences.py new file mode 100644 index 0000000..8eb4783 --- /dev/null +++ b/app/migrations/20260803000001_user_ui_preferences.py @@ -0,0 +1,63 @@ +"""Add user locale and theme preferences.""" + +from peewee import CharField +from playhouse.migrate import migrate + +from app.db import init_database +from app.migrations.introspection import get_columns as migration_get_columns +from app.modules.db.migrator import get_migrator +from app.modules.db.models import User + + +db = init_database() +migrator = get_migrator(db) + + +def table_has_column(table_name: str, column_name: str) -> bool: + """Return True when a table already contains the requested column.""" + return any( + column.name == column_name + for column in migration_get_columns(db, table_name) + ) + + +def upgrade() -> None: + """Add nullable locale and system-default theme preferences.""" + user_table = User._meta.table_name + operations = [] + + if not table_has_column(user_table, "locale"): + operations.append( + migrator.add_column( + user_table, + "locale", + CharField(null=True), + ) + ) + + if not table_has_column(user_table, "theme"): + operations.append( + migrator.add_column( + user_table, + "theme", + CharField(default="system"), + ) + ) + + if operations: + migrate(*operations) + + +def downgrade() -> None: + """Remove user locale and theme preferences.""" + user_table = User._meta.table_name + operations = [] + + if table_has_column(user_table, "theme"): + operations.append(migrator.drop_column(user_table, "theme")) + + if table_has_column(user_table, "locale"): + operations.append(migrator.drop_column(user_table, "locale")) + + if operations: + migrate(*operations) diff --git a/app/migrations/20260805070000_maintenance_retroactive.py b/app/migrations/20260805070000_maintenance_retroactive.py new file mode 100644 index 0000000..6e5b70d --- /dev/null +++ b/app/migrations/20260805070000_maintenance_retroactive.py @@ -0,0 +1,75 @@ +"""Add retroactive maintenance lifecycle settings and application tracking.""" + +from peewee import BooleanField, DateTimeField +from playhouse.migrate import migrate + +from app.db import init_database +from app.migrations.introspection import get_columns as migration_get_columns +from app.modules.db.migrator import get_migrator +from app.modules.db.models import ( + MaintenanceWindow, + MaintenanceWindowAlertApplication, +) + + +db = init_database() +migrator = get_migrator(db) + + +def _has_column(table_name: str, column_name: str) -> bool: + return any( + column.name == column_name + for column in migration_get_columns(db, table_name) + ) + + +def upgrade(): + """Add per-window lifecycle flags and the applied-effects table.""" + table_name = MaintenanceWindow._meta.table_name + operations = [] + + if not _has_column(table_name, "apply_to_existing"): + operations.append( + migrator.add_column( + table_name, + "apply_to_existing", + BooleanField(default=False), + ) + ) + + if not _has_column(table_name, "reactivate_on_end"): + operations.append( + migrator.add_column( + table_name, + "reactivate_on_end", + BooleanField(default=True), + ) + ) + + if not _has_column(table_name, "reconciled_at"): + operations.append( + migrator.add_column( + table_name, + "reconciled_at", + DateTimeField(null=True), + ) + ) + + if operations: + migrate(*operations) + + db.create_tables([MaintenanceWindowAlertApplication], safe=True) + + +def downgrade(): + """Remove retroactive maintenance lifecycle storage.""" + db.drop_tables([MaintenanceWindowAlertApplication], safe=True) + + table_name = MaintenanceWindow._meta.table_name + operations = [] + for column_name in ("reconciled_at", "reactivate_on_end", "apply_to_existing"): + if _has_column(table_name, column_name): + operations.append(migrator.drop_column(table_name, column_name)) + + if operations: + migrate(*operations) diff --git a/app/modules/common.py b/app/modules/common.py index da29014..9896726 100644 --- a/app/modules/common.py +++ b/app/modules/common.py @@ -1,4 +1,6 @@ +import datetime as dt from datetime import datetime, timezone as dt_timezone +from zoneinfo import ZoneInfo class SafeFormatDict(dict): @@ -46,6 +48,66 @@ def as_utc_aware(value): return value.astimezone(dt_timezone.utc) +def as_utc_naive(value): + """Return naive UTC datetime from datetime or ISO string. + + IncidentRelay stores timestamps as naive UTC values. Naive inputs are + therefore interpreted as UTC for backward compatibility. + """ + value = as_utc_aware(value) + + if value is None: + return None + + return value.replace(tzinfo=None) + + +def as_utc_naive_seconds(value): + """Return naive UTC datetime normalized to whole seconds.""" + value = as_utc_naive(value) + + if value is None: + return None + + return value.replace(microsecond=0) + + +def timezone_or_utc(timezone_name): + """Return requested IANA timezone, falling back to UTC.""" + try: + return ZoneInfo(str(timezone_name or "UTC")) + except Exception: + return ZoneInfo("UTC") + + +def local_datetime_to_utc_naive(value, timezone_name): + """Convert a local wall-clock datetime to IncidentRelay naive UTC. + + Naive values are interpreted in ``timezone_name``. Aware values already + represent an absolute instant and are converted directly to UTC. + """ + value = parse_datetime(value) + + if value is None: + return None + + if value.tzinfo is not None: + return value.astimezone(dt_timezone.utc).replace(tzinfo=None) + + zone = timezone_or_utc(timezone_name) + return value.replace(tzinfo=zone).astimezone(dt_timezone.utc).replace(tzinfo=None) + + +def utc_datetime_to_local_naive(value, timezone_name): + """Convert an absolute UTC datetime to naive local wall-clock time.""" + value = as_utc_aware(value) + + if value is None: + return None + + return value.astimezone(timezone_or_utc(timezone_name)).replace(tzinfo=None) + + def as_naive_datetime(value): """Return naive wall-clock datetime. @@ -70,3 +132,20 @@ def truncate_text(value, limit=500): return value return value[: limit - 1].rstrip() + "…" + + + +UTC = getattr(dt, "UTC", dt.timezone.utc) + + +def utc_now() -> dt.datetime: + """Return current UTC time without tzinfo. + + IncidentRelay stores timestamps as naive UTC values. + """ + return dt.datetime.now(UTC).replace(tzinfo=None) + + +def utc_now_seconds() -> dt.datetime: + """Return current naive UTC time normalized to whole seconds.""" + return utc_now().replace(microsecond=0) diff --git a/app/modules/crypto.py b/app/modules/crypto.py new file mode 100644 index 0000000..3dcc07f --- /dev/null +++ b/app/modules/crypto.py @@ -0,0 +1,67 @@ +"""Project-wide encryption helpers for secrets stored in the database.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from typing import Any + +from cryptography.fernet import Fernet + +from app.settings import Config + + +def _fernet(raw_key: str | None = None) -> Fernet: + """Build a stable Fernet key from an explicit or application secret.""" + raw_key = ( + raw_key + or getattr(Config, "SECRET_ENCRYPTION_KEY", None) + or Config.SECRET_KEY + ) + digest = hashlib.sha256(str(raw_key).encode("utf-8")).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt_secret(value: str | None, *, key: str | None = None) -> str | None: + """Encrypt a UTF-8 string for database storage.""" + if value in (None, ""): + return None + return _fernet(key).encrypt(str(value).encode("utf-8")).decode("utf-8") + + +def decrypt_secret(value: str | None, *, key: str | None = None) -> str | None: + """Decrypt a UTF-8 string previously produced by :func:`encrypt_secret`.""" + if not value: + return None + return _fernet(key).decrypt(value.encode("utf-8")).decode("utf-8") + + +def encrypt_json(value: Any) -> str | None: + """Serialize and encrypt a JSON-compatible value.""" + if value is None: + return None + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + return encrypt_secret(encoded) + + +def decrypt_json(value: str | None, *, default: Any = None) -> Any: + """Decrypt and deserialize a JSON-compatible value.""" + plaintext = decrypt_secret(value) + if plaintext is None: + return default + return json.loads(plaintext) + + +__all__ = [ + "decrypt_json", + "decrypt_secret", + "encrypt_json", + "encrypt_secret", +] diff --git a/app/modules/db/alerts_repo.py b/app/modules/db/alerts_repo.py index 4cce192..8064acf 100644 --- a/app/modules/db/alerts_repo.py +++ b/app/modules/db/alerts_repo.py @@ -26,6 +26,7 @@ apply_field_values_filter, normalize_filter_values, ) +from app.modules.common import utc_now MAX_ALERTS_PAGE_SIZE = 100 @@ -524,7 +525,7 @@ def _collect_group_labels(alerts): def recalculate_alert_group(group): """Recalculate alert group counters, labels and effective status.""" - now = datetime.utcnow() + now = utc_now() alerts = list( Alert @@ -632,7 +633,7 @@ def acknowledge_alert_group(group_id, user_id=None): group.previous_status = group.status group.status = "acknowledged" group.acknowledged_by = user_id - group.acknowledged_at = datetime.utcnow() + group.acknowledged_at = utc_now() group.save() return group @@ -640,7 +641,7 @@ def acknowledge_alert_group(group_id, user_id=None): def resolve_alert_group(group_id, user_id=None): """Resolve alert group and all child alerts.""" - now = datetime.utcnow() + now = utc_now() group = AlertGroup.get_by_id(group_id) ( @@ -709,7 +710,7 @@ def merge_alert_groups(target_group_id, source_group_ids, user_id=None, reason=N """Merge source groups into target group.""" target = AlertGroup.get_by_id(target_group_id) - now = datetime.utcnow() + now = utc_now() for source_id in source_group_ids: if int(source_id) == int(target_group_id): @@ -836,12 +837,12 @@ def update_alert_from_payload(alert, alert_data, status, group_key): """ Update an alert from normalized payload data. """ - now = datetime.utcnow() + now = utc_now() alert.last_seen_at = now previous_status = alert.status alert.previous_status = previous_status - alert.last_seen_at = datetime.utcnow() + alert.last_seen_at = utc_now() alert.payload = alert_data.get("payload") alert.labels = alert_data.get("labels") alert.message = alert_data.get("message") @@ -894,7 +895,7 @@ def schedule_alert_group_notification(group, due_at, reason="notification"): group.notification_pending = True group.notification_due_at = due_at group.notification_reason = reason - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save() return group @@ -906,7 +907,7 @@ def clear_alert_group_notification(group): group.notification_pending = False group.notification_due_at = None group.notification_reason = None - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save() return group @@ -915,7 +916,7 @@ def clear_alert_group_notification(group): def mark_alert_group_notification_sent(group, now=None): """Mark group notification as sent.""" - now = now or datetime.utcnow() + now = now or utc_now() group.notification_pending = False group.notification_due_at = None @@ -930,7 +931,7 @@ def mark_alert_group_notification_sent(group, now=None): def list_due_alert_group_notifications(now=None, limit=100): """Return groups with due pending notifications.""" - now = now or datetime.utcnow() + now = now or utc_now() return list( AlertGroup @@ -962,8 +963,8 @@ def create_alert_comment( alert=alert_id, user=user_id, body=body, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -1016,8 +1017,8 @@ def soft_delete_alert_comment(comment_id: int) -> bool: AlertComment .update( deleted=True, - deleted_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + deleted_at=utc_now(), + updated_at=utc_now(), ) .where( AlertComment.id == comment_id, @@ -1043,7 +1044,7 @@ def update_alert_comment( return None comment.body = body - comment.updated_at = datetime.utcnow() + comment.updated_at = utc_now() comment.save(only=[ AlertComment.body, AlertComment.updated_at, @@ -1160,7 +1161,7 @@ def finish_alert_explain_trace( trace.outcome = outcome trace.reason = reason trace.result = result or {} - trace.finished_at = finished_at or datetime.utcnow() + trace.finished_at = finished_at or utc_now() trace.save( only=[ diff --git a/app/modules/db/audit_repo.py b/app/modules/db/audit_repo.py index 724f776..31d385e 100644 --- a/app/modules/db/audit_repo.py +++ b/app/modules/db/audit_repo.py @@ -1,10 +1,25 @@ -from app.modules.db.models import AuditLog +from datetime import timedelta -def create_audit_log(action, object_type=None, object_id=None, group_id=None, team_id=None, user_id=None, api_token_id=None, message=None, data=None): - """ - Create an audit log entry. - """ +from app.modules.db.models import AuditLog, Team, User + + +DEFAULT_AUDIT_PAGE_SIZE = 25 +MAX_AUDIT_PAGE_SIZE = 100 + + +def create_audit_log( + action, + object_type=None, + object_id=None, + group_id=None, + team_id=None, + user_id=None, + api_token_id=None, + message=None, + data=None, +): + """Create an audit log entry.""" return AuditLog.create( group=group_id, @@ -19,11 +34,201 @@ def create_audit_log(action, object_type=None, object_id=None, group_id=None, te ) -def list_audit_logs(team_id=None, limit=300): - """ - Return audit log entries. +def audit_scope_condition(group_ids): + """Return a query condition for audit entries owned by groups. + + An audit record belongs to a group either directly through ``group_id`` or + indirectly through a team in that group. Records without either relation + are global and therefore intentionally excluded from group-editor scope. """ + normalized_ids = sorted({int(group_id) for group_id in group_ids or []}) + if not normalized_ids: + return AuditLog.id == -1 + + team_ids = Team.select(Team.id).where(Team.group.in_(normalized_ids)) + return ( + AuditLog.group.in_(normalized_ids) + | AuditLog.team.in_(team_ids) + ) + + +def scoped_audit_query(group_ids=None): + """Return the base audit query, optionally restricted to group scope.""" + + query = AuditLog.select() + if group_ids is not None: + query = query.where(audit_scope_condition(group_ids)) + return query + + +def filter_audit_query( + query, + *, + search=None, + group_id=None, + actor_id=None, + action=None, + object_type=None, + date_from=None, + date_to=None, +): + """Apply supported audit-log filters to a Peewee query.""" + + if group_id: + query = query.where(audit_scope_condition([group_id])) + + if actor_id: + query = query.where(AuditLog.user == actor_id) + + if action: + query = query.where(AuditLog.action == action) + + if object_type: + query = query.where(AuditLog.object_type == object_type) + + if date_from: + query = query.where(AuditLog.created_at >= date_from) + + if date_to: + query = query.where(AuditLog.created_at < date_to + timedelta(days=1)) + + normalized_search = str(search or "").strip() + if normalized_search: + actor_ids = User.select(User.id).where( + User.username.contains(normalized_search) + | User.display_name.contains(normalized_search) + ) + query = query.where( + AuditLog.action.contains(normalized_search) + | AuditLog.object_type.contains(normalized_search) + | AuditLog.message.contains(normalized_search) + | AuditLog.user.in_(actor_ids) + ) + + return query + + +def paginate_audit_query(query, page=1, page_size=DEFAULT_AUDIT_PAGE_SIZE): + """Return a normalized paginated response for an audit query.""" + + try: + normalized_page = max(1, int(page or 1)) + except (TypeError, ValueError): + normalized_page = 1 + + try: + normalized_page_size = int(page_size or DEFAULT_AUDIT_PAGE_SIZE) + except (TypeError, ValueError): + normalized_page_size = DEFAULT_AUDIT_PAGE_SIZE + + normalized_page_size = min( + MAX_AUDIT_PAGE_SIZE, + max(1, normalized_page_size), + ) + + total_items = query.count() + total_pages = max( + 1, + (total_items + normalized_page_size - 1) // normalized_page_size, + ) + normalized_page = min(normalized_page, total_pages) + + ordered_query = query.order_by( + AuditLog.created_at.desc(), + AuditLog.id.desc(), + ) + items = list(ordered_query.paginate(normalized_page, normalized_page_size)) + + page_from = 0 + page_to = 0 + if total_items: + page_from = ((normalized_page - 1) * normalized_page_size) + 1 + page_to = min(normalized_page * normalized_page_size, total_items) + + return { + "items": items, + "pagination": { + "page": normalized_page, + "page_size": normalized_page_size, + "total_items": total_items, + "total_pages": total_pages, + "from": page_from, + "to": page_to, + "has_previous": normalized_page > 1, + "has_next": normalized_page < total_pages, + }, + } + + +def audit_filter_options(query): + """Return distinct actions, object types and actor ids in scope.""" + + actions = [ + row.action + for row in ( + query + .select(AuditLog.action) + .where(AuditLog.action.is_null(False)) + .distinct() + .order_by(AuditLog.action.asc()) + ) + if row.action + ] + + object_types = [ + row.object_type + for row in ( + query + .select(AuditLog.object_type) + .where(AuditLog.object_type.is_null(False)) + .distinct() + .order_by(AuditLog.object_type.asc()) + ) + if row.object_type + ] + + actor_ids = [ + row.user_id + for row in ( + query + .select(AuditLog.user) + .where(AuditLog.user.is_null(False)) + .distinct() + ) + if row.user_id + ] + + return { + "actions": actions, + "object_types": object_types, + "actor_ids": actor_ids, + } + + +def audit_summary(query): + """Return aggregate values for the currently filtered audit query.""" + + total = query.count() + actor_count = ( + query + .select(AuditLog.user) + .where(AuditLog.user.is_null(False)) + .distinct() + .count() + ) + action_count = query.select(AuditLog.action).distinct().count() + + return { + "total": total, + "actors": actor_count, + "actions": action_count, + } + + +def list_audit_logs(team_id=None, limit=300): + """Return audit log entries for backwards-compatible internal callers.""" + query = AuditLog.select().order_by(AuditLog.id.desc()) if team_id: query = query.where(AuditLog.team == team_id) diff --git a/app/modules/db/business_services_repo.py b/app/modules/db/business_services_repo.py index e16acf7..21ba120 100644 --- a/app/modules/db/business_services_repo.py +++ b/app/modules/db/business_services_repo.py @@ -9,6 +9,7 @@ BusinessServiceStatusHistory, Service, ) +from app.modules.common import utc_now def set_business_service_manual_status( @@ -18,7 +19,7 @@ def set_business_service_manual_status( until=None, user_id=None, ): - now = datetime.utcnow() + now = utc_now() BusinessService.update( manual_status=manual_status, @@ -33,7 +34,7 @@ def set_business_service_manual_status( def clear_business_service_manual_status(business_service_id): - now = datetime.utcnow() + now = utc_now() BusinessService.update( manual_status=None, @@ -91,7 +92,7 @@ def list_business_services(group_id=None, public_only=False, active_only=True): def create_business_service(data): data = dict(data) - now = datetime.utcnow() + now = utc_now() data.setdefault("status", "unknown") data.setdefault("status_source", "calculated") data.setdefault("created_at", now) @@ -105,7 +106,7 @@ def update_business_service(business_service_id, data): for field, value in data.items(): setattr(business_service, field, value) - business_service.updated_at = datetime.utcnow() + business_service.updated_at = utc_now() business_service.save() return business_service @@ -113,7 +114,7 @@ def update_business_service(business_service_id, data): def soft_delete_business_service(business_service_id): business_service = get_business_service(business_service_id) - now = datetime.utcnow() + now = utc_now() business_service.deleted = True business_service.deleted_at = now business_service.enabled = False @@ -192,7 +193,7 @@ def list_components_for_service(service_id, active_only=True): def create_business_service_component(business_service_id, data): data = dict(data) - now = datetime.utcnow() + now = utc_now() data["business_service"] = business_service_id data.setdefault("created_at", now) data["updated_at"] = now @@ -205,7 +206,7 @@ def update_business_service_component(component_id, data): for field, value in data.items(): setattr(component, field, value) - component.updated_at = datetime.utcnow() + component.updated_at = utc_now() component.save() return component @@ -254,7 +255,7 @@ def count_components_by_business_service_ids(business_service_ids, active_only=T def soft_delete_business_service_component(component_id): component = BusinessServiceComponent.get_by_id(component_id) - now = datetime.utcnow() + now = utc_now() component.deleted = True component.deleted_at = now component.enabled = False @@ -313,7 +314,7 @@ def upsert_incident_impact( reason=None, component_snapshot=None, ): - now = datetime.utcnow() + now = utc_now() impact, created = BusinessServiceIncidentImpact.get_or_create( business_service=business_service.id, @@ -349,7 +350,7 @@ def upsert_incident_impact( def deactivate_incident_impacts_for_group(group_id): - now = datetime.utcnow() + now = utc_now() return ( BusinessServiceIncidentImpact diff --git a/app/modules/db/calendar_feeds_repo.py b/app/modules/db/calendar_feeds_repo.py index e89c13f..22c7b95 100644 --- a/app/modules/db/calendar_feeds_repo.py +++ b/app/modules/db/calendar_feeds_repo.py @@ -3,6 +3,7 @@ from datetime import datetime from app.modules.db.models import CalendarFeed +from app.modules.common import utc_now def list_calendar_feeds(team_id): @@ -62,13 +63,13 @@ def update_calendar_feed(feed, **fields): def mark_calendar_feed_used(feed): - feed.last_used_at = datetime.utcnow() + feed.last_used_at = utc_now() feed.save(only=[CalendarFeed.last_used_at]) def soft_delete_calendar_feed(feed): feed.deleted = True - feed.deleted_at = datetime.utcnow() + feed.deleted_at = utc_now() feed.enabled = False feed.save( only=[ diff --git a/app/modules/db/channels_repo.py b/app/modules/db/channels_repo.py index 064781e..5681af6 100644 --- a/app/modules/db/channels_repo.py +++ b/app/modules/db/channels_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import AlertRouteChannel, Group, NotificationChannel, Team +from app.modules.common import utc_now def list_channels( @@ -154,7 +155,7 @@ def soft_delete_channel(channel_id): channel = get_channel(channel_id) channel.enabled = False channel.deleted = True - channel.deleted_at = datetime.utcnow() + channel.deleted_at = utc_now() channel.save() return channel diff --git a/app/modules/db/escalation_policies_repo.py b/app/modules/db/escalation_policies_repo.py index 3e19eca..7797ccd 100644 --- a/app/modules/db/escalation_policies_repo.py +++ b/app/modules/db/escalation_policies_repo.py @@ -10,6 +10,7 @@ TeamUser, User, ) +from app.modules.common import utc_now def list_policies(team_id=None, team_ids=None, enabled_only=False, include_deleted=False): @@ -74,7 +75,7 @@ def update_policy(policy_id, data): if field in data: setattr(policy, field, data[field]) - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -84,8 +85,8 @@ def soft_delete_policy(policy_id): policy = get_policy(policy_id) policy.enabled = False policy.deleted = True - policy.deleted_at = datetime.utcnow() - policy.updated_at = datetime.utcnow() + policy.deleted_at = utc_now() + policy.updated_at = utc_now() policy.save() return policy @@ -194,7 +195,7 @@ def update_rule(rule_id, data): elif rule.target_type == "user": rule.target_user = target_id - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule diff --git a/app/modules/db/groups_repo.py b/app/modules/db/groups_repo.py index 02121ef..dd47c9c 100644 --- a/app/modules/db/groups_repo.py +++ b/app/modules/db/groups_repo.py @@ -23,6 +23,7 @@ User, UserGroup, ) +from app.modules.common import utc_now def list_groups(active_only=False, include_deleted=False): @@ -172,7 +173,7 @@ def soft_delete_group(group_id): - users are not deleted; - group memberships are disabled. """ - now = datetime.utcnow() + now = utc_now() with db.atomic(): group = get_group(group_id) group.deleted = True diff --git a/app/modules/db/heartbeats_repo.py b/app/modules/db/heartbeats_repo.py index d6d7617..d7f8f17 100644 --- a/app/modules/db/heartbeats_repo.py +++ b/app/modules/db/heartbeats_repo.py @@ -4,6 +4,7 @@ from app.modules.db.models import Group, Heartbeat, HeartbeatInstance, HeartbeatPing, Service, Team from app.services.integrations.auth import hash_token +from app.modules.common import utc_now def list_heartbeats( @@ -192,13 +193,13 @@ def update_heartbeat(heartbeat, data): for key, value in data.items(): setattr(heartbeat, key, value) - heartbeat.updated_at = datetime.utcnow() + heartbeat.updated_at = utc_now() heartbeat.save() return heartbeat def soft_delete_heartbeat(heartbeat): - now = datetime.utcnow() + now = utc_now() heartbeat.deleted = True heartbeat.deleted_at = now heartbeat.enabled = False @@ -232,7 +233,7 @@ def record_ping( remote_addr=remote_addr, user_agent=user_agent, alert_group=alert_group_id, - received_at=received_at or datetime.utcnow(), + received_at=received_at or utc_now(), ) @@ -278,7 +279,7 @@ def create_heartbeat_instance(heartbeat, instance_key, **data): def update_heartbeat_instance(instance, data): for key, value in data.items(): setattr(instance, key, value) - instance.updated_at = datetime.utcnow() + instance.updated_at = utc_now() instance.save() return instance @@ -290,6 +291,6 @@ def delete_heartbeat_instance(instance): def disable_heartbeat_instance(instance, status="paused"): instance.enabled = False instance.status = status - instance.updated_at = datetime.utcnow() + instance.updated_at = utc_now() instance.save() return instance diff --git a/app/modules/db/incidents_repo.py b/app/modules/db/incidents_repo.py index 8bae181..e8ff5a0 100644 --- a/app/modules/db/incidents_repo.py +++ b/app/modules/db/incidents_repo.py @@ -8,6 +8,7 @@ ServiceOwner, ) from app.modules.db import alerts_repo +from app.modules.common import utc_now DEFAULT_PRIORITY_SLUG = "p3" @@ -142,8 +143,8 @@ def set_incident_priority(group_id, priority_slug, *, user_id=None, manual=True) group.priority_order = priority.level group.priority_set_manually = manual group.priority_set_by = user_id - group.priority_set_at = datetime.utcnow() - group.updated_at = datetime.utcnow() + group.priority_set_at = utc_now() + group.updated_at = utc_now() group.save(only=[ AlertGroup.priority, @@ -180,7 +181,7 @@ def reset_incident_priority(group_id, priority, *, update_mode=None): group.priority_set_manually = False group.priority_set_by = None group.priority_set_at = None - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save(only=[ AlertGroup.priority, @@ -242,7 +243,7 @@ def update_incident_responder_notification( .update( notification_status=status, notification_error=error, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where(IncidentResponder.id == responder_id) .execute() @@ -258,7 +259,7 @@ def create_incident_responder(group_id, data): expires_at = data.get("expires_at") if not expires_at and data.get("expires_after_minutes"): - expires_at = datetime.utcnow() + timedelta( + expires_at = utc_now() + timedelta( minutes=int(data["expires_after_minutes"]) ) @@ -275,10 +276,10 @@ def create_incident_responder(group_id, data): response_message=data.get("response_message"), notification_status=data.get("notification_status") or "pending", notification_error=data.get("notification_error"), - requested_at=datetime.utcnow(), + requested_at=utc_now(), expires_at=expires_at, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -309,8 +310,8 @@ def update_incident_responder_status( responder.status = status responder.response_message = response_message - responder.responded_at = datetime.utcnow() - responder.updated_at = datetime.utcnow() + responder.responded_at = utc_now() + responder.updated_at = utc_now() save_fields = [ IncidentResponder.status, @@ -333,7 +334,7 @@ def update_incident_responder_status( def list_expired_requested_responders(*, now=None, limit=100): """Return requested responder rows whose expiration time has passed.""" - now = now or datetime.utcnow() + now = now or utc_now() return list( IncidentResponder @@ -353,7 +354,7 @@ def list_expired_requested_responders(*, now=None, limit=100): def expire_incident_responder(responder_id, *, now=None): """Expire one responder request only if it is still requested.""" - now = now or datetime.utcnow() + now = now or utc_now() updated = ( IncidentResponder @@ -393,8 +394,8 @@ def create_incident_stakeholder(group_id, data): notify_on_comment=data.get("notify_on_comment", True), active=data.get("active", True), created_by=data.get("created_by_id"), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -426,7 +427,7 @@ def deactivate_incident_stakeholder(stakeholder_id): IncidentStakeholder .update( active=False, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( IncidentStakeholder.id == stakeholder_id, diff --git a/app/modules/db/locks_repo.py b/app/modules/db/locks_repo.py index 4665cf0..aaa65f2 100644 --- a/app/modules/db/locks_repo.py +++ b/app/modules/db/locks_repo.py @@ -3,6 +3,7 @@ from peewee import IntegrityError from app.modules.db.models import AppLock +from app.modules.common import utc_now def acquire_lock(name, owner, ttl_seconds): @@ -10,7 +11,7 @@ def acquire_lock(name, owner, ttl_seconds): Acquire a database-backed lock. """ - now = datetime.utcnow() + now = utc_now() expires_at = now + timedelta(seconds=ttl_seconds) try: diff --git a/app/modules/db/maintenance_repo.py b/app/modules/db/maintenance_repo.py index 84ba8be..c487bed 100644 --- a/app/modules/db/maintenance_repo.py +++ b/app/modules/db/maintenance_repo.py @@ -5,10 +5,14 @@ from dateutil.rrule import rrulestr from app.modules.db.models import ( + AlertGroup, MaintenanceWindow, + MaintenanceWindowAlertApplication, MaintenanceWindowScope, + Team, ) from app.modules.common import as_naive_datetime +from app.modules.common import utc_now ACTIVE_STATUSES = ("scheduled", "active") @@ -305,6 +309,8 @@ def create_maintenance_window( description=None, rrule=None, enabled=True, + apply_to_existing=False, + reactivate_on_end=True, status="scheduled", group=None, team=None, @@ -320,6 +326,8 @@ def create_maintenance_window( timezone=timezone, rrule=rrule, enabled=enabled, + apply_to_existing=apply_to_existing, + reactivate_on_end=reactivate_on_end, status=status, ) @@ -336,6 +344,9 @@ def update_maintenance_window(window, **fields): "timezone", "rrule", "enabled", + "apply_to_existing", + "reactivate_on_end", + "reconciled_at", "status", } @@ -364,7 +375,7 @@ def cancel_maintenance_window(window, *, cancelled_by=None, reason=None): window.status = "cancelled" window.enabled = False window.cancelled_by = cancelled_by - window.cancelled_at = datetime.utcnow() + window.cancelled_at = utc_now() window.cancel_reason = str(reason or "").strip() or None window.save() @@ -390,7 +401,7 @@ def replace_maintenance_window_scopes(window_id, scopes): team=scope.get("team_id"), service=scope.get("service_id"), route=scope.get("route_id"), - created_at=datetime.utcnow(), + created_at=utc_now(), ) ) @@ -406,50 +417,44 @@ def list_maintenance_window_scopes(window_id): ) -def find_active_maintenance_window( +def list_active_maintenance_windows( *, - group_id=None, - team_id=None, - service_id=None, - route_id=None, - now=None, -): + group_id: int | None = None, + team_id: int | None = None, + service_id: int | None = None, + route_id: int | None = None, + now: datetime | None = None, +) -> list[MaintenanceWindow]: + """Return every active window matching the supplied routing scope.""" query = ( MaintenanceWindow .select(MaintenanceWindow) .distinct() .join(MaintenanceWindowScope) .where( - MaintenanceWindow.deleted == False, - MaintenanceWindow.enabled == True, + MaintenanceWindow.deleted == False, # noqa: E712 + MaintenanceWindow.enabled == True, # noqa: E712 MaintenanceWindow.status.in_(ACTIVE_STATUSES), ) - .order_by( - MaintenanceWindow.starts_at.desc(), - MaintenanceWindow.id.desc(), - ) + .order_by(MaintenanceWindow.starts_at.desc(), MaintenanceWindow.id.desc()) ) conditions = [] - if group_id: conditions.append( (MaintenanceWindowScope.scope_type == "group") & (MaintenanceWindowScope.group == group_id) ) - if team_id: conditions.append( (MaintenanceWindowScope.scope_type == "team") & (MaintenanceWindowScope.team == team_id) ) - if service_id: conditions.append( (MaintenanceWindowScope.scope_type == "service") & (MaintenanceWindowScope.service == service_id) ) - if route_id: conditions.append( (MaintenanceWindowScope.scope_type == "route") @@ -457,15 +462,129 @@ def find_active_maintenance_window( ) if not conditions: - return None + return [] combined = conditions[0] + for condition in conditions[1:]: + combined = combined | condition + return [ + window + for window in query.where(combined) + if is_window_active_now(window, now=now) + ] + + +def list_unresolved_alert_groups_for_window( + window: MaintenanceWindow, + *, + limit: int | None = None, +) -> list[AlertGroup]: + """Return unresolved alert groups in any scope of one maintenance window.""" + conditions = [] + for scope in list_maintenance_window_scopes(window): + if scope.scope_type == "group" and scope.group_id: + team_ids = Team.select(Team.id).where(Team.group == scope.group_id) + conditions.append(AlertGroup.team.in_(team_ids)) + elif scope.scope_type == "team" and scope.team_id: + conditions.append(AlertGroup.team == scope.team_id) + elif scope.scope_type == "service" and scope.service_id: + conditions.append(AlertGroup.service == scope.service_id) + elif scope.scope_type == "route" and scope.route_id: + conditions.append(AlertGroup.route == scope.route_id) + + if not conditions: + return [] + + combined = conditions[0] for condition in conditions[1:]: combined = combined | condition - for window in query.where(combined): - if is_window_active_now(window, now=now): - return window + query = ( + AlertGroup + .select() + .where( + combined, + AlertGroup.status != "resolved", + AlertGroup.merged_into.is_null(True), + ) + .distinct() + .order_by(AlertGroup.id.asc()) + ) + if limit: + query = query.limit(limit) + return list(query) + + +def list_window_applications( + window: MaintenanceWindow, + *, + active_only: bool = False, +) -> list[MaintenanceWindowAlertApplication]: + query = ( + MaintenanceWindowAlertApplication + .select() + .where(MaintenanceWindowAlertApplication.maintenance_window == window.id) + .order_by(MaintenanceWindowAlertApplication.id.asc()) + ) + if active_only: + query = query.where(MaintenanceWindowAlertApplication.active == True) # noqa: E712 + return list(query) + + +def list_group_applications( + group: AlertGroup, + *, + active_only: bool = False, +) -> list[MaintenanceWindowAlertApplication]: + query = ( + MaintenanceWindowAlertApplication + .select() + .where(MaintenanceWindowAlertApplication.alert_group == group.id) + .order_by(MaintenanceWindowAlertApplication.applied_at.desc()) + ) + if active_only: + query = query.where(MaintenanceWindowAlertApplication.active == True) # noqa: E712 + return list(query) + + +def get_window_group_application( + window: MaintenanceWindow, + group: AlertGroup, +) -> MaintenanceWindowAlertApplication | None: + return MaintenanceWindowAlertApplication.get_or_none( + MaintenanceWindowAlertApplication.maintenance_window == window.id, + MaintenanceWindowAlertApplication.alert_group == group.id, + ) + - return None +def find_active_maintenance_window( + *, + group_id: int | None = None, + team_id: int | None = None, + service_id: int | None = None, + route_id: int | None = None, + now: datetime | None = None, +) -> MaintenanceWindow | None: + """Return the highest-priority active window for intake-time behavior.""" + windows = list_active_maintenance_windows( + group_id=group_id, + team_id=team_id, + service_id=service_id, + route_id=route_id, + now=now, + ) + + if not windows: + return None + + behavior_priority = { + "suppress_incident": 0, + "create_maintenance_incident": 1, + "suppress_notifications": 2, + "pause_escalation_only": 3, + } + return sorted( + windows, + key=lambda item: (behavior_priority.get(item.behavior, 99), -item.id), + )[0] diff --git a/app/modules/db/matcher_presets_repo.py b/app/modules/db/matcher_presets_repo.py index c744f1e..7eea941 100644 --- a/app/modules/db/matcher_presets_repo.py +++ b/app/modules/db/matcher_presets_repo.py @@ -12,6 +12,7 @@ Silence, ServiceRunbook, ) +from app.modules.common import utc_now def count_service_runbook_usages(preset_id): @@ -164,8 +165,8 @@ def create_matcher_preset( matchers=matchers or {}, enabled=enabled, version=1, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -187,7 +188,7 @@ def restore_matcher_preset( preset.version = int(preset.version or 0) + 1 preset.deleted = False preset.deleted_at = None - preset.updated_at = datetime.utcnow() + preset.updated_at = utc_now() preset.save() return preset @@ -209,7 +210,7 @@ def update_matcher_preset(preset_id, data): if field in allowed_fields: setattr(preset, field, value) - preset.updated_at = datetime.utcnow() + preset.updated_at = utc_now() preset.save() return preset @@ -315,7 +316,7 @@ def list_matcher_preset_usages(preset_id): def soft_delete_matcher_preset(preset_id): """Soft-delete a matcher preset.""" preset = get_matcher_preset(preset_id) - now = datetime.utcnow() + now = utc_now() preset.enabled = False preset.deleted = True diff --git a/app/modules/db/models.py b/app/modules/db/models.py index 92e1817..6b39aae 100644 --- a/app/modules/db/models.py +++ b/app/modules/db/models.py @@ -16,6 +16,7 @@ ) from app.db import database_proxy +from app.modules.common import utc_now class JSONTextField(TextField): @@ -56,7 +57,7 @@ class Migration(BaseModel): id = AutoField() name = CharField(unique=True) - applied_at = DateTimeField(default=datetime.utcnow) + applied_at = DateTimeField(default=utc_now) class MigrationState(BaseModel): @@ -66,7 +67,7 @@ class MigrationState(BaseModel): version = IntegerField(unique=True) name = CharField() service_version = CharField(null=True) - applied_at = DateTimeField(default=datetime.utcnow) + applied_at = DateTimeField(default=utc_now) class Group(SoftDeleteModel): @@ -77,7 +78,7 @@ class Group(SoftDeleteModel): name = CharField() description = TextField(null=True) active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_group" @@ -94,7 +95,7 @@ class Team(SoftDeleteModel): escalation_enabled = BooleanField(default=True) escalation_after_reminders = IntegerField(default=2) active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class MatcherPreset(SoftDeleteModel): @@ -115,8 +116,8 @@ class MatcherPreset(SoftDeleteModel): enabled = BooleanField(default=True, index=True) version = IntegerField(default=1) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "matcher_preset" @@ -135,6 +136,8 @@ class User(SoftDeleteModel): email = CharField(null=True) phone = CharField(null=True) timezone = CharField(null=True) + locale = CharField(null=True) + theme = CharField(default="system") telegram_user_id = CharField(null=True) slack_user_id = CharField(null=True) mattermost_user_id = CharField(null=True) @@ -145,7 +148,7 @@ class User(SoftDeleteModel): active = BooleanField(default=True) is_admin = BooleanField(default=False) active_group = ForeignKeyField(Group, null=True, backref="active_users", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class UserGroup(BaseModel): @@ -162,7 +165,7 @@ class UserGroup(BaseModel): group = ForeignKeyField(Group, backref="user_memberships", on_delete="CASCADE") role = CharField(default="viewer") active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -177,7 +180,7 @@ class Role(BaseModel): name = CharField(unique=True) description = TextField(null=True) permissions = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class UserRole(BaseModel): @@ -187,7 +190,7 @@ class UserRole(BaseModel): user = ForeignKeyField(User, backref="role_assignments", on_delete="CASCADE") role = ForeignKeyField(Role, backref="user_assignments", on_delete="CASCADE") team = ForeignKeyField(Team, null=True, backref="role_assignments", on_delete="CASCADE") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -209,7 +212,7 @@ class TeamUser(BaseModel): user = ForeignKeyField(User, backref="team_memberships", on_delete="CASCADE") role = CharField(default="viewer") active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -234,7 +237,7 @@ class Rotation(SoftDeleteModel): handoff_weekday = IntegerField(null=True) timezone = CharField(default="UTC") enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "rotation" @@ -265,7 +268,7 @@ class RotationOverride(BaseModel): starts_at = DateTimeField() ends_at = DateTimeField() reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class NotificationChannel(SoftDeleteModel): @@ -278,7 +281,7 @@ class NotificationChannel(SoftDeleteModel): channel_type = CharField() config = JSONTextField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -295,8 +298,8 @@ class EscalationPolicy(SoftDeleteModel): description = TextField(null=True) enabled = BooleanField(default=True) repeat_count = IntegerField(default=0) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "escalation_policy" @@ -326,8 +329,8 @@ class EscalationPolicyRule(BaseModel): on_delete="SET NULL", ) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "escalation_policy_rule" @@ -352,8 +355,8 @@ class NotificationPolicy(SoftDeleteModel): description = TextField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "notification_policy" @@ -391,8 +394,8 @@ class NotificationPolicyRule(SoftDeleteModel): continue_matching = BooleanField(default=False) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "notification_policy_rule" @@ -419,7 +422,7 @@ class NotificationPolicyRuleChannel(BaseModel): on_delete="CASCADE", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "notification_policy_rule_channel" @@ -494,8 +497,8 @@ class Service(SoftDeleteModel): public_description = TextField(null=True) public_order = IntegerField(default=100) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service" @@ -518,7 +521,7 @@ class ServiceChannel(BaseModel): ) purpose = CharField(default="default") enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_channel" @@ -540,8 +543,8 @@ class ServiceDependency(SoftDeleteModel): description = TextField(null=True) metadata = JSONTextField(default=dict) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_dependency" @@ -585,8 +588,8 @@ class BusinessService(SoftDeleteModel): metadata = JSONTextField(default=dict) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "business_service" @@ -615,8 +618,8 @@ class BusinessServiceComponent(SoftDeleteModel): description = TextField(null=True) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "business_service_component" @@ -639,7 +642,7 @@ class BusinessServiceStatusHistory(BaseModel): message = TextField(null=True) impact_score = IntegerField(default=0) component_snapshot = JSONTextField(default=list) - created_at = DateTimeField(default=datetime.utcnow, index=True) + created_at = DateTimeField(default=utc_now, index=True) class Meta: table_name = "business_service_status_history" @@ -665,9 +668,9 @@ class BusinessServiceIncidentImpact(BaseModel): component_snapshot = JSONTextField(default=list) - first_seen_at = DateTimeField(default=datetime.utcnow, index=True) - last_seen_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now, index=True) + last_seen_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "business_service_incident_impact" @@ -736,10 +739,10 @@ class AlertGroupCorrelation(BaseModel): reason = TextField(null=True) active = BooleanField(default=True, index=True) - first_seen_at = DateTimeField(default=datetime.utcnow, index=True) - last_seen_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) - created_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now, index=True) + last_seen_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + created_at = DateTimeField(default=utc_now) context = JSONTextField(default=dict) @@ -776,8 +779,8 @@ class ServiceEvent(BaseModel): actor_label = CharField(null=True) severity = CharField(max_length=32, null=True) status = CharField(max_length=32, null=True) - occurred_at = DateTimeField(default=datetime.utcnow, index=True) - recorded_at = DateTimeField(default=datetime.utcnow) + occurred_at = DateTimeField(default=utc_now, index=True) + recorded_at = DateTimeField(default=utc_now) schema_version = IntegerField(default=1) payload = JSONTextField(default=dict) @@ -806,7 +809,7 @@ class ServiceImpactSnapshot(BaseModel): source = CharField(default="manual", index=True) scope = CharField(default="all", index=True) - captured_at = DateTimeField(default=datetime.utcnow, index=True) + captured_at = DateTimeField(default=utc_now, index=True) max_depth = IntegerField(default=5) include_disabled = BooleanField(default=False) @@ -835,7 +838,7 @@ class ServiceImpactSnapshot(BaseModel): filters = JSONTextField(default=dict) payload = JSONTextField(default=dict) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_impact_snapshot" @@ -889,7 +892,7 @@ class ServiceImpactSnapshotItem(BaseModel): blast_radius = JSONTextField(null=True) payload = JSONTextField(default=dict) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_impact_snapshot_item" @@ -914,8 +917,8 @@ class ServiceStandard(SoftDeleteModel): applies_to = JSONTextField(default=dict) enabled = BooleanField(default=True) created_by = ForeignKeyField(User, null=True, backref="created_service_standards", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_standard" @@ -941,8 +944,8 @@ class ServiceStandardCheck(SoftDeleteModel): required = BooleanField(default=True) enabled = BooleanField(default=True) position = IntegerField(default=0) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_standard_check" @@ -972,7 +975,7 @@ class ServiceReadinessEvaluation(BaseModel): trigger = CharField(default="system") actor_user = ForeignKeyField(User, null=True, backref="service_readiness_evaluations", on_delete="SET NULL") content_hash = CharField(null=True, index=True) - evaluated_at = DateTimeField(default=datetime.utcnow, index=True) + evaluated_at = DateTimeField(default=utc_now, index=True) class Meta: table_name = "service_readiness_evaluation" @@ -1000,7 +1003,7 @@ class ServiceReadinessCheckResult(BaseModel): required = BooleanField(default=True) message = TextField(null=True) details = JSONTextField(default=dict) - evaluated_at = DateTimeField(default=datetime.utcnow) + evaluated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_readiness_check_result" @@ -1024,7 +1027,7 @@ class ServiceReadinessState(BaseModel): failed_required_count = IntegerField(default=0) failed_critical_count = IntegerField(default=0) content_hash = CharField(null=True, index=True) - evaluated_at = DateTimeField(default=datetime.utcnow, index=True) + evaluated_at = DateTimeField(default=utc_now, index=True) class Meta: table_name = "service_readiness_state" @@ -1055,8 +1058,8 @@ class ServiceRunbook(SoftDeleteModel): priority = IntegerField(default=100) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_runbook" @@ -1079,8 +1082,8 @@ class ServiceLink(SoftDeleteModel): priority = IntegerField(default=100) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_link" @@ -1105,8 +1108,8 @@ class IncidentPriority(BaseModel): enabled = BooleanField(default=True, index=True) default = BooleanField(default=False, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "incident_priority" @@ -1143,8 +1146,8 @@ class PriorityPolicy(SoftDeleteModel): on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "priority_policy" @@ -1186,8 +1189,8 @@ class PriorityPolicyRule(SoftDeleteModel): enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "priority_policy_rule" @@ -1210,7 +1213,7 @@ class ServiceOwner(BaseModel): notify_on_status_change = BooleanField(default=True) notify_on_resolved = BooleanField(default=True) notify_on_comment = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_owner" @@ -1237,8 +1240,8 @@ class ServiceSli(SoftDeleteModel): priority = CharField(null=True, index=True) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_sli" @@ -1269,8 +1272,8 @@ class ServiceSlo(SoftDeleteModel): include_open_alerts = BooleanField(default=True) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_slo" @@ -1309,7 +1312,7 @@ class ServiceSloMeasurement(BaseModel): budget_consumed_seconds = IntegerField(null=True) budget_remaining_seconds = IntegerField(null=True) - calculated_at = DateTimeField(default=datetime.utcnow, index=True) + calculated_at = DateTimeField(default=utc_now, index=True) details = JSONTextField(default=dict) class Meta: @@ -1382,8 +1385,8 @@ class Heartbeat(SoftDeleteModel): metadata = JSONTextField(default=dict) created_by = ForeignKeyField(User, null=True, backref="created_heartbeats", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "heartbeat" @@ -1427,8 +1430,8 @@ class HeartbeatInstance(BaseModel): ) metadata = JSONTextField(default=dict) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "heartbeat_instance" @@ -1445,7 +1448,7 @@ class HeartbeatPing(BaseModel): id = AutoField() heartbeat = ForeignKeyField(Heartbeat, backref="pings", on_delete="CASCADE") - received_at = DateTimeField(default=datetime.utcnow, index=True) + received_at = DateTimeField(default=utc_now, index=True) event_type = CharField(default="ping", index=True) instance_key = CharField(null=True, index=True) status_before = CharField(null=True) @@ -1494,7 +1497,7 @@ class AlertRoute(SoftDeleteModel): intake_token_prefix = CharField(null=True, index=True) intake_token_hash = CharField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) service = ForeignKeyField( Service, null=True, @@ -1539,6 +1542,9 @@ class MaintenanceWindow(SoftDeleteModel): status = CharField(default="scheduled", index=True) enabled = BooleanField(default=True, index=True) + apply_to_existing = BooleanField(default=False) + reactivate_on_end = BooleanField(default=True) + reconciled_at = DateTimeField(null=True) created_by = ForeignKeyField( User, @@ -1556,8 +1562,8 @@ class MaintenanceWindow(SoftDeleteModel): cancelled_at = DateTimeField(null=True) cancel_reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "maintenance_window" @@ -1585,7 +1591,7 @@ class MaintenanceWindowService(BaseModel): on_delete="CASCADE", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "maintenance_window_service" @@ -1631,7 +1637,7 @@ class MaintenanceWindowScope(BaseModel): on_delete="CASCADE", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "maintenance_window_scope" @@ -1669,8 +1675,8 @@ class ServiceMatchRule(SoftDeleteModel): matchers = JSONTextField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_match_rule" @@ -1681,6 +1687,39 @@ class Meta: ) +class MaintenanceWindowAlertApplication(BaseModel): + """One maintenance effect applied to an unresolved alert group.""" + + id = AutoField() + maintenance_window = ForeignKeyField( + MaintenanceWindow, + backref="alert_applications", + on_delete="CASCADE", + ) + alert_group = DeferredForeignKey( + "AlertGroup", + backref="maintenance_applications", + on_delete="CASCADE", + ) + behavior = CharField(index=True) + application_source = CharField(default="new") + previous_status = CharField(null=True) + occurrence_started_at = DateTimeField(null=True) + active = BooleanField(default=True, index=True) + applied_at = DateTimeField(default=utc_now) + retained_at = DateTimeField(null=True) + released_at = DateTimeField(null=True) + release_reason = CharField(null=True) + + class Meta: + table_name = "maintenance_window_alert_application" + indexes = ( + (("maintenance_window", "alert_group"), True), + (("alert_group", "active"), False), + (("maintenance_window", "active"), False), + ) + + class AlertRouteChannel(BaseModel): """Link an alert route to notification channels.""" @@ -1716,6 +1755,13 @@ class AlertGroup(BaseModel): backref="alert_groups", on_delete="SET NULL", ) + notification_policy = ForeignKeyField( + NotificationPolicy, + null=True, + backref="alert_groups", + on_delete="SET NULL", + index=True, + ) next_escalation_at = DateTimeField(null=True, index=True) last_escalated_at = DateTimeField(null=True) @@ -1755,8 +1801,8 @@ class AlertGroup(BaseModel): ) resolved_at = DateTimeField(null=True) - first_seen_at = DateTimeField(default=datetime.utcnow) - last_seen_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now) + last_seen_at = DateTimeField(default=utc_now) last_notification_at = DateTimeField(null=True) notification_due_at = DateTimeField(null=True, index=True) notification_pending = BooleanField(default=False, index=True) @@ -1786,8 +1832,8 @@ class AlertGroup(BaseModel): merged_at = DateTimeField(null=True) merge_reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) priority = ForeignKeyField( IncidentPriority, @@ -1818,6 +1864,8 @@ class AlertGroup(BaseModel): maintenance_behavior = CharField(null=True) maintenance_suppressed = BooleanField(default=False, index=True) + orchestration_suppressed = BooleanField(default=False, index=True) + orchestration_suppress_reason = TextField(null=True) class Meta: table_name = "alert_group" @@ -1849,6 +1897,13 @@ class Alert(BaseModel): backref="alerts", on_delete="SET NULL", ) + notification_policy = ForeignKeyField( + NotificationPolicy, + null=True, + backref="alerts", + on_delete="SET NULL", + index=True, + ) next_escalation_at = DateTimeField(null=True, index=True) last_escalated_at = DateTimeField(null=True) escalation_repeat_count = IntegerField(default=0) @@ -1879,14 +1934,16 @@ class Alert(BaseModel): maintenance_behavior = CharField(null=True) maintenance_suppressed = BooleanField(default=False, index=True) + orchestration_suppressed = BooleanField(default=False, index=True) + orchestration_suppress_reason = TextField(null=True) labels = JSONTextField(null=True) payload = JSONTextField(null=True) status = CharField(default="firing") previous_status = CharField(null=True) acknowledged_by = ForeignKeyField(User, null=True, backref="acknowledged_alerts", on_delete="SET NULL") acknowledged_at = DateTimeField(null=True) - first_seen_at = DateTimeField(default=datetime.utcnow) - last_seen_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now) + last_seen_at = DateTimeField(default=utc_now) last_notification_at = DateTimeField(null=True) reminder_count = IntegerField(default=0) escalation_level = IntegerField(default=0) @@ -1935,8 +1992,8 @@ class AlertComment(BaseModel): body = TextField() - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) deleted = BooleanField(default=False, index=True) deleted_at = DateTimeField(null=True) @@ -2017,12 +2074,12 @@ class IncidentResponder(BaseModel): notification_status = CharField(default="pending", index=True) notification_error = TextField(null=True) - requested_at = DateTimeField(default=datetime.utcnow) + requested_at = DateTimeField(default=utc_now) responded_at = DateTimeField(null=True) expires_at = DateTimeField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "incident_responder" @@ -2074,8 +2131,8 @@ class IncidentStakeholder(BaseModel): on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "incident_stakeholder" @@ -2096,7 +2153,7 @@ class AlertEvent(BaseModel): event_type = CharField() message = TextField(null=True) user = ForeignKeyField(User, null=True, backref="alert_events", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class AlertExplainTrace(BaseModel): @@ -2129,7 +2186,7 @@ class AlertExplainTrace(BaseModel): input_summary = JSONTextField(null=True) result = JSONTextField(null=True) - started_at = DateTimeField(default=datetime.utcnow, index=True) + started_at = DateTimeField(default=utc_now, index=True) finished_at = DateTimeField(null=True, index=True) @@ -2154,7 +2211,7 @@ class AlertExplainStep(BaseModel): message = TextField(null=True) data = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow, index=True) + created_at = DateTimeField(default=utc_now, index=True) class AlertGroupMerge(BaseModel): @@ -2165,7 +2222,7 @@ class AlertGroupMerge(BaseModel): target_group = ForeignKeyField(AlertGroup, backref="target_merges", on_delete="CASCADE") merged_by = ForeignKeyField(User, null=True, backref="alert_group_merges", on_delete="SET NULL") reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "alert_group_merge" @@ -2190,8 +2247,8 @@ class AlertNotification(BaseModel): provider_payload = JSONTextField(null=True) last_callback_at = DateTimeField(null=True) callback_count = IntegerField(default=0) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -2215,7 +2272,7 @@ class AlertNotificationEvent(BaseModel): action = CharField(null=True) message = TextField(null=True) payload = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -2244,9 +2301,9 @@ class OnCallShiftEmailNotification(BaseModel): status = CharField(default="pending", index=True) # pending | sent | failed | skipped last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) sent_at = DateTimeField(null=True) - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_shift_email_notification" @@ -2278,9 +2335,9 @@ class OnCallShiftMattermostNotification(BaseModel): status = CharField(default="pending", index=True) # pending | sent | failed | skipped last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) sent_at = DateTimeField(null=True) - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_shift_mattermost_notification" @@ -2307,11 +2364,50 @@ class Silence(SoftDeleteModel): matchers = JSONTextField(null=True) starts_at = DateTimeField() ends_at = DateTimeField() + apply_to_existing = BooleanField(default=False) + reactivate_on_end = BooleanField(default=True) + reconciled_at = DateTimeField(null=True, index=True) created_by = ForeignKeyField(User, null=True, backref="created_silences", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) enabled = BooleanField(default=True) +class SilenceAlertApplication(BaseModel): + """Track which Silence currently suppresses a concrete alert.""" + + id = AutoField() + silence = ForeignKeyField( + Silence, + backref="alert_applications", + on_delete="CASCADE", + ) + alert = ForeignKeyField( + Alert, + backref="silence_applications", + on_delete="CASCADE", + ) + group = ForeignKeyField( + AlertGroup, + backref="silence_applications", + on_delete="CASCADE", + ) + previous_status = CharField(default="firing") + source = CharField(default="new_alert") + active = BooleanField(default=True, index=True) + applied_at = DateTimeField(default=utc_now) + released_at = DateTimeField(null=True) + release_reason = CharField(null=True) + + class Meta: + table_name = "silence_alert_application" + indexes = ( + (("silence", "alert"), True), + (("alert", "active"), False), + (("group", "active"), False), + ) + + class ApiToken(SoftDeleteModel): """Hashed API token.""" @@ -2325,7 +2421,7 @@ class ApiToken(SoftDeleteModel): scopes = JSONTextField(null=True) expires_at = DateTimeField(null=True) active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) last_used_at = DateTimeField(null=True) @@ -2385,8 +2481,8 @@ class SsoProvider(SoftDeleteModel): extra_config = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "sso_provider" @@ -2405,7 +2501,7 @@ class SsoIdentity(BaseModel): raw_claims = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) last_login_at = DateTimeField(null=True) class Meta: @@ -2439,8 +2535,8 @@ class SsoGroupMapping(BaseModel): active = BooleanField(default=True) priority = IntegerField(default=100) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "sso_group_mapping" @@ -2462,7 +2558,7 @@ class AuditLog(BaseModel): object_id = IntegerField(null=True) message = TextField(null=True) data = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class AppLock(BaseModel): @@ -2472,7 +2568,7 @@ class AppLock(BaseModel): name = CharField(unique=True) owner = CharField() expires_at = DateTimeField() - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class RotationLayer(SoftDeleteModel): @@ -2501,7 +2597,7 @@ class RotationLayer(SoftDeleteModel): timezone = CharField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "rotation_layer" @@ -2522,7 +2618,7 @@ class RotationLayerMember(BaseModel): # Period of this membership. # Removing a user closes the period. # Re-adding the same user creates a new row. - starts_at = DateTimeField(default=datetime.utcnow, index=True) + starts_at = DateTimeField(default=utc_now, index=True) ends_at = DateTimeField(null=True, index=True) class Meta: @@ -2546,7 +2642,7 @@ class RotationLayerRestriction(BaseModel): weekday = IntegerField(null=True) start_time = CharField() end_time = CharField() - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "rotation_layer_restriction" @@ -2576,8 +2672,8 @@ class BrowserPushSubscription(BaseModel): deleted_at = DateTimeField(null=True) last_seen_at = DateTimeField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "browser_push_subscription" @@ -2605,7 +2701,7 @@ class BrowserPushActionToken(BaseModel): token_hash = CharField(max_length=128, unique=True) used_at = DateTimeField(null=True) expires_at = DateTimeField() - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "browser_push_action_token" @@ -2633,8 +2729,8 @@ class UserNotificationRule(SoftDeleteModel): severities = JSONTextField(null=True) event_types = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "user_notification_rule" @@ -2684,8 +2780,8 @@ class UserNotificationDelivery(BaseModel): last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "user_notification_delivery" @@ -2713,5 +2809,502 @@ class CalendarFeed(SoftDeleteModel): backref="calendar_feeds", on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) last_used_at = DateTimeField(null=True) + +# BEGIN EVENT ORCHESTRATION V1 MODELS + +EVENT_ORCHESTRATION_SCOPES = ("global", "service") +EVENT_ORCHESTRATION_MODES = ("active", "shadow", "disabled") +EVENT_ORCHESTRATION_COMPATIBILITY_MODES = ("legacy", "hybrid", "orchestration") +EVENT_ORCHESTRATION_VERSION_STATUSES = ("draft", "published", "archived") +EVENT_ORCHESTRATION_PROCESSING_MODES = ( + "continue", + "stop", + "evaluate_children", + "children_then_continue", +) + + +class EventOrchestration(SoftDeleteModel): + """A group-owned orchestration with one atomically selected active version.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="event_orchestrations", + on_delete="CASCADE", + index=True, + ) + name = CharField(max_length=255) + description = TextField(null=True) + scope = CharField(max_length=32, default="global", index=True) + service = ForeignKeyField( + Service, + backref="event_orchestrations", + null=True, + on_delete="SET NULL", + index=True, + ) + enabled = BooleanField(default=False, index=True) + mode = CharField(max_length=32, default="disabled", index=True) + compatibility_mode = CharField(max_length=32, default="legacy", index=True) + # Kept as an integer to avoid a circular DDL dependency between the + # orchestration and orchestration-version tables. Repository code verifies + # that the selected version belongs to this orchestration. + active_version_id = IntegerField(null=True, index=True) + created_by = ForeignKeyField( + User, + backref="created_event_orchestrations", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + + class Meta: + table_name = "event_orchestration" + indexes = ( + (("group", "name"), True), + (("group", "scope"), False), + (("group", "mode", "enabled"), False), + (("group", "compatibility_mode", "enabled"), False), + (("service", "enabled"), False), + ) + + def save(self, *args, **kwargs): + if self.scope not in EVENT_ORCHESTRATION_SCOPES: + raise ValueError("Invalid orchestration scope") + if self.mode not in EVENT_ORCHESTRATION_MODES: + raise ValueError("Invalid orchestration mode") + if self.compatibility_mode not in EVENT_ORCHESTRATION_COMPATIBILITY_MODES: + raise ValueError("Invalid orchestration compatibility mode") + + if self.scope == "global" and self.service_id is not None: + raise ValueError("Global orchestration cannot reference a service") + if self.scope == "service" and self.service_id is None: + raise ValueError("Service-scoped orchestration requires a service") + + if self.service_id is not None: + service_group_id = ( + Service.select(Service.group) + .where(Service.id == self.service_id) + .scalar() + ) + if service_group_id is None: + raise ValueError("Referenced service does not exist") + if int(service_group_id) != int(self.group_id): + raise ValueError("Referenced service belongs to another group") + + self.updated_at = utc_now() + return super().save(*args, **kwargs) + + +class EventOrchestrationVersion(BaseModel): + """An immutable published orchestration definition.""" + + id = AutoField() + orchestration = ForeignKeyField( + EventOrchestration, + backref="versions", + on_delete="CASCADE", + index=True, + ) + version_number = IntegerField() + status = CharField(max_length=32, default="draft", index=True) + definition_hash = CharField(max_length=64, null=True, index=True) + definition_json = JSONTextField(default=dict) + comment = TextField(null=True) + created_by = ForeignKeyField( + User, + backref="created_event_orchestration_versions", + null=True, + on_delete="SET NULL", + ) + published_by = ForeignKeyField( + User, + backref="published_event_orchestration_versions", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + published_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "event_orchestration_version" + indexes = ( + (("orchestration", "version_number"), True), + (("orchestration", "status"), False), + ) + + def save(self, *args, **kwargs): + if self.status not in EVENT_ORCHESTRATION_VERSION_STATUSES: + raise ValueError("Invalid orchestration version status") + + if self.id is None and self.status != "draft": + raise ValueError("New orchestration versions must start as drafts") + + if self.id is not None: + current = ( + EventOrchestrationVersion.select( + EventOrchestrationVersion.status, + ) + .where(EventOrchestrationVersion.id == self.id) + .dicts() + .get() + ) + dirty = {field.name for field in self.dirty_fields} + current_status = current["status"] + + # A published version may only be archived. All other changes are + # rejected. Repository publication uses an atomic UPDATE for this + # narrowly allowed state transition. + archive_only = ( + current_status == "published" + and self.status == "archived" + and dirty.issubset({"status", "updated_at"}) + ) + if current_status in ("published", "archived") and not archive_only: + raise ValueError("Published orchestration versions are immutable") + + self.updated_at = utc_now() + return super().save(*args, **kwargs) + + def delete_instance(self, *args, **kwargs): + if self.status != "draft": + raise ValueError("Published orchestration versions cannot be deleted") + return super().delete_instance(*args, **kwargs) + + +class EventOrchestrationRule(BaseModel): + """A deterministic ordered rule tree belonging to one draft/version.""" + + id = AutoField() + version = ForeignKeyField( + EventOrchestrationVersion, + backref="rules", + on_delete="CASCADE", + index=True, + ) + parent_rule = ForeignKeyField( + "self", + backref="children", + null=True, + on_delete="CASCADE", + index=True, + ) + position = IntegerField(default=0) + name = CharField(max_length=255) + description = TextField(null=True) + enabled = BooleanField(default=True, index=True) + condition_tree_json = JSONTextField(default=dict) + actions_json = JSONTextField(default=list) + processing_mode = CharField(max_length=32, default="continue") + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + + class Meta: + table_name = "event_orchestration_rule" + indexes = ( + (("version", "parent_rule", "position"), True), + (("version", "enabled"), False), + ) + + def _assert_draft(self): + version_status = ( + EventOrchestrationVersion.select(EventOrchestrationVersion.status) + .where(EventOrchestrationVersion.id == self.version_id) + .scalar() + ) + if version_status != "draft": + raise ValueError("Rules of published orchestration versions are immutable") + + def save(self, *args, **kwargs): + if self.processing_mode not in EVENT_ORCHESTRATION_PROCESSING_MODES: + raise ValueError("Invalid orchestration rule processing mode") + if self.version_id is None: + raise ValueError("Orchestration rule requires a version") + self._assert_draft() + + if self.parent_rule_id is not None: + parent_version_id = ( + EventOrchestrationRule.select(EventOrchestrationRule.version) + .where(EventOrchestrationRule.id == self.parent_rule_id) + .scalar() + ) + if parent_version_id is None: + raise ValueError("Parent orchestration rule does not exist") + if int(parent_version_id) != int(self.version_id): + raise ValueError("Parent rule belongs to another version") + + self.updated_at = utc_now() + return super().save(*args, **kwargs) + + def delete_instance(self, *args, **kwargs): + self._assert_draft() + return super().delete_instance(*args, **kwargs) + + +class OrchestrationIntakeToken(BaseModel): + """Hashed intake credential scoped to a single orchestration.""" + + id = AutoField() + orchestration = ForeignKeyField( + EventOrchestration, + backref="intake_tokens", + on_delete="CASCADE", + index=True, + ) + name = CharField(max_length=255) + token_hash = CharField(max_length=255, unique=True, index=True) + token_prefix = CharField(max_length=24, null=True, index=True) + enabled = BooleanField(default=True, index=True) + created_by = ForeignKeyField( + User, + backref="created_orchestration_intake_tokens", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=utc_now, index=True) + last_used_at = DateTimeField(null=True) + revoked_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "orchestration_intake_token" + indexes = ( + (("orchestration", "name"), True), + (("orchestration", "enabled"), False), + ) + + +class OrchestrationExecution(BaseModel): + """Immutable execution audit record for explainability and retention.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="orchestration_executions", + on_delete="CASCADE", + index=True, + ) + orchestration = ForeignKeyField( + EventOrchestration, + backref="executions", + on_delete="CASCADE", + index=True, + ) + version = ForeignKeyField( + EventOrchestrationVersion, + backref="executions", + on_delete="RESTRICT", + index=True, + ) + source = CharField(max_length=128, null=True, index=True) + integration_name = CharField(max_length=255, null=True) + event_fingerprint = CharField(max_length=255, null=True, index=True) + disposition = CharField(max_length=64, null=True, index=True) + matched_rule_count = IntegerField(default=0) + duration_ms = IntegerField(null=True) + trace_json = JSONTextField(default=dict) + # Deliberately retained as scalar IDs: execution history must remain + # readable even if an alert or alert group is later removed. + alert_id = IntegerField(null=True, index=True) + alert_group_id = IntegerField(null=True, index=True) + created_at = DateTimeField(default=utc_now, index=True) + expires_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "orchestration_execution" + indexes = ( + (("group", "created_at"), False), + (("orchestration", "created_at"), False), + (("version", "created_at"), False), + (("event_fingerprint", "created_at"), False), + ) + + +class PendingOrchestratedEvent(BaseModel): + """A paused normalized event waiting to enter the normal alert lifecycle.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="pending_orchestrated_events", + on_delete="CASCADE", + index=True, + ) + orchestration = ForeignKeyField( + EventOrchestration, + backref="pending_events", + on_delete="CASCADE", + index=True, + ) + version = ForeignKeyField( + EventOrchestrationVersion, + backref="pending_events", + on_delete="RESTRICT", + index=True, + ) + route = ForeignKeyField( + AlertRoute, + null=True, + backref="pending_orchestrated_events", + on_delete="SET NULL", + index=True, + ) + service = ForeignKeyField( + Service, + null=True, + backref="pending_orchestrated_events", + on_delete="SET NULL", + index=True, + ) + source = CharField(max_length=128, index=True) + integration_name = CharField(max_length=255, null=True) + dedup_key = CharField(max_length=255, index=True) + active_key = CharField(max_length=64, null=True, unique=True, index=True) + normalized_event_json = JSONTextField(default=dict) + context_json = JSONTextField(default=dict) + activation_at = DateTimeField(index=True) + status = CharField(max_length=32, default="pending", index=True) + attempts = IntegerField(default=0) + last_error = TextField(null=True) + claim_token = CharField(max_length=64, null=True, index=True) + claimed_at = DateTimeField(null=True, index=True) + next_attempt_at = DateTimeField(null=True, index=True) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + resolved_at = DateTimeField(null=True, index=True) + activated_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "pending_orchestrated_event" + indexes = ( + (("status", "activation_at"), False), + (("group", "source", "dedup_key"), False), + (("status", "next_attempt_at"), False), + ) + + + +ORCHESTRATION_WEBHOOK_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") +ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_POLICIES = ("deny", "allowlist") +AUTOMATION_EXECUTION_STATUSES = ( + "pending", + "running", + "succeeded", + "failed", + "cancelled", +) + + +class OrchestrationWebhookAction(SoftDeleteModel): + """Reusable group-owned outbound webhook action configuration.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="orchestration_webhook_actions", + on_delete="CASCADE", + index=True, + ) + name = CharField(max_length=255) + description = TextField(null=True) + url = TextField() + method = CharField(max_length=16, default="POST") + headers_encrypted = TextField(null=True) + body_template = TextField(null=True) + timeout_seconds = IntegerField(default=10) + retry_count = IntegerField(default=2) + private_network_policy = CharField(max_length=32, default="deny") + enabled = BooleanField(default=True, index=True) + created_by = ForeignKeyField( + User, + backref="created_orchestration_webhook_actions", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + + class Meta: + table_name = "orchestration_webhook_action" + indexes = ( + (("group", "name"), True), + (("group", "enabled"), False), + ) + + def save(self, *args, **kwargs): + self.method = str(self.method or "POST").upper() + if self.method not in ORCHESTRATION_WEBHOOK_METHODS: + raise ValueError("Invalid orchestration webhook method") + if self.private_network_policy not in ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_POLICIES: + raise ValueError("Invalid orchestration webhook private network policy") + if isinstance(self.timeout_seconds, bool) or not 1 <= int(self.timeout_seconds) <= 60: + raise ValueError("Webhook timeout must be between 1 and 60 seconds") + if isinstance(self.retry_count, bool) or not 0 <= int(self.retry_count) <= 10: + raise ValueError("Webhook retry count must be between 0 and 10") + self.updated_at = utc_now() + return super().save(*args, **kwargs) + + +class AutomationExecution(BaseModel): + """Queued and audited execution of one orchestration webhook action.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + action = ForeignKeyField( + OrchestrationWebhookAction, + backref="executions", + on_delete="RESTRICT", + index=True, + ) + orchestration_execution = ForeignKeyField( + OrchestrationExecution, + backref="automation_executions", + on_delete="CASCADE", + index=True, + ) + group = ForeignKeyField( + Group, + backref="automation_executions", + on_delete="CASCADE", + index=True, + ) + alert_group_id = IntegerField(null=True, index=True) + rule_path = CharField(max_length=512, null=True) + status = CharField(max_length=32, default="pending", index=True) + attempts = IntegerField(default=0) + idempotency_key = CharField(max_length=128, unique=True, index=True) + request_metadata_json = JSONTextField(default=dict) + request_headers_encrypted = TextField(null=True) + request_body_encrypted = TextField(null=True) + response_status = IntegerField(null=True) + response_excerpt_safe = TextField(null=True) + error_safe = TextField(null=True) + next_attempt_at = DateTimeField(null=True, index=True) + claim_token = CharField(max_length=64, null=True, index=True) + claimed_at = DateTimeField(null=True, index=True) + created_at = DateTimeField(default=utc_now, index=True) + started_at = DateTimeField(null=True, index=True) + finished_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "automation_execution" + indexes = ( + (("status", "next_attempt_at"), False), + (("group", "status", "created_at"), False), + (("orchestration_execution", "created_at"), False), + ) + + def save(self, *args, **kwargs): + if self.status not in AUTOMATION_EXECUTION_STATUSES: + raise ValueError("Invalid automation execution status") + return super().save(*args, **kwargs) + +# END EVENT ORCHESTRATION V1 MODELS diff --git a/app/modules/db/notification_policies_repo.py b/app/modules/db/notification_policies_repo.py index b89e687..679bc75 100644 --- a/app/modules/db/notification_policies_repo.py +++ b/app/modules/db/notification_policies_repo.py @@ -11,6 +11,7 @@ Service, Team, ) +from app.modules.common import utc_now def list_notification_policies( @@ -133,7 +134,7 @@ def create_notification_policy( enabled=True, ): """Create notification policy.""" - now = datetime.utcnow() + now = utc_now() return NotificationPolicy.create( team=team_id, @@ -163,7 +164,7 @@ def restore_notification_policy( policy.enabled = enabled policy.deleted = False policy.deleted_at = None - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -181,7 +182,7 @@ def update_notification_policy(policy_id, data): if field in data: setattr(policy, field, data[field]) - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -190,7 +191,7 @@ def update_notification_policy(policy_id, data): def soft_delete_notification_policy(policy_id): """Soft-delete policy and its active rules.""" policy = get_notification_policy(policy_id) - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): ( @@ -352,7 +353,7 @@ def create_policy_rule( enabled=True, ): """Create notification policy rule.""" - now = datetime.utcnow() + now = utc_now() if position is None: position = get_next_rule_position(policy_id) @@ -389,7 +390,7 @@ def update_policy_rule(rule_id, data): if field in data: setattr(rule, field, data[field]) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -398,7 +399,7 @@ def update_policy_rule(rule_id, data): def soft_delete_policy_rule(rule_id): """Soft-delete notification policy rule.""" rule = get_policy_rule(rule_id) - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): ( @@ -513,7 +514,7 @@ def reorder_policy_rules(policy_id, ordered_rule_ids): NotificationPolicyRule .update( position=temporary_base + index, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( (NotificationPolicyRule.id == rule_id) @@ -531,7 +532,7 @@ def reorder_policy_rules(policy_id, ordered_rule_ids): NotificationPolicyRule .update( position=position, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( (NotificationPolicyRule.id == rule_id) diff --git a/app/modules/db/notifications_repo.py b/app/modules/db/notifications_repo.py index 13dc3fe..9ee7f6f 100644 --- a/app/modules/db/notifications_repo.py +++ b/app/modules/db/notifications_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import AlertNotification, AlertNotificationEvent +from app.modules.common import utc_now def get_notification(group_id, channel_id): @@ -53,7 +54,7 @@ def save_notification( last_error=error, provider_status=provider_status, provider_payload=provider_payload, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) record.provider = provider or record.provider @@ -66,7 +67,7 @@ def save_notification( if provider_payload is not None: record.provider_payload = provider_payload - record.updated_at = datetime.utcnow() + record.updated_at = utc_now() record.save() return record @@ -86,9 +87,9 @@ def update_notification_callback_state( if provider_payload is not None: notification.provider_payload = provider_payload - notification.last_callback_at = datetime.utcnow() + notification.last_callback_at = utc_now() notification.callback_count = (notification.callback_count or 0) + 1 - notification.updated_at = datetime.utcnow() + notification.updated_at = utc_now() notification.save() return notification diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py new file mode 100644 index 0000000..fa08fcc --- /dev/null +++ b/app/modules/db/orchestrations_repo.py @@ -0,0 +1,1043 @@ +import hashlib +import json +import secrets +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from peewee import IntegrityError, fn + +from app.modules.db import models as db_models +from app.db import database_proxy +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationIntakeToken, + Service, +) +from app.services.integrations.auth import hash_token +from app.services.orchestration.validation import ( + issues_to_messages, + validate_rule_definition, +) +from app.modules.common import utc_now + + +VALID_SCOPES = {"global", "service"} +VALID_MODES = {"active", "shadow", "disabled"} +VALID_COMPATIBILITY_MODES = {"legacy", "hybrid", "orchestration"} +VALID_VERSION_STATUSES = {"draft", "published", "archived"} +VALID_PROCESSING_MODES = { + "continue", + "stop", + "evaluate_children", + "children_then_continue", +} + + +class OrchestrationError(ValueError): + """Base error raised for invalid orchestration state transitions.""" + + +class OrchestrationNotFound(OrchestrationError): + pass + + +class OrchestrationConflict(OrchestrationError): + pass + + +class OrchestrationValidationError(OrchestrationError): + def __init__(self, errors: Sequence[str], warnings: Optional[Sequence[str]] = None): + self.errors = list(errors) + self.warnings = list(warnings or []) + super().__init__("; ".join(self.errors) or "Invalid orchestration definition") + + +_REFERENCE_ACTIONS: Dict[str, Tuple[str, Tuple[str, ...]]] = { + "set_team": ("Team", ("team_id", "value")), + "set_route": ("AlertRoute", ("route_id", "value")), + "set_service": ("Service", ("service_id", "value")), + "set_escalation_policy": ( + "EscalationPolicy", + ("escalation_policy_id", "policy_id", "value"), + ), + "set_notification_policy": ( + "NotificationPolicy", + ("notification_policy_id", "policy_id", "value"), + ), + "set_priority_policy": ( + "PriorityPolicy", + ("priority_policy_id", "policy_id", "value"), + ), + "enqueue_webhook": ( + "OrchestrationWebhookAction", + ("action_id", "webhook_action_id"), + ), +} + + +def canonical_json(value: Any) -> str: + """Return the stable JSON representation used for content hashing.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def definition_hash(definition: Dict[str, Any]) -> str: + return hashlib.sha256(canonical_json(definition).encode("utf-8")).hexdigest() + + +def _get_orchestration(orchestration_id: int) -> EventOrchestration: + orchestration = EventOrchestration.get_or_none(EventOrchestration.id == orchestration_id) + if ( + orchestration is None + or bool(orchestration.deleted) + or orchestration.deleted_at is not None + ): + raise OrchestrationNotFound("Orchestration not found") + return orchestration + + +def _get_version(version_id: int) -> EventOrchestrationVersion: + version = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == version_id + ) + if version is None: + raise OrchestrationNotFound("Orchestration version not found") + return version + + +def _for_update(query): + """Use row locking where supported; SQLite is protected by its transaction.""" + + database = getattr(database_proxy, "obj", None) + if database is not None and database.__class__.__name__ != "SqliteDatabase": + return query.for_update() + return query + + +def _locked_version(version_id: int) -> EventOrchestrationVersion: + version = _for_update( + EventOrchestrationVersion.select().where( + EventOrchestrationVersion.id == version_id + ) + ).first() + if version is None: + raise OrchestrationNotFound("Orchestration version not found") + return version + + +def _locked_orchestration(orchestration_id: int) -> EventOrchestration: + query = EventOrchestration.select().where( + (EventOrchestration.id == orchestration_id) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + orchestration = _for_update(query).first() + if orchestration is None: + raise OrchestrationNotFound("Orchestration not found") + return orchestration + + +def _next_version_number(orchestration_id: int) -> int: + maximum = ( + EventOrchestrationVersion.select( + fn.MAX(EventOrchestrationVersion.version_number) + ) + .where(EventOrchestrationVersion.orchestration == orchestration_id) + .scalar() + ) + return int(maximum or 0) + 1 + + +def _assert_draft(version: EventOrchestrationVersion) -> None: + if version.status != "draft": + raise OrchestrationConflict("Only draft versions can be edited") + + +def _service_group_id(service_id: int) -> Optional[int]: + value = Service.select(Service.group).where(Service.id == service_id).scalar() + return int(value) if value is not None else None + + +def create_orchestration( + *, + group_id: int, + name: str, + scope: str = "global", + service_id: Optional[int] = None, + description: Optional[str] = None, + created_by_id: Optional[int] = None, + compatibility_mode: str = "legacy", +) -> EventOrchestration: + name = (name or "").strip() + if not name: + raise OrchestrationValidationError(["name is required"]) + if scope not in VALID_SCOPES: + raise OrchestrationValidationError(["scope must be global or service"]) + if compatibility_mode not in VALID_COMPATIBILITY_MODES: + raise OrchestrationValidationError(["invalid compatibility_mode"]) + if scope == "global" and service_id is not None: + raise OrchestrationValidationError( + ["global orchestration cannot reference a service"] + ) + if scope == "service": + if service_id is None: + raise OrchestrationValidationError( + ["service-scoped orchestration requires service_id"] + ) + if _service_group_id(service_id) != int(group_id): + raise OrchestrationValidationError( + ["referenced service belongs to another group"] + ) + + try: + return EventOrchestration.create( + group=group_id, + name=name, + description=description, + scope=scope, + service=service_id, + enabled=False, + mode="disabled", + compatibility_mode=compatibility_mode, + created_by=created_by_id, + ) + except IntegrityError as exc: + raise OrchestrationConflict( + "An orchestration with this name already exists in the group" + ) from exc + + + + +def get_orchestration(orchestration_id: int) -> EventOrchestration: + """Return a non-deleted orchestration by id.""" + orchestration = _get_orchestration(orchestration_id) + if bool(getattr(orchestration, "deleted", False)) or getattr( + orchestration, "deleted_at", None + ) is not None: + raise OrchestrationNotFound("Event orchestration not found") + return orchestration + + +def list_orchestrations( + *, + group_ids: Optional[Sequence[int]] = None, + group_id: Optional[int] = None, +) -> List[EventOrchestration]: + """List non-deleted orchestrations visible in the requested groups.""" + query = EventOrchestration.select().where( + (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + if group_id is not None: + query = query.where(EventOrchestration.group == int(group_id)) + elif group_ids is not None: + normalized = sorted({int(value) for value in group_ids}) + if not normalized: + return [] + query = query.where(EventOrchestration.group.in_(normalized)) + return list( + query.order_by( + EventOrchestration.group.asc(), + EventOrchestration.name.asc(), + EventOrchestration.id.asc(), + ) + ) + + +def update_orchestration( + orchestration_id: int, + *, + name: Optional[str] = None, + description: Optional[str] = None, + description_provided: bool = False, + scope: Optional[str] = None, + scope_provided: bool = False, + service_id: Optional[int] = None, + service_provided: bool = False, +) -> EventOrchestration: + """Update editable orchestration metadata without changing runtime state.""" + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + if name is not None: + normalized_name = str(name or "").strip() + if not normalized_name: + raise OrchestrationValidationError(["name is required"]) + orchestration.name = normalized_name + if description_provided: + orchestration.description = description + + next_scope = scope if scope_provided else orchestration.scope + next_service_id = ( + service_id if service_provided else orchestration.service_id + ) + + if next_scope not in VALID_SCOPES: + raise OrchestrationValidationError( + ["scope must be global or service"] + ) + if next_scope == "global": + if service_provided and service_id is not None: + raise OrchestrationValidationError( + ["global orchestration cannot reference a service"] + ) + next_service_id = None + else: + if next_service_id is None: + raise OrchestrationValidationError( + ["service-scoped orchestration requires service_id"] + ) + if _service_group_id(next_service_id) != int(orchestration.group_id): + raise OrchestrationValidationError( + ["referenced service belongs to another group"] + ) + + orchestration.scope = next_scope + orchestration.service = next_service_id + try: + orchestration.save() + except IntegrityError as exc: + raise OrchestrationConflict( + "An orchestration with this name already exists in the group" + ) from exc + return get_orchestration(orchestration_id) + + +def list_versions(orchestration_id: int) -> List[EventOrchestrationVersion]: + orchestration = get_orchestration(orchestration_id) + return list( + EventOrchestrationVersion.select() + .where(EventOrchestrationVersion.orchestration == orchestration.id) + .order_by( + EventOrchestrationVersion.version_number.desc(), + EventOrchestrationVersion.id.desc(), + ) + ) + + +def get_version( + orchestration_id: int, + version_id: int, +) -> EventOrchestrationVersion: + orchestration = get_orchestration(orchestration_id) + version = _get_version(version_id) + if version.orchestration_id != orchestration.id: + raise OrchestrationNotFound("Orchestration version not found") + return version + + +def get_draft(orchestration_id: int) -> Optional[EventOrchestrationVersion]: + return ( + EventOrchestrationVersion.select() + .where( + (EventOrchestrationVersion.orchestration == orchestration_id) + & (EventOrchestrationVersion.status == "draft") + ) + .order_by(EventOrchestrationVersion.version_number.desc()) + .first() + ) + + +def _serialize_rule(rule: EventOrchestrationRule) -> Dict[str, Any]: + children = list( + EventOrchestrationRule.select() + .where(EventOrchestrationRule.parent_rule == rule.id) + .order_by( + EventOrchestrationRule.position.asc(), + EventOrchestrationRule.id.asc(), + ) + ) + return { + "name": rule.name, + "description": rule.description, + "enabled": bool(rule.enabled), + "condition_tree": rule.condition_tree_json or {}, + "actions": rule.actions_json or [], + "processing_mode": rule.processing_mode, + "children": [_serialize_rule(child) for child in children], + } + + +def export_version(version_id: int) -> Dict[str, Any]: + version = _get_version(version_id) + orchestration = version.orchestration + roots = list( + EventOrchestrationRule.select() + .where( + (EventOrchestrationRule.version == version.id) + & EventOrchestrationRule.parent_rule.is_null(True) + ) + .order_by( + EventOrchestrationRule.position.asc(), + EventOrchestrationRule.id.asc(), + ) + ) + return { + "schema_version": 1, + "scope": orchestration.scope, + "service_id": orchestration.service_id, + "rules": [_serialize_rule(rule) for rule in roots], + } + + +def _create_rule_tree( + version: EventOrchestrationVersion, + rules: Iterable[Dict[str, Any]], + parent_rule_id: Optional[int] = None, +) -> None: + for position, raw_rule in enumerate(rules): + if not isinstance(raw_rule, dict): + raise OrchestrationValidationError(["every rule must be an object"]) + + children = raw_rule.get("children") or [] + if not isinstance(children, list): + raise OrchestrationValidationError(["rule children must be a list"]) + + rule = EventOrchestrationRule.create( + version=version.id, + parent_rule=parent_rule_id, + position=position, + name=(raw_rule.get("name") or "").strip() or f"Rule {position + 1}", + description=raw_rule.get("description"), + enabled=bool(raw_rule.get("enabled", True)), + condition_tree_json=raw_rule.get("condition_tree") or {}, + actions_json=raw_rule.get("actions") or [], + processing_mode=raw_rule.get("processing_mode") or "continue", + ) + _create_rule_tree(version, children, rule.id) + + +def replace_draft_rules( + version_id: int, + rules: Sequence[Dict[str, Any]], +) -> EventOrchestrationVersion: + if not isinstance(rules, (list, tuple)): + raise OrchestrationValidationError(["rules must be a list"]) + + with database_proxy.atomic(): + version = _locked_version(version_id) + _assert_draft(version) + EventOrchestrationRule.delete().where( + EventOrchestrationRule.version == version.id + ).execute() + _create_rule_tree(version, rules) + EventOrchestrationVersion.update(updated_at=utc_now()).where( + EventOrchestrationVersion.id == version.id + ).execute() + return _get_version(version_id) + + +def save_draft_definition( + orchestration_id: int, + rules: Sequence[Dict[str, Any]], + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, +) -> EventOrchestrationVersion: + """Create/update one draft definition and its metadata atomically.""" + with database_proxy.atomic(): + draft = get_or_create_draft( + orchestration_id, + actor_id=actor_id, + comment=comment, + ) + draft = replace_draft_rules(draft.id, rules) + if comment is not None: + EventOrchestrationVersion.update( + comment=comment, + updated_at=utc_now(), + ).where( + EventOrchestrationVersion.id == draft.id + ).execute() + draft = _get_version(draft.id) + return draft + + +def _clone_version_rules( + source_version_id: int, + target_version: EventOrchestrationVersion, +) -> None: + source_definition = export_version(source_version_id) + _create_rule_tree(target_version, source_definition.get("rules") or []) + + +def get_or_create_draft( + orchestration_id: int, + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, +) -> EventOrchestrationVersion: + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + draft = get_draft(orchestration.id) + if draft is not None: + return draft + + try: + draft = EventOrchestrationVersion.create( + orchestration=orchestration.id, + version_number=_next_version_number(orchestration.id), + status="draft", + definition_json={}, + comment=comment, + created_by=actor_id, + ) + except IntegrityError as exc: + # A concurrent creator may have won the unique version-number race. + draft = get_draft(orchestration.id) + if draft is None: + raise OrchestrationConflict( + "Could not allocate an orchestration draft version" + ) from exc + return draft + + if orchestration.active_version_id is not None: + active = _get_version(orchestration.active_version_id) + if active.orchestration_id != orchestration.id: + raise OrchestrationConflict( + "Active version does not belong to the orchestration" + ) + _clone_version_rules(active.id, draft) + + return draft + + +def _walk_json(value: Any): + yield value + if isinstance(value, dict): + for child in value.values(): + yield from _walk_json(child) + elif isinstance(value, list): + for child in value: + yield from _walk_json(child) + + +def _extract_reference_id(action: Dict[str, Any], keys: Sequence[str]) -> Any: + for key in keys: + if key in action and action[key] not in (None, ""): + return action[key] + params = action.get("params") + if isinstance(params, dict): + for key in keys: + if key in params and params[key] not in (None, ""): + return params[key] + return None + + +def _entity_group_id(entity: Any) -> Optional[int]: + direct = getattr(entity, "group_id", None) + if direct is not None: + return int(direct) + + team_id = getattr(entity, "team_id", None) + if team_id is not None: + team_model = getattr(db_models, "Team", None) + if team_model is None: + return None + value = team_model.select(team_model.group).where(team_model.id == team_id).scalar() + return int(value) if value is not None else None + + service_id = getattr(entity, "service_id", None) + if service_id is not None: + return _service_group_id(service_id) + + return None + + +def _validate_action_references( + actions: Any, + orchestration_group_id: int, + rule_path: str, +) -> List[str]: + errors: List[str] = [] + for node in _walk_json(actions): + if not isinstance(node, dict): + continue + action_type = node.get("type") or node.get("action") + reference_spec = _REFERENCE_ACTIONS.get(action_type) + if reference_spec is None: + continue + + model_name, id_keys = reference_spec + reference_id = _extract_reference_id(node, id_keys) + if reference_id in (None, ""): + errors.append(f"{rule_path}: {action_type} requires a reference id") + continue + + model = getattr(db_models, model_name, None) + if model is None: + errors.append( + f"{rule_path}: action {action_type} references unsupported model {model_name}" + ) + continue + + try: + reference_id = int(reference_id) + except (TypeError, ValueError): + errors.append(f"{rule_path}: {action_type} reference id must be an integer") + continue + + entity = model.get_or_none(model.id == reference_id) + if entity is None: + errors.append(f"{rule_path}: referenced {model_name} does not exist") + continue + if action_type == "enqueue_webhook" and ( + not bool(getattr(entity, "enabled", False)) + or bool(getattr(entity, "deleted", False)) + or getattr(entity, "deleted_at", None) is not None + ): + errors.append(f"{rule_path}: referenced webhook action is disabled") + continue + + entity_group_id = _entity_group_id(entity) + if entity_group_id is None: + errors.append( + f"{rule_path}: could not determine group for referenced {model_name}" + ) + elif entity_group_id != int(orchestration_group_id): + errors.append( + f"{rule_path}: referenced {model_name} belongs to another group" + ) + + return errors + + +def validate_version(version_id: int) -> Dict[str, Any]: + version = _get_version(version_id) + orchestration = version.orchestration + errors: List[str] = [] + warnings: List[str] = [] + + if version.status not in VALID_VERSION_STATUSES: + errors.append("invalid version status") + if orchestration.scope not in VALID_SCOPES: + errors.append("invalid orchestration scope") + if orchestration.mode not in VALID_MODES: + errors.append("invalid orchestration mode") + if orchestration.compatibility_mode not in VALID_COMPATIBILITY_MODES: + errors.append("invalid orchestration compatibility mode") + + if orchestration.scope == "global" and orchestration.service_id is not None: + errors.append("global orchestration cannot reference a service") + if orchestration.scope == "service": + if orchestration.service_id is None: + errors.append("service-scoped orchestration requires a service") + elif _service_group_id(orchestration.service_id) != orchestration.group_id: + errors.append("service-scoped orchestration references another group") + + rules = list( + EventOrchestrationRule.select() + .where(EventOrchestrationRule.version == version.id) + .order_by(EventOrchestrationRule.id.asc()) + ) + if not rules: + warnings.append("orchestration version has no rules") + + positions: Dict[Tuple[Optional[int], int], int] = {} + for rule in rules: + path = f"rule {rule.id} ({rule.name})" + if rule.processing_mode not in VALID_PROCESSING_MODES: + errors.append(f"{path}: invalid processing_mode") + if not isinstance(rule.condition_tree_json, dict): + errors.append(f"{path}: condition_tree must be an object") + if not isinstance(rule.actions_json, list): + errors.append(f"{path}: actions must be a list") + + # EVENT_ORCHESTRATION_WS2_VALIDATION + if isinstance(rule.condition_tree_json, dict) and isinstance( + rule.actions_json, list + ): + rule_validation = validate_rule_definition( + rule.condition_tree_json, + rule.actions_json, + path=path, + ) + errors.extend(issues_to_messages(rule_validation["errors"])) + warnings.extend(issues_to_messages(rule_validation["warnings"])) + if rule.parent_rule_id is not None: + parent_version_id = ( + EventOrchestrationRule.select(EventOrchestrationRule.version) + .where(EventOrchestrationRule.id == rule.parent_rule_id) + .scalar() + ) + if parent_version_id != version.id: + errors.append(f"{path}: parent belongs to another version") + + position_key = (rule.parent_rule_id, rule.position) + positions[position_key] = positions.get(position_key, 0) + 1 + if positions[position_key] > 1: + errors.append(f"{path}: duplicate sibling position {rule.position}") + + errors.extend( + _validate_action_references( + rule.actions_json, + orchestration.group_id, + path, + ) + ) + + exported = export_version(version.id) + digest = definition_hash(exported) + return { + "valid": not errors, + "errors": errors, + "warnings": warnings, + "definition": exported, + "definition_hash": digest, + } + + + +def _constant_condition_value(condition: Any) -> Optional[bool]: + """Return True/False for statically constant condition trees.""" + if not isinstance(condition, dict): + return None + if not condition: + return True + + logical = [key for key in ("all", "any", "none") if key in condition] + if len(logical) != 1 or len(condition) != 1: + return None + + key = logical[0] + children = condition.get(key) + if not isinstance(children, list): + return None + values = [_constant_condition_value(child) for child in children] + + if key == "all": + if any(value is False for value in values): + return False + if all(value is True for value in values): + return True + return None + + if key == "any": + if any(value is True for value in values): + return True + if all(value is False for value in values): + return False + return None + + # none is the negation of any(children). + if any(value is True for value in values): + return False + if all(value is False for value in values): + return True + return None + + +def _definition_has_catch_all_drop(definition: Dict[str, Any]) -> bool: + def walk(rules): + for rule in rules or []: + if not isinstance(rule, dict) or not bool(rule.get("enabled", True)): + continue + actions = rule.get("actions") or [] + if _constant_condition_value(rule.get("condition_tree") or {}) is True and any( + isinstance(action, dict) + and (action.get("type") or action.get("action")) == "drop" + for action in actions + ): + return True + if walk(rule.get("children") or []): + return True + return False + + return walk(definition.get("rules") or []) + +def _publish_version_locked( + orchestration: EventOrchestration, + draft: EventOrchestrationVersion, + *, + actor_id: Optional[int], + comment: Optional[str], + confirm_catch_all_drop: bool = False, +) -> EventOrchestrationVersion: + if draft.orchestration_id != orchestration.id: + raise OrchestrationConflict("Draft belongs to another orchestration") + _assert_draft(draft) + + validation = validate_version(draft.id) + if not validation["valid"]: + raise OrchestrationValidationError( + validation["errors"], + validation["warnings"], + ) + if ( + _definition_has_catch_all_drop(validation["definition"]) + and not confirm_catch_all_drop + ): + raise OrchestrationValidationError( + ["catch-all drop requires explicit publish confirmation"], + validation["warnings"], + ) + + now = utc_now() + EventOrchestrationVersion.update( + status="archived", + updated_at=now, + ).where( + (EventOrchestrationVersion.orchestration == orchestration.id) + & (EventOrchestrationVersion.status == "published") + & (EventOrchestrationVersion.id != draft.id) + ).execute() + + update_values: Dict[str, Any] = { + "status": "published", + "definition_hash": validation["definition_hash"], + "definition_json": validation["definition"], + "published_by": actor_id, + "published_at": now, + "updated_at": now, + } + if comment is not None: + update_values["comment"] = comment + + updated = ( + EventOrchestrationVersion.update(**update_values) + .where( + (EventOrchestrationVersion.id == draft.id) + & (EventOrchestrationVersion.status == "draft") + ) + .execute() + ) + if updated != 1: + raise OrchestrationConflict("Draft changed while it was being published") + + EventOrchestration.update( + active_version_id=draft.id, + updated_at=now, + ).where(EventOrchestration.id == orchestration.id).execute() + + return _get_version(draft.id) + + +def publish_draft( + orchestration_id: int, + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, + confirm_catch_all_drop: bool = False, +) -> EventOrchestrationVersion: + """Validate and atomically activate the current draft.""" + + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + draft_query = EventOrchestrationVersion.select().where( + (EventOrchestrationVersion.orchestration == orchestration.id) + & (EventOrchestrationVersion.status == "draft") + ).order_by(EventOrchestrationVersion.version_number.desc()) + draft = _for_update(draft_query).first() + if draft is None: + raise OrchestrationConflict("Orchestration has no draft to publish") + return _publish_version_locked( + orchestration, + draft, + actor_id=actor_id, + comment=comment, + confirm_catch_all_drop=confirm_catch_all_drop, + ) + + +def rollback_to_version( + orchestration_id: int, + source_version_id: int, + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, + confirm_catch_all_drop: bool = False, +) -> EventOrchestrationVersion: + """Publish a new immutable version copied from an earlier version.""" + + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + source = _get_version(source_version_id) + if source.orchestration_id != orchestration.id: + raise OrchestrationValidationError( + ["rollback source belongs to another orchestration"] + ) + if source.status not in {"published", "archived"}: + raise OrchestrationValidationError( + ["rollback source must be published or archived"] + ) + if get_draft(orchestration.id) is not None: + raise OrchestrationConflict( + "Archive or publish the existing draft before rollback" + ) + + draft = EventOrchestrationVersion.create( + orchestration=orchestration.id, + version_number=_next_version_number(orchestration.id), + status="draft", + definition_json={}, + comment=comment or f"Rollback to version {source.version_number}", + created_by=actor_id, + ) + _clone_version_rules(source.id, draft) + return _publish_version_locked( + orchestration, + draft, + actor_id=actor_id, + comment=draft.comment, + confirm_catch_all_drop=confirm_catch_all_drop, + ) + + +def archive_draft(version_id: int) -> EventOrchestrationVersion: + with database_proxy.atomic(): + version = _locked_version(version_id) + _assert_draft(version) + EventOrchestrationRule.delete().where( + EventOrchestrationRule.version == version.id + ).execute() + EventOrchestrationVersion.update( + status="archived", + updated_at=utc_now(), + ).where( + (EventOrchestrationVersion.id == version.id) + & (EventOrchestrationVersion.status == "draft") + ).execute() + return _get_version(version_id) + + +def set_runtime_state( + orchestration_id: int, + *, + enabled: bool, + mode: str, + compatibility_mode: str, +) -> EventOrchestration: + """Enable or disable one orchestration runtime atomically.""" + if mode not in VALID_MODES: + raise OrchestrationValidationError(["invalid orchestration mode"]) + if compatibility_mode not in VALID_COMPATIBILITY_MODES: + raise OrchestrationValidationError(["invalid compatibility_mode"]) + if bool(enabled) != (mode != "disabled"): + raise OrchestrationValidationError([ + "enabled must be true for active/shadow mode and false for disabled mode" + ]) + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + if enabled and mode != "disabled" and orchestration.active_version_id is None: + raise OrchestrationConflict("Published version is required before enabling runtime") + EventOrchestration.update( + enabled=bool(enabled), + mode=mode, + compatibility_mode=compatibility_mode, + updated_at=utc_now(), + ).where(EventOrchestration.id == orchestration.id).execute() + return _get_orchestration(orchestration_id) + + +def list_runtime_orchestrations( + *, + group_id: int, + scope: str, + service_id: Optional[int] = None, +) -> List[EventOrchestration]: + """Return enabled runtime orchestrations in deterministic order.""" + if scope not in VALID_SCOPES: + raise OrchestrationValidationError(["scope must be global or service"]) + query = EventOrchestration.select().where( + (EventOrchestration.group == group_id) + & (EventOrchestration.scope == scope) + & (EventOrchestration.enabled == True) # noqa: E712 + & (EventOrchestration.mode.in_(("active", "shadow"))) + & EventOrchestration.active_version_id.is_null(False) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + if scope == "service": + query = query.where(EventOrchestration.service == service_id) + return list(query.order_by(EventOrchestration.id.asc())) + + +def get_published_runtime_version(orchestration: EventOrchestration) -> EventOrchestrationVersion: + """Return and verify the exact published version selected for runtime.""" + if orchestration.active_version_id is None: + raise OrchestrationConflict("Orchestration has no active version") + version = _get_version(orchestration.active_version_id) + if version.orchestration_id != orchestration.id or version.status != "published": + raise OrchestrationConflict("Active orchestration version is not published") + return version + + +def archive_orchestration(orchestration_id: int) -> EventOrchestration: + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + now = utc_now() + EventOrchestration.update( + enabled=False, + mode="disabled", + deleted=True, + deleted_at=now, + updated_at=now, + ).where(EventOrchestration.id == orchestration.id).execute() + OrchestrationIntakeToken.update( + enabled=False, + revoked_at=now, + ).where( + (OrchestrationIntakeToken.orchestration == orchestration.id) + & (OrchestrationIntakeToken.enabled == True) # noqa: E712 + ).execute() + return EventOrchestration.get_by_id(orchestration_id) + + +def create_intake_token( + orchestration_id: int, + *, + name: str, + actor_id: Optional[int] = None, +) -> Tuple[OrchestrationIntakeToken, str]: + """Create a token and return plaintext once together with the DB record.""" + + name = (name or "").strip() + if not name: + raise OrchestrationValidationError(["token name is required"]) + + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + plaintext = secrets.token_urlsafe(32) + record = OrchestrationIntakeToken.create( + orchestration=orchestration.id, + name=name, + token_hash=hash_token(plaintext), + token_prefix=plaintext[:12], + enabled=True, + created_by=actor_id, + ) + return record, plaintext + + +def authenticate_intake_token(plaintext: str) -> Optional[OrchestrationIntakeToken]: + if not plaintext: + return None + token = OrchestrationIntakeToken.get_or_none( + (OrchestrationIntakeToken.token_hash == hash_token(plaintext)) + & (OrchestrationIntakeToken.enabled == True) # noqa: E712 + & OrchestrationIntakeToken.revoked_at.is_null(True) + ) + if token is None: + return None + OrchestrationIntakeToken.update(last_used_at=utc_now()).where( + OrchestrationIntakeToken.id == token.id + ).execute() + return token + + +def revoke_intake_token(token_id: int) -> OrchestrationIntakeToken: + now = utc_now() + updated = OrchestrationIntakeToken.update( + enabled=False, + revoked_at=now, + ).where(OrchestrationIntakeToken.id == token_id).execute() + if updated != 1: + raise OrchestrationNotFound("Orchestration intake token not found") + return OrchestrationIntakeToken.get_by_id(token_id) diff --git a/app/modules/db/priority_policies_repo.py b/app/modules/db/priority_policies_repo.py index 1071729..cedb851 100644 --- a/app/modules/db/priority_policies_repo.py +++ b/app/modules/db/priority_policies_repo.py @@ -9,6 +9,7 @@ PriorityPolicyRule, Service, ) +from app.modules.common import utc_now def list_priority_policies( @@ -129,8 +130,8 @@ def create_priority_policy( source_priority_mode=source_priority_mode, fallback_mode=fallback_mode, fallback_priority=fallback_priority_id, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -159,7 +160,7 @@ def restore_priority_policy( policy.fallback_priority = fallback_priority_id policy.deleted = False policy.deleted_at = None - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -184,7 +185,7 @@ def update_priority_policy(policy_id, data): if field in allowed_fields: setattr(policy, field, value) - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -194,7 +195,7 @@ def clear_default_priority_policies(team_id, *, exclude_policy_id=None): """Clear the default flag from policies owned by a team.""" query = PriorityPolicy.update( default_for_team=False, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ).where( PriorityPolicy.team == team_id, PriorityPolicy.default_for_team == True, @@ -210,7 +211,7 @@ def clear_default_priority_policies(team_id, *, exclude_policy_id=None): def soft_delete_priority_policy(policy_id): """Soft-delete a policy and all of its rules.""" policy = get_priority_policy(policy_id) - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): rules = list_priority_policy_rules(policy.id) @@ -369,8 +370,8 @@ def create_priority_policy_rule( matcher_preset=matcher_preset_id, priority=priority_id, enabled=enabled, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -392,7 +393,7 @@ def update_priority_policy_rule(rule_id, data): if field in allowed_fields: setattr(rule, field, value) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -401,7 +402,7 @@ def update_priority_policy_rule(rule_id, data): def soft_delete_priority_policy_rule(rule_id): """Soft-delete one priority policy rule.""" rule = get_priority_policy_rule(rule_id) - now = datetime.utcnow() + now = utc_now() rule.enabled = False rule.deleted = True @@ -427,7 +428,7 @@ def reorder_priority_policy_rules(policy_id, ordered_rule_ids): ( PriorityPolicyRule.update( position=temporary_position + index, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( PriorityPolicyRule.id == rule_id, @@ -441,7 +442,7 @@ def reorder_priority_policy_rules(policy_id, ordered_rule_ids): ( PriorityPolicyRule.update( position=position, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( PriorityPolicyRule.id == rule_id, diff --git a/app/modules/db/rotations_repo.py b/app/modules/db/rotations_repo.py index 2a66c4c..f584c16 100644 --- a/app/modules/db/rotations_repo.py +++ b/app/modules/db/rotations_repo.py @@ -1,5 +1,3 @@ -from datetime import datetime - from peewee import IntegrityError, prefetch from app.db import database_proxy @@ -16,13 +14,7 @@ TeamUser, User ) - - -def _rotation_member_period_filter(model, at): - return ( - ((model.starts_at.is_null(True)) | (model.starts_at <= at)) - & ((model.ends_at.is_null(True)) | (model.ends_at > at)) - ) +from app.modules.common import utc_now def list_rotations( @@ -222,7 +214,7 @@ def list_rotation_overrides(rotation_id, start_at=None, end_at=None, include_exp & (RotationOverride.ends_at > start_at) ) elif not include_expired: - query = query.where(RotationOverride.ends_at > datetime.utcnow()) + query = query.where(RotationOverride.ends_at > utc_now()) return list(query.order_by(RotationOverride.starts_at.asc(), RotationOverride.id.asc())) @@ -232,7 +224,7 @@ def get_active_override(rotation_id, now=None): Return the active override for a rotation. """ - now = now or datetime.utcnow() + now = now or utc_now() return ( RotationOverride.select() .where( @@ -315,7 +307,7 @@ def soft_delete_rotation(rotation_id): RotationLayer.update( enabled=False, deleted=True, - deleted_at=datetime.utcnow(), + deleted_at=utc_now(), ).where( RotationLayer.id.in_(layer_ids) ).execute() @@ -339,7 +331,7 @@ def soft_delete_rotation(rotation_id): rotation.enabled = False rotation.deleted = True - rotation.deleted_at = datetime.utcnow() + rotation.deleted_at = utc_now() rotation.save() return rotation @@ -578,7 +570,7 @@ def soft_delete_rotation_layer(layer_id): layer = get_rotation_layer(layer_id) layer.enabled = False layer.deleted = True - layer.deleted_at = datetime.utcnow() + layer.deleted_at = utc_now() layer.save() return layer @@ -675,7 +667,7 @@ def list_rotation_layer_member_periods( def add_rotation_layer_member(layer_id, user_id, position, starts_at=None): """Add user to layer as a new membership period.""" - starts_at = starts_at or datetime.utcnow() + starts_at = starts_at or utc_now() with database_proxy.atomic(): ( @@ -730,7 +722,7 @@ def update_rotation_layer_member(member_id, position, active=True): """Update layer member without rewriting historical schedule.""" member = get_rotation_layer_member(member_id) - now = datetime.utcnow() + now = utc_now() if not active: member.active = False @@ -777,7 +769,7 @@ def delete_rotation_layer_member(member_id): member.active = False if member.ends_at is None: - member.ends_at = datetime.utcnow() + member.ends_at = utc_now() member.save() return data diff --git a/app/modules/db/routes_repo.py b/app/modules/db/routes_repo.py index 9ff4456..1a7c437 100644 --- a/app/modules/db/routes_repo.py +++ b/app/modules/db/routes_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import AlertRoute, AlertRouteChannel, Group, Team +from app.modules.common import utc_now def list_routes(team_id=None, team_ids=None, enabled_only=False, source=None, active_only=True, include_deleted=False): @@ -309,7 +310,7 @@ def soft_delete_route(route_id): route.enabled = False route.deleted = True - route.deleted_at = datetime.utcnow() + route.deleted_at = utc_now() route.save() AlertRouteChannel.delete().where( diff --git a/app/modules/db/services_repo.py b/app/modules/db/services_repo.py index 156dc4e..8f65ba5 100644 --- a/app/modules/db/services_repo.py +++ b/app/modules/db/services_repo.py @@ -13,6 +13,7 @@ ServiceSloMeasurement, User, ) +from app.modules.common import utc_now def list_services(team_id=None, team_ids=None, include_disabled=True): @@ -88,7 +89,7 @@ def restore_service(service_id, data): service.deleted = False service.deleted_at = None - service.updated_at = datetime.utcnow() + service.updated_at = utc_now() service.save() return service @@ -136,7 +137,7 @@ def create_service(data): if existing and existing.deleted: return restore_service(existing.id, data) - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return Service.create(**data) @@ -152,7 +153,7 @@ def update_service(service_id, data): for field, value in data.items(): setattr(service, field, value) - service.updated_at = datetime.utcnow() + service.updated_at = utc_now() service.save() return service @@ -161,7 +162,7 @@ def update_service(service_id, data): def soft_delete_service(service_id): """Soft-delete a service and service-owned routing helpers.""" service = get_service(service_id) - now = datetime.utcnow() + now = utc_now() service.enabled = False service.deleted = True @@ -270,7 +271,7 @@ def get_match_rule(rule_id): def create_match_rule(data): """Create a service match rule.""" - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceMatchRule.create(**data) @@ -281,7 +282,7 @@ def update_match_rule(rule_id, data): for field, value in data.items(): setattr(rule, field, value) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -292,8 +293,8 @@ def soft_delete_match_rule(rule_id): rule = get_match_rule(rule_id) rule.enabled = False rule.deleted = True - rule.deleted_at = datetime.utcnow() - rule.updated_at = datetime.utcnow() + rule.deleted_at = utc_now() + rule.updated_at = utc_now() rule.save() return rule @@ -486,7 +487,7 @@ def get_service_link(link_id): def create_service_link(service_id, data): """Create a service link.""" data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceLink.create(**data) @@ -497,7 +498,7 @@ def update_service_link(link_id, data): for field, value in data.items(): setattr(link, field, value) - link.updated_at = datetime.utcnow() + link.updated_at = utc_now() link.save() return link @@ -508,8 +509,8 @@ def soft_delete_service_link(link_id): link = get_service_link(link_id) link.enabled = False link.deleted = True - link.deleted_at = datetime.utcnow() - link.updated_at = datetime.utcnow() + link.deleted_at = utc_now() + link.updated_at = utc_now() link.save() return link @@ -531,7 +532,7 @@ def get_service_runbook(runbook_id): def create_service_runbook(service_id, data): """Create a service runbook.""" data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceRunbook.create(**data) @@ -542,7 +543,7 @@ def update_service_runbook(runbook_id, data): for field, value in data.items(): setattr(runbook, field, value) - runbook.updated_at = datetime.utcnow() + runbook.updated_at = utc_now() runbook.save() return runbook @@ -553,8 +554,8 @@ def soft_delete_service_runbook(runbook_id): runbook = get_service_runbook(runbook_id) runbook.enabled = False runbook.deleted = True - runbook.deleted_at = datetime.utcnow() - runbook.updated_at = datetime.utcnow() + runbook.deleted_at = utc_now() + runbook.updated_at = utc_now() runbook.save() return runbook @@ -575,7 +576,7 @@ def get_service_dependency(dependency_id): def create_service_dependency(service_id, data): """Create a service dependency.""" data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceDependency.create(**data) @@ -586,7 +587,7 @@ def update_service_dependency(dependency_id, data): for field, value in data.items(): setattr(dependency, field, value) - dependency.updated_at = datetime.utcnow() + dependency.updated_at = utc_now() dependency.save() return dependency @@ -597,8 +598,8 @@ def soft_delete_service_dependency(dependency_id): dependency = get_service_dependency(dependency_id) dependency.enabled = False dependency.deleted = True - dependency.deleted_at = datetime.utcnow() - dependency.updated_at = datetime.utcnow() + dependency.deleted_at = utc_now() + dependency.updated_at = utc_now() dependency.save() return dependency @@ -783,7 +784,7 @@ def create_service_sli(service_id, data): """Create a Service Level Indicator.""" data = dict(data) data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceSli.create(**data) @@ -794,7 +795,7 @@ def update_service_sli(sli_id, data): for field, value in data.items(): setattr(sli, field, value) - sli.updated_at = datetime.utcnow() + sli.updated_at = utc_now() sli.save() return sli @@ -805,8 +806,8 @@ def soft_delete_service_sli(sli_id): sli = get_service_sli(sli_id) sli.enabled = False sli.deleted = True - sli.deleted_at = datetime.utcnow() - sli.updated_at = datetime.utcnow() + sli.deleted_at = utc_now() + sli.updated_at = utc_now() sli.save() return sli @@ -856,7 +857,7 @@ def create_service_slo(service_id, data): """Create a Service Level Objective.""" data = dict(data) data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceSlo.create(**data) @@ -867,7 +868,7 @@ def update_service_slo(slo_id, data): for field, value in data.items(): setattr(slo, field, value) - slo.updated_at = datetime.utcnow() + slo.updated_at = utc_now() slo.save() return slo @@ -878,8 +879,8 @@ def soft_delete_service_slo(slo_id): slo = get_service_slo(slo_id) slo.enabled = False slo.deleted = True - slo.deleted_at = datetime.utcnow() - slo.updated_at = datetime.utcnow() + slo.deleted_at = utc_now() + slo.updated_at = utc_now() slo.save() return slo diff --git a/app/modules/db/silences_repo.py b/app/modules/db/silences_repo.py index b91c975..3aecf53 100644 --- a/app/modules/db/silences_repo.py +++ b/app/modules/db/silences_repo.py @@ -1,17 +1,24 @@ from datetime import datetime, timedelta -from app.modules.db.models import Group, Silence, Team +from app.modules.db.models import ( + Alert, + Group, + Silence, + SilenceAlertApplication, + Team, +) +from app.modules.common import utc_now def list_silences( - team_id=None, - team_ids=None, - active_only=True, - include_deleted=False, - include_expired_history=False, - expired_retention_days=30, - now=None, -): + team_id: int | None = None, + team_ids: list[int] | None = None, + active_only: bool = True, + include_deleted: bool = False, + include_expired_history: bool = False, + expired_retention_days: int = 30, + now: datetime | None = None, +) -> list[Silence]: """Return silence rules.""" query = ( Silence @@ -40,7 +47,7 @@ def list_silences( ) if not include_expired_history: - cutoff = (now or datetime.utcnow()) - timedelta(days=expired_retention_days) + cutoff = (now or utc_now()) - timedelta(days=expired_retention_days) query = query.where(Silence.ends_at >= cutoff) if team_id: @@ -53,12 +60,12 @@ def list_silences( return list(query) -def list_active_silences(team_id, now=None): - """ - Return active silences for a team. - """ - - now = now or datetime.utcnow() +def list_active_silences( + team_id: int, + now: datetime | None = None, +) -> list[Silence]: + """Return active silences for a team.""" + now = now or utc_now() return list( Silence.select() .where( @@ -72,16 +79,59 @@ def list_active_silences(team_id, now=None): ) +def list_due_retroactive_silences( + now: datetime | None = None, +) -> list[Silence]: + """Return active retroactive silences that still require reconciliation.""" + now = now or utc_now() + return list( + Silence.select() + .where( + (Silence.enabled == True) + & (Silence.deleted == False) + & (Silence.apply_to_existing == True) + & (Silence.starts_at <= now) + & (Silence.ends_at > now) + & (Silence.reconciled_at.is_null(True)) + ) + .order_by(Silence.starts_at.asc(), Silence.id.asc()) + ) + + +def list_silences_with_due_releases( + now: datetime | None = None, +) -> list[Silence]: + """Return silences with active applications that are no longer active.""" + now = now or utc_now() + return list( + Silence.select(Silence) + .join(SilenceAlertApplication) + .where( + (SilenceAlertApplication.active == True) + & (Silence.reactivate_on_end == True) + & ( + (Silence.enabled == False) + | (Silence.deleted == True) + | (Silence.ends_at <= now) + ) + ) + .distinct() + .order_by(Silence.id.asc()) + ) + + def create_silence( - team_id, - name, - starts_at, - ends_at, - reason=None, - matcher_preset_id=None, - matchers=None, - created_by=None, -): + team_id: int, + name: str, + starts_at: datetime, + ends_at: datetime, + reason: str | None = None, + matcher_preset_id: int | None = None, + matchers: dict | None = None, + created_by: int | None = None, + apply_to_existing: bool = False, + reactivate_on_end: bool = True, +) -> Silence: """Create a silence rule.""" return Silence.create( team=team_id, @@ -92,14 +142,18 @@ def create_silence( starts_at=starts_at, ends_at=ends_at, created_by=created_by, + apply_to_existing=apply_to_existing, + reactivate_on_end=reactivate_on_end, + reconciled_at=None, + updated_at=utc_now(), ) -def get_silence(silence_id, include_deleted=False): - """ - Return a silence by id. - """ - +def get_silence( + silence_id: int, + include_deleted: bool = False, +) -> Silence: + """Return a silence by id.""" query = Silence.select().where(Silence.id == silence_id) if not include_deleted: @@ -108,35 +162,136 @@ def get_silence(silence_id, include_deleted=False): return query.get() -def update_silence(silence_id, data): - """ - Update a silence rule. - """ - +def update_silence(silence_id: int, data: dict) -> Silence: + """Update a silence rule and request lifecycle reconciliation.""" silence = get_silence(silence_id) - for field in ["team", "name", "reason", "matcher_preset", "matchers", "starts_at", "ends_at", "created_by", "enabled"]: + for field in [ + "team", + "name", + "reason", + "matcher_preset", + "matchers", + "starts_at", + "ends_at", + "created_by", + "enabled", + "apply_to_existing", + "reactivate_on_end", + ]: if field in data: setattr(silence, field, data[field]) + silence.reconciled_at = None + silence.updated_at = utc_now() + silence.save() + return silence + + +def enable_silence(silence_id: int) -> Silence: + """Enable a silence rule and request lifecycle reconciliation.""" + silence = get_silence(silence_id) + silence.enabled = True + silence.reconciled_at = None + silence.updated_at = utc_now() silence.save() return silence -def disable_silence(silence_id): +def disable_silence(silence_id: int) -> Silence: """Disable a silence rule.""" silence = get_silence(silence_id) silence.enabled = False + silence.reconciled_at = None + silence.updated_at = utc_now() silence.save() return silence -def soft_delete_silence(silence_id): - """ - Soft-delete a silence rule. - """ - +def soft_delete_silence(silence_id: int) -> Silence: + """Soft-delete a silence rule.""" silence = get_silence(silence_id) silence.enabled = False silence.deleted = True - silence.deleted_at = datetime.utcnow() + silence.deleted_at = utc_now() + silence.reconciled_at = None + silence.updated_at = utc_now() silence.save() return silence + + +def get_or_create_application( + *, + silence: Silence, + alert: Alert, + previous_status: str, + source: str, + now: datetime | None = None, +) -> tuple[SilenceAlertApplication, bool]: + """Create or reactivate the persisted Silence-to-alert relation.""" + now = now or utc_now() + application, created = SilenceAlertApplication.get_or_create( + silence=silence.id, + alert=alert.id, + defaults={ + "group": alert.group_id, + "previous_status": previous_status, + "source": source, + "active": True, + "applied_at": now, + }, + ) + + if not created and not application.active: + application.group = alert.group_id + application.previous_status = previous_status + application.source = source + application.active = True + application.applied_at = now + application.released_at = None + application.release_reason = None + application.save() + created = True + + return application, created + + +def list_active_applications_for_silence( + silence_id: int, +) -> list[SilenceAlertApplication]: + """Return active applications belonging to one Silence.""" + return list( + SilenceAlertApplication.select() + .where( + (SilenceAlertApplication.silence == silence_id) + & (SilenceAlertApplication.active == True) + ) + .order_by(SilenceAlertApplication.id.asc()) + ) + + +def has_other_active_application( + alert_id: int, + *, + exclude_application_id: int | None = None, +) -> bool: + """Return whether another active Silence application covers an alert.""" + query = SilenceAlertApplication.select().where( + (SilenceAlertApplication.alert == alert_id) + & (SilenceAlertApplication.active == True) + ) + if exclude_application_id is not None: + query = query.where(SilenceAlertApplication.id != exclude_application_id) + return query.exists() + + +def release_application( + application: SilenceAlertApplication, + *, + reason: str, + now: datetime | None = None, +) -> SilenceAlertApplication: + """Mark a Silence application as released.""" + application.active = False + application.released_at = now or utc_now() + application.release_reason = reason + application.save() + return application diff --git a/app/modules/db/sso_repo.py b/app/modules/db/sso_repo.py index 63df60d..b91621b 100644 --- a/app/modules/db/sso_repo.py +++ b/app/modules/db/sso_repo.py @@ -3,6 +3,7 @@ from app.modules.db.models import Group, SsoGroupMapping, SsoIdentity, SsoProvider, Team from app.modules.sso.crypto import encrypt_secret from app.modules.sso.saml_security import normalize_sso_extra_config +from app.modules.common import utc_now PROVIDER_FIELDS = [ @@ -101,7 +102,7 @@ def _apply_provider_data(provider, data, update_secret=True): if private_key is not None: provider.saml_sp_private_key_encrypted = encrypt_secret(private_key) - provider.updated_at = datetime.utcnow() + provider.updated_at = utc_now() return provider @@ -162,7 +163,7 @@ def update_provider(provider_id: int, data: dict) -> SsoProvider: def soft_delete_provider(provider_id): """Soft-delete SSO provider and disable it.""" provider = get_provider(provider_id) - now = datetime.utcnow() + now = utc_now() database = SsoProvider._meta.database with database.atomic(): @@ -240,7 +241,7 @@ def update_group_mapping(mapping_id, data): mapping.team_role = data.get("team_role") if team else None mapping.active = data.get("active", True) mapping.priority = data.get("priority", 100) - mapping.updated_at = datetime.utcnow() + mapping.updated_at = utc_now() mapping.save() return mapping @@ -281,7 +282,7 @@ def create_identity(provider_id, user_id, subject, email=None, username=None, ra email=email, username=username, raw_claims=raw_claims, - last_login_at=datetime.utcnow(), + last_login_at=utc_now(), ) @@ -290,6 +291,6 @@ def touch_identity(identity, email=None, username=None, raw_claims=None): identity.email = email identity.username = username identity.raw_claims = raw_claims - identity.last_login_at = datetime.utcnow() + identity.last_login_at = utc_now() identity.save() return identity diff --git a/app/modules/db/teams_repo.py b/app/modules/db/teams_repo.py index cbafcdc..661417f 100644 --- a/app/modules/db/teams_repo.py +++ b/app/modules/db/teams_repo.py @@ -16,6 +16,7 @@ Team, TeamUser, ) +from app.modules.common import utc_now def list_teams(active_only=False, group_ids=None, include_deleted=False): @@ -177,7 +178,7 @@ def remove_team(team_id: int): def soft_delete_team(team_id: int): """Soft-delete a team and all resources under it.""" - now = datetime.utcnow() + now = utc_now() team = get_team(team_id) with Team._meta.database.atomic(): diff --git a/app/modules/db/tokens_repo.py b/app/modules/db/tokens_repo.py index 7c12991..04e867a 100644 --- a/app/modules/db/tokens_repo.py +++ b/app/modules/db/tokens_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import ApiToken +from app.modules.common import utc_now def create_api_token(name, token_prefix, token_hash, scopes, team_id=None, group_id=None, user_id=None, expires_at=None): @@ -68,7 +69,7 @@ def revoke_user_token(token_id, user_id): token.active = False token.deleted = True - token.deleted_at = datetime.utcnow() + token.deleted_at = utc_now() token.save() return token @@ -91,7 +92,7 @@ def mark_token_used(token): Update last token usage timestamp. """ - token.last_used_at = datetime.utcnow() + token.last_used_at = utc_now() token.save() return token @@ -104,7 +105,7 @@ def soft_delete_token(token_id): token = ApiToken.get_by_id(token_id) token.active = False token.deleted = True - token.deleted_at = datetime.utcnow() + token.deleted_at = utc_now() token.save() return token diff --git a/app/modules/db/users_repo.py b/app/modules/db/users_repo.py index 3310e83..79717ec 100644 --- a/app/modules/db/users_repo.py +++ b/app/modules/db/users_repo.py @@ -14,6 +14,7 @@ UserGroup, UserRole, ) +from app.modules.common import utc_now DEFAULT_USERS_PAGE_SIZE = 25 @@ -239,6 +240,8 @@ def update_user(user_id, data): "email", "phone", "timezone", + "locale", + "theme", "telegram_user_id", "slack_user_id", "mattermost_user_id", @@ -283,7 +286,7 @@ def soft_delete_user(user_id): if not user: return None - now = datetime.utcnow() + now = utc_now() database = User._meta.database if user.is_admin and user.active and count_active_admins(exclude_user_id=user.id) == 0: diff --git a/app/modules/sso/crypto.py b/app/modules/sso/crypto.py index 6ec0d1f..8f18794 100644 --- a/app/modules/sso/crypto.py +++ b/app/modules/sso/crypto.py @@ -1,33 +1,22 @@ -import base64 -import hashlib - -from cryptography.fernet import Fernet +"""SSO secret encryption using the historically configured SSO key.""" +from app.modules.crypto import decrypt_secret as _decrypt_secret +from app.modules.crypto import encrypt_secret as _encrypt_secret from app.settings import Config -def _fernet(): - """ - Build a stable Fernet key from configured SSO secret encryption key. - - For production, set [sso] secret_encryption_key in incidentrelay.conf. - If this value changes, existing encrypted provider secrets cannot be decrypted. - """ - raw_key = Config.SSO_SECRET_ENCRYPTION_KEY or Config.SECRET_KEY - digest = hashlib.sha256(raw_key.encode("utf-8")).digest() - key = base64.urlsafe_b64encode(digest) - return Fernet(key) - - def encrypt_secret(value: str | None) -> str | None: - """Encrypt a secret string for database storage.""" - if value in (None, ""): - return None - return _fernet().encrypt(value.encode("utf-8")).decode("utf-8") + return _encrypt_secret( + value, + key=Config.SSO_SECRET_ENCRYPTION_KEY or Config.SECRET_KEY, + ) def decrypt_secret(value: str | None) -> str | None: - """Decrypt a secret string from database storage.""" - if not value: - return None - return _fernet().decrypt(value.encode("utf-8")).decode("utf-8") + return _decrypt_secret( + value, + key=Config.SSO_SECRET_ENCRYPTION_KEY or Config.SECRET_KEY, + ) + + +__all__ = ["decrypt_secret", "encrypt_secret"] diff --git a/app/modules/sso/sso_login.py b/app/modules/sso/sso_login.py index 97458a1..7c346bd 100644 --- a/app/modules/sso/sso_login.py +++ b/app/modules/sso/sso_login.py @@ -11,6 +11,7 @@ from app.modules.db.models import SsoGroupMapping, SsoIdentity, User, UserGroup from app.settings import Config from app.api.schemas.limits import normalize_phone +from app.modules.common import utc_now class SsoLoginError(Exception): @@ -253,7 +254,7 @@ def _resolve_sso_user(provider, claims): identity.email = email identity.username = username identity.raw_claims = raw_claims - identity.last_login_at = datetime.utcnow() + identity.last_login_at = utc_now() identity.save() return user @@ -296,7 +297,7 @@ def _resolve_sso_user(provider, claims): email=email, username=username, raw_claims=raw_claims, - last_login_at=datetime.utcnow(), + last_login_at=utc_now(), ) except IntegrityError: identity = SsoIdentity.get( diff --git a/app/notifiers/browser_push/service.py b/app/notifiers/browser_push/service.py index 3a13a68..0462262 100644 --- a/app/notifiers/browser_push/service.py +++ b/app/notifiers/browser_push/service.py @@ -21,6 +21,7 @@ alert_priority_short_label, format_alert_title_with_priority, ) +from app.modules.common import utc_now logger = logging.getLogger("oncall.notifications") @@ -72,7 +73,7 @@ def save_user_subscription(user, endpoint, keys, device_name=None, user_agent=No BrowserPushSubscription.endpoint == endpoint ) - now = datetime.utcnow() + now = utc_now() if not subscription: return BrowserPushSubscription.create( @@ -115,8 +116,8 @@ def disable_user_subscription(user, subscription_id): subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() return subscription @@ -135,7 +136,7 @@ def create_action_token(user, group, action): group=group.id, action=action, token_hash=_hash_token(raw_token), - expires_at=datetime.utcnow() + timedelta(seconds=ttl), + expires_at=utc_now() + timedelta(seconds=ttl), ) return raw_token @@ -243,7 +244,7 @@ def send_alert_push_to_user(user, group, event_type="notification"): for subscription in subscriptions: try: _webpush(subscription, payload) - subscription.last_seen_at = datetime.utcnow() + subscription.last_seen_at = utc_now() subscription.save() sent += 1 except WebPushException as exc: @@ -252,8 +253,8 @@ def send_alert_push_to_user(user, group, event_type="notification"): if status_code in {404, 410}: subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() logger.warning( @@ -410,7 +411,7 @@ def send_stakeholder_push_to_user( for subscription in subscriptions: try: _webpush(subscription, payload) - subscription.last_seen_at = datetime.utcnow() + subscription.last_seen_at = utc_now() subscription.save() sent += 1 except WebPushException as exc: @@ -423,8 +424,8 @@ def send_stakeholder_push_to_user( if status_code in {404, 410}: subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() logger.warning( @@ -460,7 +461,7 @@ def send_test_push(user): for subscription in _active_user_subscriptions(user.id): try: _webpush(subscription, payload) - subscription.last_seen_at = datetime.utcnow() + subscription.last_seen_at = utc_now() subscription.save() sent += 1 except WebPushException as exc: @@ -469,8 +470,8 @@ def send_test_push(user): if status_code in {404, 410}: subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() logger.warning( @@ -497,7 +498,7 @@ def execute_push_action(token, action): return {"ok": False, "error": "missing_token"} token_hash = _hash_token(token) - now = datetime.utcnow() + now = utc_now() record = BrowserPushActionToken.get_or_none( BrowserPushActionToken.token_hash == token_hash diff --git a/app/notifiers/telegram/poller.py b/app/notifiers/telegram/poller.py index 096ced3..44192d0 100644 --- a/app/notifiers/telegram/poller.py +++ b/app/notifiers/telegram/poller.py @@ -17,6 +17,7 @@ get_telegram_bot, update_telegram_alert, ) +from app.modules.common import utc_now logger = logging.getLogger("oncall.telegram") @@ -80,12 +81,12 @@ def _friendly_transport_error(exc): def _is_channel_transport_backoff_active(channel_id): retry_at = _channel_transport_failed_until.get(channel_id) - return bool(retry_at and retry_at > datetime.utcnow()) + return bool(retry_at and retry_at > utc_now()) def _mark_channel_transport_failed(channel_id): _channel_transport_failed_until[channel_id] = ( - datetime.utcnow() + timedelta(seconds=TELEGRAM_TRANSPORT_RETRY_SECONDS) + utc_now() + timedelta(seconds=TELEGRAM_TRANSPORT_RETRY_SECONDS) ) @@ -101,12 +102,12 @@ def _is_telegram_unauthorized(exc): def _is_channel_auth_backoff_active(channel_id): retry_at = _channel_auth_failed_until.get(channel_id) - return bool(retry_at and retry_at > datetime.utcnow()) + return bool(retry_at and retry_at > utc_now()) def _mark_channel_auth_failed(channel_id): _channel_auth_failed_until[channel_id] = ( - datetime.utcnow() + timedelta(seconds=TELEGRAM_AUTH_RETRY_SECONDS) + utc_now() + timedelta(seconds=TELEGRAM_AUTH_RETRY_SECONDS) ) @@ -328,7 +329,7 @@ def handle_telegram_callback(channel, callback): "action": action, "user_id": user.id, "telegram_user_id": telegram_user_id, - "processed_at": datetime.utcnow().isoformat(), + "processed_at": utc_now().isoformat(), } }, ) diff --git a/app/services/alerts/actions.py b/app/services/alerts/actions.py index 4139fa6..bd9a325 100644 --- a/app/services/alerts/actions.py +++ b/app/services/alerts/actions.py @@ -1,4 +1,5 @@ from app.modules.db import alerts_repo, users_repo +from app.modules.db.models import AlertGroup from app.services.alerts.correlation import refresh_alert_group_correlations_safely from app.services.incidents.stakeholders import notify_stakeholders from app.services.notifications.delivery import update_alert_messages @@ -47,7 +48,12 @@ def acknowledge_alert(alert_id, user_id=None): return group -def resolve_alert(alert_id, user_id=None): +def resolve_alert( + alert_id: int, + user_id: int | None = None, + *, + update_messages: bool = True, +) -> AlertGroup: """Resolve an alert group.""" group_before = alerts_repo.get_alert_group(alert_id) old_status = getattr(group_before, "status", None) @@ -76,7 +82,8 @@ def resolve_alert(alert_id, user_id=None): refresh_business_impacts_safely_for_group(group, reason="manual_resolve") - update_alert_messages(group, event_type="resolved") + if update_messages: + update_alert_messages(group, event_type="resolved") if old_status != group.status: notify_stakeholders( diff --git a/app/services/alerts/correlation.py b/app/services/alerts/correlation.py index 4b8c2b1..d63f56f 100644 --- a/app/services/alerts/correlation.py +++ b/app/services/alerts/correlation.py @@ -5,6 +5,7 @@ from app.db import database_proxy from app.modules.db.models import AlertGroup, AlertGroupCorrelation, ServiceDependency from app.modules.db import alerts_repo +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -53,7 +54,7 @@ def _seen_at(group): getattr(group, "last_seen_at", None) or getattr(group, "first_seen_at", None) or getattr(group, "created_at", None) - or datetime.utcnow() + or utc_now() ) @@ -428,7 +429,7 @@ def _deactivate_stale_correlations(group, now, keep_ids=None): def refresh_alert_group_correlations(group): """Recalculate and persist dependency-aware correlations for a group.""" - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): if getattr(group, "status", None) not in ACTIVE_ALERT_GROUP_STATUSES: diff --git a/app/services/alerts/escalation.py b/app/services/alerts/escalation.py index f4b5846..5ba7a49 100644 --- a/app/services/alerts/escalation.py +++ b/app/services/alerts/escalation.py @@ -4,12 +4,82 @@ from app.modules.db import alerts_repo from app.services import escalation_policies as escalation_policy_service from app.services.notifications.delivery import has_matching_notification_channel, notify_alert +from app.services.alerts.maintenance_state import ( + is_escalation_lifecycle_paused, + is_notification_lifecycle_suppressed, + pause_notification_lifecycle, + resume_notification_lifecycle, +) from app.services.oncall import get_current_oncall_user, get_next_rotation_user +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") -def apply_initial_escalation_policy_assignment(policy, fallback_rotation): +def _deliver_escalation_notification(group): + """Attempt escalation delivery without affecting the state transition.""" + has_target = False + resolution_error = None + + try: + has_target = has_matching_notification_channel( + group, + event_type="escalation", + ) + except Exception as exc: + resolution_error = exc + logger.exception( + "escalation notification target resolution failed", + extra={ + "extra": { + "alert_group_id": group.id, + "route_id": group.route.id if group.route else None, + } + }, + ) + + try: + sent_count = notify_alert(group, event_type="escalation") + except Exception as exc: + sent_count = 0 + resolution_error = resolution_error or exc + logger.exception( + "escalation notification delivery failed", + extra={ + "extra": { + "alert_group_id": group.id, + "route_id": group.route.id if group.route else None, + } + }, + ) + + if sent_count: + return sent_count + + if has_target or resolution_error is not None: + event_type = "escalation_notification_failed" + message = "Escalation completed, but notification delivery did not succeed." + else: + event_type = "escalation_notification_skipped" + message = ( + "Escalation completed, but no matching notification target was found." + ) + + alerts_repo.create_alert_event( + group_id=group.id, + event_type=event_type, + message=message, + ) + + return 0 + + +def apply_initial_escalation_policy_assignment( + policy, + fallback_rotation, + *, + now: datetime | None = None, +): """Return initial policy rule, rotation, assignee and next escalation time.""" policy_rule = None rotation = fallback_rotation @@ -36,7 +106,7 @@ def apply_initial_escalation_policy_assignment(policy, fallback_rotation): delay_seconds = escalation_policy_service.get_rule_delay_seconds(policy_rule) if delay_seconds: - next_escalation_at = datetime.utcnow() + timedelta(seconds=delay_seconds) + next_escalation_at = (now or utc_now()) + timedelta(seconds=delay_seconds) return policy_rule, rotation, assignee, next_escalation_at @@ -44,25 +114,24 @@ def apply_initial_escalation_policy_assignment(policy, fallback_rotation): def maybe_escalate_alert(group): """Escalate an alert group according to route escalation mode.""" + if is_notification_lifecycle_suppressed(group): + pause_notification_lifecycle(group) + return False + + if is_escalation_lifecycle_paused(group): + group.next_escalation_at = None + group.save(only=[group.__class__.next_escalation_at]) + return False + + if resume_notification_lifecycle(group): + return False + if group.escalation_policy_id: return maybe_escalate_alert_by_policy(group) if not group.team or not group.team.escalation_enabled: return False - if not has_matching_notification_channel(group, event_type="escalation"): - logger.debug( - "escalation skipped because no channel matches alert group severity", - extra={ - "extra": { - "alert_group_id": group.id, - "severity": group.severity, - "route_id": group.route.id if group.route else None, - } - }, - ) - return False - if group.reminder_count < group.team.escalation_after_reminders: return False @@ -71,7 +140,7 @@ def maybe_escalate_alert(group): if not next_user or (group.assignee and next_user.id == group.assignee.id): return False - now = datetime.utcnow() + now = utc_now() group.assignee = next_user.id group.escalation_level = (group.escalation_level or 0) + 1 @@ -86,7 +155,7 @@ def maybe_escalate_alert(group): message=f"Escalated to {next_user.username}", ) - notify_alert(group, event_type="escalation") + _deliver_escalation_notification(group) return True @@ -94,10 +163,22 @@ def maybe_escalate_alert(group): def maybe_escalate_alert_by_policy(group): """Escalate an alert group according to its escalation policy.""" + if is_notification_lifecycle_suppressed(group): + pause_notification_lifecycle(group) + return False + + if is_escalation_lifecycle_paused(group): + group.next_escalation_at = None + group.save(only=[group.__class__.next_escalation_at]) + return False + + if resume_notification_lifecycle(group): + return False + if not group.escalation_policy_id: return False - now = datetime.utcnow() + now = utc_now() if not group.next_escalation_at: return False @@ -105,20 +186,6 @@ def maybe_escalate_alert_by_policy(group): if group.next_escalation_at > now: return False - if not has_matching_notification_channel(group, event_type="escalation"): - logger.debug( - "policy escalation skipped because no channel matches alert group severity", - extra={ - "extra": { - "alert_group_id": group.id, - "severity": group.severity, - "route_id": group.route.id if group.route else None, - "escalation_policy_id": group.escalation_policy_id, - } - }, - ) - return False - next_rule, repeat_count = escalation_policy_service.get_next_rule_for_alert(group) if not next_rule: @@ -162,6 +229,6 @@ def maybe_escalate_alert_by_policy(group): message=f"Escalated by policy rule #{next_rule.position} to {target}", ) - notify_alert(group, event_type="escalation") + _deliver_escalation_notification(group) return True diff --git a/app/services/alerts/explain_cleanup.py b/app/services/alerts/explain_cleanup.py index 9bc9b08..29b3847 100644 --- a/app/services/alerts/explain_cleanup.py +++ b/app/services/alerts/explain_cleanup.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from app.modules.db import alerts_repo +from app.modules.common import utc_now DEFAULT_ALERT_EXPLAIN_RETENTION_DAYS = 30 @@ -16,7 +17,7 @@ def cleanup_alert_explain_traces(*, retention_days=None, now=None): raise ValueError("retention_days must be greater than 0") if now is None: - now = datetime.utcnow() + now = utc_now() cutoff = now - timedelta(days=retention_days) diff --git a/app/services/alerts/lifecycle.py b/app/services/alerts/lifecycle.py index 3837570..2a2bc7c 100644 --- a/app/services/alerts/lifecycle.py +++ b/app/services/alerts/lifecycle.py @@ -1,7 +1,7 @@ import logging -from datetime import datetime from app import Config +from app.modules.common import utc_now from app.modules.db import alerts_repo, incidents_repo from app.services.alerts.escalation import apply_initial_escalation_policy_assignment from app.services.alerts.explain import AlertExplainTrace @@ -9,7 +9,9 @@ apply_maintenance_to_existing_alert, maintenance_create_kwargs, maybe_apply_maintenance_to_group, + reconcile_alert_group_maintenance, record_maintenance_match, + should_apply_window_to_group, ) from app.services.alerts.notification_queue import schedule_group_notification from app.services.alerts.priority import ( @@ -21,6 +23,15 @@ ) from app.services.incidents.priority_policies.resolver import resolve_incident_priority from app.services.alerts.result import AlertProcessingResult +from app.services.orchestration.runtime import ( + attach_runtime_executions, + run_event_orchestration, + run_service_orchestration, +) +from app.services.orchestration.pending import ( + resolve_pending_event, + store_paused_event, +) from app.services.incidents.stakeholders import notify_stakeholders from app.services.maintenance import get_maintenance_decision from app.services.notifications.delivery import notify_alert @@ -30,14 +41,31 @@ get_effective_route_rotation, resolve_alert_service, ) -from app.services.silences import find_active_silence +from app.services.silences import find_active_silences, record_new_alert_silences from app.services.alerts.correlation import refresh_alert_group_correlations, refresh_alert_group_correlations_safely from app.services.business_services.impact import refresh_business_impacts_safely_for_group from app.services.business_services.status import refresh_business_services_safely_for_technical_service +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") +def _route_for_runtime(alert_data, runtime, *, current_route=None): + if runtime is None: + return find_route_for_alert(alert_data) + + if runtime.route_selected_by_orchestration: + return runtime.route + + if runtime.route is None: + return find_route_for_alert(alert_data) + + if current_route is None: + return find_route_for_alert(alert_data) + + return current_route + + def _add_service_stakeholders_for_new_group(group): """Auto-add service stakeholders to a newly created incident.""" try: @@ -65,9 +93,39 @@ def upsert_alert(alert_data): AlertProcessingResult """ trace = AlertExplainTrace.start(alert_data) + runtime = None try: - return _upsert_alert(alert_data, trace) + runtime = run_event_orchestration(alert_data, trace=trace) + if runtime.blocked: + return _stopped_result( + trace=trace, + outcome="orchestration_blocked", + reason=runtime.reason or "Event orchestration blocked processing.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + if ( + runtime.disposition == "drop" + and (alert_data.get("status") or "firing") != "resolved" + ): + trace.step( + "orchestration", + "orchestration_event_dropped", + "success", + "Event dropped by orchestration", + runtime.disposition_reason, + ) + result = _stopped_result( + trace=trace, + outcome="dropped", + reason=runtime.disposition_reason or "Event dropped by orchestration.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + attach_runtime_executions(runtime, group=None, alert=None) + return result + result = _upsert_alert(alert_data, trace, runtime=runtime) + attach_runtime_executions(runtime, group=result.group, alert=result.alert) + return result except Exception as exc: trace.fail(exc) raise @@ -102,14 +160,23 @@ def _completed_result(*, trace, group, alert, created_group, outcome): ) -def _resolve_policy_assignment(route, service, rotation, maintenance_decision, trace): - policy = get_effective_escalation_policy(route, service) +def _resolve_policy_assignment( + route, + service, + rotation, + maintenance_decision, + trace, + *, + policy_override=None, + orchestration_suppressed=False, +): + policy = policy_override or get_effective_escalation_policy(route, service) policy_rule, rotation, assignee, next_escalation_at = ( apply_initial_escalation_policy_assignment(policy, rotation) ) - if maintenance_decision.pause_escalation_only: + if maintenance_decision.pause_escalation_only or orchestration_suppressed: next_escalation_at = None trace.policy_resolved(policy, policy_rule) @@ -131,6 +198,7 @@ def _create_group( rotation, policy, policy_rule, + notification_policy, assignee, next_escalation_at, group_key, @@ -140,6 +208,8 @@ def _create_group( silenced, priority_kwargs, maintenance_kwargs, + orchestration_suppressed=False, + orchestration_suppress_reason=None, ): return alerts_repo.create_alert_group( team=team.id if team else None, @@ -148,6 +218,9 @@ def _create_group( rotation=rotation.id if rotation else None, escalation_policy=policy.id if policy else None, escalation_rule=policy_rule.id if policy_rule else None, + notification_policy=( + notification_policy.id if notification_policy else None + ), next_escalation_at=next_escalation_at, assignee=assignee.id if assignee else None, source=alert_data["source"], @@ -160,6 +233,8 @@ def _create_group( last_seen_at=last_seen_at, silenced=silenced, priority_set_manually=False, + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, **priority_kwargs, **maintenance_kwargs, ) @@ -172,12 +247,15 @@ def _set_alert_routing_fields( route, service, rotation, + notification_policy, group, ): alert.team = team.id if team else None alert.route = route.id if route else None alert.service = service.id if service else None alert.rotation = rotation.id if rotation else None + if notification_policy is not None: + alert.notification_policy = notification_policy.id alert.group = group.id @@ -198,6 +276,10 @@ def _handle_existing_alert( maintenance_decision, maintenance_kwargs, now, + policy_override=None, + notification_policy_override=None, + orchestration_suppressed=False, + orchestration_suppress_reason=None, ): group = existing_alert.group or existing_group priority = priority_resolution.priority @@ -212,6 +294,8 @@ def _handle_existing_alert( rotation, maintenance_decision, trace, + policy_override=policy_override, + orchestration_suppressed=orchestration_suppressed, ) ) @@ -223,6 +307,7 @@ def _handle_existing_alert( rotation=rotation, policy=policy, policy_rule=policy_rule, + notification_policy=notification_policy_override, assignee=assignee, next_escalation_at=next_escalation_at, group_key=group_key, @@ -232,6 +317,8 @@ def _handle_existing_alert( silenced=bool(existing_alert.silenced), priority_kwargs=priority_kwargs, maintenance_kwargs=maintenance_kwargs, + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, ) created_group = True @@ -278,11 +365,23 @@ def _handle_existing_alert( route=route, service=service, rotation=rotation, + notification_policy=notification_policy_override, group=group, ) - if maintenance_decision.pause_escalation_only: + if notification_policy_override is not None: + group.notification_policy = notification_policy_override.id + + existing_alert.orchestration_suppressed = orchestration_suppressed + existing_alert.orchestration_suppress_reason = orchestration_suppress_reason + group.orchestration_suppressed = orchestration_suppressed + group.orchestration_suppress_reason = orchestration_suppress_reason + group.save() + + if maintenance_decision.pause_escalation_only or orchestration_suppressed: existing_alert.next_escalation_at = None + group.next_escalation_at = None + group.save(only=[group.__class__.next_escalation_at]) apply_priority_to_existing_alert(existing_alert, priority) apply_maintenance_to_existing_alert(existing_alert, maintenance_decision) @@ -347,7 +446,16 @@ def _handle_existing_alert( refresh_business_impacts_safely_for_group(group, reason="existing_alert_update") - if maintenance_decision.suppress_notifications: + if orchestration_suppressed: + alerts_repo.clear_alert_group_notification(group) + trace.step( + "orchestration", + "orchestration_notifications_suppressed", + "success", + "Notifications suppressed by orchestration", + orchestration_suppress_reason, + ) + elif maintenance_decision.suppress_notifications: alerts_repo.clear_alert_group_notification(group) trace.notification_suppressed( behavior=maintenance_decision.behavior, @@ -404,8 +512,8 @@ def _handle_existing_alert( ) -def _upsert_alert(alert_data, trace): - route = find_route_for_alert(alert_data) +def _upsert_alert(alert_data, trace, runtime=None): + route = _route_for_runtime(alert_data, runtime) if not route: trace.route_not_matched(alert_data) @@ -435,7 +543,52 @@ def _upsert_alert(alert_data, trace): ) team = route.team - service = resolve_alert_service(route, alert_data) + service = ( + runtime.service + if runtime and runtime.service + else resolve_alert_service(route, alert_data) + ) + + if runtime is not None and service is not None: + runtime = run_service_orchestration( + alert_data, + runtime, + route=route, + team=team, + service=service, + trace=trace, + ) + + if runtime.blocked: + return _stopped_result( + trace=trace, + outcome="orchestration_blocked", + reason=runtime.reason or "Service orchestration blocked processing.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + + route = _route_for_runtime( + alert_data, + runtime, + current_route=route, + ) + + if not route: + trace.route_not_matched(alert_data) + + return _stopped_result( + trace=trace, + outcome="routing_failed", + reason="Service orchestration did not leave an active route.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + team = route.team + service = ( + runtime.service + if runtime.service + else resolve_alert_service(route, alert_data) + ) + rotation = get_effective_route_rotation(route, service) trace.route_matched(route, team) @@ -443,6 +596,70 @@ def _upsert_alert(alert_data, trace): trace.rotation_resolved(rotation) status = alert_data.get("status") or "firing" + group_id = getattr(getattr(route, "team", None), "group_id", None) + + if status == "resolved" and group_id is not None: + pending = resolve_pending_event( + group_id=group_id, + source=alert_data.get("source"), + dedup_key=alert_data.get("dedup_key"), + trace=trace, + ) + if pending is not None: + return _stopped_result( + trace=trace, + outcome="resolved_before_activation", + reason="Paused event resolved before activation.", + result={ + "created": False, + "group_id": None, + "alert_id": None, + "pending_event_id": pending.id, + }, + ) + + if runtime is not None and runtime.disposition == "drop": + trace.step( + "orchestration", + "orchestration_event_dropped", + "success", + "Event dropped by orchestration", + runtime.disposition_reason, + ) + return _stopped_result( + trace=trace, + outcome="dropped", + reason=runtime.disposition_reason or "Event dropped by orchestration.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + + if runtime is not None and runtime.disposition == "pause": + pending = store_paused_event( + alert_data, + runtime, + route=route, + service=service, + trace=trace, + ) + return _stopped_result( + trace=trace, + outcome="paused", + reason=runtime.disposition_reason or "Event activation paused by orchestration.", + result={ + "created": False, + "group_id": None, + "alert_id": None, + "pending_event_id": pending.id, + "activation_at": pending.activation_at.isoformat(), + }, + ) + + orchestration_suppressed = bool( + runtime is not None and runtime.disposition == "suppress" + ) + orchestration_suppress_reason = ( + runtime.disposition_reason if orchestration_suppressed else None + ) priority_resolution = resolve_incident_priority( alert_data, @@ -458,15 +675,15 @@ def _upsert_alert(alert_data, trace): severity=alert_data.get("severity"), ) - group_key = build_group_key( - route, - alert_data, - service=service, + group_key = ( + runtime.group_key + if runtime and runtime.group_key + else build_group_key(route, alert_data, service=service) ) trace.group_key_built(group_key) - now = datetime.utcnow() + now = utc_now() maintenance_decision = get_maintenance_decision( team=team, @@ -476,17 +693,20 @@ def _upsert_alert(alert_data, trace): now=now, ) - if maintenance_decision.incident_status: - status = maintenance_decision.incident_status - + maintenance_incident_status = maintenance_decision.incident_status maintenance_kwargs = maintenance_create_kwargs(maintenance_decision) trace.maintenance_resolved(maintenance_decision) + grouping_window_seconds = ( + runtime.grouping_window_seconds + if runtime and runtime.grouping_window_seconds is not None + else Config.ALERT_GROUP_WINDOW_SECONDS + ) existing_alert = alerts_repo.find_existing_alert( alert_data["source"], alert_data["dedup_key"], - Config.ALERT_GROUP_WINDOW_SECONDS, + grouping_window_seconds, ) existing_group = alerts_repo.find_open_alert_group( @@ -502,6 +722,20 @@ def _upsert_alert(alert_data, trace): existing_group=existing_group, ) + target_group = existing_alert.group if existing_alert and existing_alert.group else existing_group + if target_group and maintenance_decision.window and not should_apply_window_to_group( + maintenance_decision.window, + target_group, + now=now, + ): + from app.services.maintenance import MaintenanceDecision + maintenance_decision = MaintenanceDecision() + maintenance_kwargs = {} + maintenance_incident_status = None + + if maintenance_incident_status: + status = maintenance_incident_status + if maintenance_decision.suppress_incident and not existing_alert and not existing_group: trace.incident_suppressed(maintenance_decision) @@ -538,6 +772,12 @@ def _upsert_alert(alert_data, trace): maintenance_decision=maintenance_decision, maintenance_kwargs=maintenance_kwargs, now=now, + policy_override=(runtime.escalation_policy if runtime else None), + notification_policy_override=( + runtime.notification_policy if runtime else None + ), + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, ) if status == "resolved": @@ -573,17 +813,21 @@ def _upsert_alert(alert_data, trace): rotation, maintenance_decision, trace, + policy_override=(runtime.escalation_policy if runtime else None), + orchestration_suppressed=orchestration_suppressed, ) ) - silence = find_active_silence( + silences = find_active_silences( team.id if team else None, alert_data, + now=now, ) + silence = silences[0] if silences else None trace.silence_resolved(silence) - if silence and status == "firing": + if silences and status == "firing": status = "silenced" group = existing_group @@ -598,6 +842,7 @@ def _upsert_alert(alert_data, trace): rotation=rotation, policy=policy, policy_rule=policy_rule, + notification_policy=(runtime.notification_policy if runtime else None), assignee=assignee, next_escalation_at=next_escalation_at, group_key=group_key, @@ -607,6 +852,8 @@ def _upsert_alert(alert_data, trace): silenced=bool(silence), priority_kwargs=priority_kwargs, maintenance_kwargs=maintenance_kwargs, + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, ) created_group = True @@ -642,6 +889,20 @@ def _upsert_alert(alert_data, trace): else: trace.group_reused(group) + group.orchestration_suppressed = orchestration_suppressed + group.orchestration_suppress_reason = orchestration_suppress_reason + group_fields = [ + group.__class__.orchestration_suppressed, + group.__class__.orchestration_suppress_reason, + ] + if orchestration_suppressed: + group.next_escalation_at = None + group_fields.append(group.__class__.next_escalation_at) + if runtime is not None and runtime.notification_policy is not None: + group.notification_policy = runtime.notification_policy.id + group_fields.append(group.__class__.notification_policy) + group.save(only=group_fields) + priority_state_before_recalculate = group_priority_state(group) previous_priority_slug = ( @@ -665,6 +926,11 @@ def _upsert_alert(alert_data, trace): rotation=rotation.id if rotation else None, escalation_policy=policy.id if policy else None, escalation_rule=policy_rule.id if policy_rule else None, + notification_policy=( + runtime.notification_policy.id + if runtime and runtime.notification_policy + else getattr(group, "notification_policy_id", None) + ), next_escalation_at=next_escalation_at, assignee=assignee.id if assignee else None, source=alert_data["source"], @@ -680,12 +946,17 @@ def _upsert_alert(alert_data, trace): first_seen_at=now, last_seen_at=now, silenced=bool(silence), + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, **priority_kwargs, **maintenance_kwargs, ) trace.alert_created(alert, group) + if silences: + record_new_alert_silences(alert, silences, now=now) + alerts_repo.create_alert_event( alert_id=alert.id, group_id=group.id, @@ -700,6 +971,12 @@ def _upsert_alert(alert_data, trace): alert_id=alert.id, ) + reconcile_alert_group_maintenance( + group, + now=now, + trigger_source="intake", + ) + if alert_data.get("routing_error"): alerts_repo.create_alert_event( alert_id=alert.id, @@ -710,12 +987,12 @@ def _upsert_alert(alert_data, trace): trace.routing_warning_recorded(alert_data["routing_error"]) - if silence: + for matched_silence in silences: alerts_repo.create_alert_event( alert_id=alert.id, group_id=group.id, event_type="silenced", - message=f"Matched silence: {silence.name}", + message=f"Matched silence: {matched_silence.name}", ) group = alerts_repo.recalculate_alert_group(group) @@ -807,7 +1084,16 @@ def _upsert_alert(alert_data, trace): }, ) - if ( + if orchestration_suppressed: + alerts_repo.clear_alert_group_notification(group) + trace.step( + "orchestration", + "orchestration_notifications_suppressed", + "success", + "Notifications suppressed by orchestration", + orchestration_suppress_reason, + ) + elif ( status == "firing" and group.status == "firing" and not maintenance_decision.suppress_notifications diff --git a/app/services/alerts/maintenance_state.py b/app/services/alerts/maintenance_state.py index be24319..b3b7289 100644 --- a/app/services/alerts/maintenance_state.py +++ b/app/services/alerts/maintenance_state.py @@ -1,7 +1,23 @@ -from app.modules.db import alerts_repo +from datetime import datetime, timezone as dt_timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from app.modules.common import utc_now +from app.modules.db import alerts_repo, audit_repo, maintenance_repo +from app.modules.db.models import ( + Alert, + AlertGroup, + MaintenanceWindow, + MaintenanceWindowAlertApplication, +) +from app.services import escalation_policies as escalation_policy_service -def maintenance_create_kwargs(maintenance_decision): +SUPPRESS_NOTIFICATIONS_BEHAVIOR = "suppress_notifications" +PAUSE_ESCALATION_BEHAVIOR = "pause_escalation_only" +MAINTENANCE_INCIDENT_BEHAVIOR = "create_maintenance_incident" +SUPPRESS_INCIDENT_BEHAVIOR = "suppress_incident" + + +def maintenance_create_kwargs(maintenance_decision) -> dict[str, object]: if not maintenance_decision or not maintenance_decision.window: return {} @@ -12,39 +28,735 @@ def maintenance_create_kwargs(maintenance_decision): } -def apply_maintenance_to_existing_alert(alert, maintenance_decision): +def _occurrence_start_utc(window: MaintenanceWindow, now=None) -> datetime | None: + occurrence = maintenance_repo.get_effective_window_occurrence(window, now=now) + if not occurrence: + return None + starts_at = occurrence.get("starts_at") + if not starts_at: + return None + try: + zone = ZoneInfo(window.timezone or "UTC") + except ZoneInfoNotFoundError: + zone = ZoneInfo("UTC") + return ( + starts_at.replace(tzinfo=zone) + .astimezone(dt_timezone.utc) + .replace(tzinfo=None) + ) + + +def should_apply_window_to_group( + window: MaintenanceWindow, + group: AlertGroup, + *, + now: datetime | None = None, +) -> bool: + """Return whether an active window may affect this existing group.""" + if not window or not group or group.status == "resolved": + return False + if window.behavior == SUPPRESS_INCIDENT_BEHAVIOR: + return False + if not maintenance_repo.is_window_active_now(window, now=now): + return False + if window.apply_to_existing: + return True + occurrence_start = _occurrence_start_utc(window, now=now) + if not occurrence_start: + return False + first_seen_at = group.first_seen_at or group.created_at + return bool(first_seen_at and first_seen_at >= occurrence_start) + + +def _group_matches_window_scope( + window: MaintenanceWindow, + group: AlertGroup, +) -> bool: + """Return whether the group still belongs to at least one window scope.""" + for scope in maintenance_repo.list_maintenance_window_scopes(window.id): + if scope.scope_type == "group": + if group.team_id and group.team.group_id == scope.group_id: + return True + elif scope.scope_type == "team" and group.team_id == scope.team_id: + return True + elif scope.scope_type == "service" and group.service_id == scope.service_id: + return True + elif scope.scope_type == "route" and group.route_id == scope.route_id: + return True + return False + + +def _iter_batches(items, batch_size: int | None): + """Yield deterministic in-memory batches without dropping later rows.""" + size = max(int(batch_size or len(items) or 1), 1) + for offset in range(0, len(items), size): + yield items[offset:offset + size] + + +def apply_maintenance_to_existing_alert(alert: Alert, maintenance_decision) -> None: if not maintenance_decision or not maintenance_decision.window: return - + if alert.group and not should_apply_window_to_group( + maintenance_decision.window, + alert.group, + ): + return alert.maintenance_window = maintenance_decision.window alert.maintenance_behavior = maintenance_decision.behavior alert.maintenance_suppressed = maintenance_decision.suppress_notifications -def maybe_apply_maintenance_to_group(group, maintenance_decision): - if not maintenance_decision or not maintenance_decision.window: - return +def _active_group_applications( + group: AlertGroup, +) -> list[MaintenanceWindowAlertApplication]: + return maintenance_repo.list_group_applications(group, active_only=True) + + +def _application_effect_is_active( + application: MaintenanceWindowAlertApplication, + *, + now: datetime | None = None, +) -> bool: + """Return whether an active application still affects its alert group. + + Application rows remain active until the lifecycle reconciler records their + release. Runtime notification and escalation checks must still notice that + a window has already ended, otherwise an expired window can suppress the + group until the scheduler performs its next reconciliation pass. + """ + if not application or not application.active: + return False + + window = application.maintenance_window + if not window or getattr(window, "deleted", False): + return False + + if application.retained_at is not None: + return True + + if maintenance_repo.is_window_active_now(window, now=now): + return True + + # When automatic reactivation is disabled, the effect intentionally + # remains in force after expiry, disable, or cancellation. The scheduler + # later marks the row as retained, but runtime checks must preserve the + # configured behavior even before that reconciliation occurs. + return not bool(window.reactivate_on_end) + + +def _effective_group_applications( + group: AlertGroup, + *, + now: datetime | None = None, +) -> list[MaintenanceWindowAlertApplication]: + return [ + application + for application in _active_group_applications(group) + if _application_effect_is_active(application, now=now) + ] + - group.maintenance_window = maintenance_decision.window - group.maintenance_behavior = maintenance_decision.behavior - group.maintenance_suppressed = maintenance_decision.suppress_notifications +def is_notification_lifecycle_suppressed( + group: AlertGroup, + *, + now: datetime | None = None, +) -> bool: + if not group: + return False + if any( + application.behavior == SUPPRESS_NOTIFICATIONS_BEHAVIOR + for application in _effective_group_applications(group, now=now) + ): + return True + window = getattr(group, "maintenance_window", None) + behavior = getattr(window, "behavior", None) or group.maintenance_behavior + return ( + behavior == SUPPRESS_NOTIFICATIONS_BEHAVIOR + and maintenance_repo.is_window_active_now(window, now=now) + ) + + +def is_escalation_lifecycle_paused( + group: AlertGroup, + *, + now: datetime | None = None, +) -> bool: + if not group: + return False + paused_behaviors = { + SUPPRESS_NOTIFICATIONS_BEHAVIOR, + PAUSE_ESCALATION_BEHAVIOR, + MAINTENANCE_INCIDENT_BEHAVIOR, + } + if any( + application.behavior in paused_behaviors + for application in _effective_group_applications(group, now=now) + ): + return True + window = getattr(group, "maintenance_window", None) + return bool( + group.maintenance_behavior in paused_behaviors + and maintenance_repo.is_window_active_now(window, now=now) + ) + + +def pause_notification_lifecycle(group: AlertGroup) -> bool: + changed = False + newly_suppressed = not bool(group.maintenance_suppressed) + if newly_suppressed: + group.maintenance_suppressed = True + changed = True + if group.notification_pending or group.notification_due_at or group.notification_reason: + group.notification_pending = False + group.notification_due_at = None + group.notification_reason = None + changed = True + if group.next_escalation_at is not None: + group.next_escalation_at = None + changed = True + if changed: + group.updated_at = utc_now() + group.save() + if newly_suppressed: + alerts_repo.create_alert_event( + group_id=group.id, + event_type="maintenance_notifications_paused", + message="Notification, reminder, and escalation processing paused by maintenance", + ) + return changed + + +def resume_notification_lifecycle( + group: AlertGroup, + *, + now: datetime | None = None, +) -> bool: + if not group or not bool(group.maintenance_suppressed): + return False + if is_notification_lifecycle_suppressed(group, now=now): + return False + now = now or utc_now() + next_escalation_at = None + if ( + group.status == "firing" + and group.escalation_policy_id + and group.escalation_rule_id + and not is_escalation_lifecycle_paused(group, now=now) + ): + next_escalation_at = escalation_policy_service.get_next_escalation_at( + group.escalation_rule, + now, + ) + updated = ( + AlertGroup.update( + maintenance_suppressed=False, + reminder_count=0, + next_escalation_at=next_escalation_at, + updated_at=now, + ) + .where( + AlertGroup.id == group.id, + AlertGroup.maintenance_suppressed == True, # noqa: E712 + ) + .execute() + ) + if not updated: + return False + group.maintenance_suppressed = False + group.reminder_count = 0 + group.next_escalation_at = next_escalation_at + group.updated_at = now + alerts_repo.create_alert_event( + group_id=group.id, + event_type="maintenance_notifications_resumed", + message="Maintenance ended; notification and escalation processing resumed", + ) + return True + + +def _original_status_for_group(group: AlertGroup, fallback: str | None = None) -> str: + if fallback and fallback not in {"maintenance", "resolved"}: + return fallback + for application in maintenance_repo.list_group_applications(group): + if application.previous_status not in (None, "maintenance", "resolved"): + return application.previous_status + if group.previous_status not in (None, "maintenance", "resolved"): + return group.previous_status + return "firing" + + +def _write_application_audit( + action: str, + application: MaintenanceWindowAlertApplication, + *, + trigger_source: str, + actor_user_id: int | None, + previous_state: str | None, + new_state: str | None, +) -> None: + group = application.alert_group + window = application.maintenance_window + audit_repo.create_audit_log( + action=action, + object_type="alert_group", + object_id=group.id, + group_id=group.team.group_id if group.team_id and group.team else None, + team_id=group.team_id, + user_id=actor_user_id, + message=None, + data={ + "maintenance_window_id": window.id, + "maintenance_window_name": window.name, + "behavior": application.behavior, + "previous_state": previous_state, + "new_state": new_state, + "trigger_source": trigger_source, + }, + ) + + +def _set_representative_maintenance(group: AlertGroup, applications) -> None: + representative = applications[0] if applications else None + group.maintenance_window = ( + representative.maintenance_window_id if representative else None + ) + group.maintenance_behavior = representative.behavior if representative else None + Alert.update( + maintenance_window=group.maintenance_window_id, + maintenance_behavior=group.maintenance_behavior, + maintenance_suppressed=group.maintenance_suppressed, + ).where( + Alert.group == group.id, + Alert.status != "resolved", + ).execute() + + +def _recompute_group_effects( + group: AlertGroup, + *, + now: datetime, + fallback_status: str | None = None, + notify_on_resume: bool = False, +) -> None: + applications = _active_group_applications(group) + behaviors = {application.behavior for application in applications} + create_maintenance = MAINTENANCE_INCIDENT_BEHAVIOR in behaviors + suppress_notifications = SUPPRESS_NOTIFICATIONS_BEHAVIOR in behaviors + pause_escalation = bool( + behaviors + & { + SUPPRESS_NOTIFICATIONS_BEHAVIOR, + PAUSE_ESCALATION_BEHAVIOR, + MAINTENANCE_INCIDENT_BEHAVIOR, + } + ) + was_maintenance = group.status == "maintenance" + was_suppressed = bool(group.maintenance_suppressed) + + if create_maintenance and group.status != "resolved": + original_status = _original_status_for_group(group, fallback_status) + if group.status != "maintenance": + group.previous_status = group.status + group.status = "maintenance" + group.resolved_at = None + Alert.update( + status="maintenance", + previous_status=Alert.status, + maintenance_suppressed=False, + ).where( + Alert.group == group.id, + Alert.status.not_in(("resolved", "maintenance")), + ).execute() + if not any(app.previous_status for app in applications): + applications[0].previous_status = original_status + applications[0].save(only=[applications[0].__class__.previous_status]) + elif was_maintenance: + restore_status = _original_status_for_group(group, fallback_status) + Alert.update( + status=Alert.previous_status, + previous_status=None, + ).where( + Alert.group == group.id, + Alert.status == "maintenance", + Alert.previous_status.is_null(False), + ).execute() + Alert.update(status="firing").where( + Alert.group == group.id, + Alert.status == "maintenance", + ).execute() + group.status = restore_status + group.resolved_at = None + group.resolved_by = None + group.save() + group = alerts_repo.recalculate_alert_group(group) + if restore_status == "acknowledged" and group.status != "resolved": + group.status = "acknowledged" + + if suppress_notifications: + pause_notification_lifecycle(group) + elif was_suppressed: + resume_notification_lifecycle(group, now=now) + + if pause_escalation: + group.next_escalation_at = None + Alert.update(next_escalation_at=None).where( + Alert.group == group.id, + Alert.status != "resolved", + ).execute() + elif ( + group.status == "firing" + and group.escalation_policy_id + and group.escalation_rule_id + ): + group.next_escalation_at = escalation_policy_service.get_next_escalation_at( + group.escalation_rule, + now, + ) + + if create_maintenance: + group.notification_pending = False + group.notification_due_at = None + group.notification_reason = None + group.next_escalation_at = None + + group.updated_at = now + group.save() + _set_representative_maintenance(group, applications) group.save() + if ( + notify_on_resume + and group.status == "firing" + and not suppress_notifications + and not create_maintenance + ): + from app.services.alerts.notification_queue import schedule_group_notification + schedule_group_notification(group, reason="maintenance_ended", now=now) -def record_maintenance_match(group, maintenance_decision, *, alert_id=None): - """Write timeline event when an alert/incident matched maintenance.""" - if not group or not maintenance_decision or not maintenance_decision.matched: + +def _apply_window_to_group( + window: MaintenanceWindow, + group: AlertGroup, + *, + now: datetime, + occurrence_start: datetime | None, + trigger_source: str, + actor_user_id: int | None, +) -> bool: + application = maintenance_repo.get_window_group_application(window, group) + source = "existing" + if occurrence_start and (group.first_seen_at or group.created_at) >= occurrence_start: + source = "new" + created_or_reactivated = application is None or not application.active + previous_behavior = application.behavior if application else None + behavior_changed = bool(application and previous_behavior != window.behavior) + previous_state = group.status + + if application is None: + previous_status = group.status + if previous_status == "maintenance": + previous_status = _original_status_for_group(group) + application = MaintenanceWindowAlertApplication.create( + maintenance_window=window, + alert_group=group, + behavior=window.behavior, + application_source=source, + previous_status=previous_status, + occurrence_started_at=occurrence_start, + active=True, + applied_at=now, + ) + else: + if window.behavior == MAINTENANCE_INCIDENT_BEHAVIOR and not application.previous_status: + application.previous_status = _original_status_for_group(group) + application.behavior = window.behavior + application.application_source = source + application.occurrence_started_at = occurrence_start + application.active = True + application.applied_at = now if created_or_reactivated else application.applied_at + application.retained_at = None + application.released_at = None + application.release_reason = None + application.save() + + notify_on_resume = bool( + behavior_changed + and previous_behavior in { + SUPPRESS_NOTIFICATIONS_BEHAVIOR, + MAINTENANCE_INCIDENT_BEHAVIOR, + } + and window.behavior not in { + SUPPRESS_NOTIFICATIONS_BEHAVIOR, + MAINTENANCE_INCIDENT_BEHAVIOR, + } + ) + _recompute_group_effects( + group, + now=now, + fallback_status=application.previous_status, + notify_on_resume=notify_on_resume, + ) + + if created_or_reactivated or behavior_changed: + alerts_repo.create_alert_event( + group_id=group.id, + event_type="maintenance_applied", + message=f"Maintenance applied: {window.name} ({window.behavior})", + ) + _write_application_audit( + "maintenance_window.alert_group_applied", + application, + trigger_source=trigger_source, + actor_user_id=actor_user_id, + previous_state=previous_state, + new_state=AlertGroup.get_by_id(group.id).status, + ) + return True + return False + + +def _release_application( + application: MaintenanceWindowAlertApplication, + *, + now: datetime, + reason: str, + trigger_source: str, + actor_user_id: int | None, +) -> bool: + if not application.active: + return False + group = application.alert_group + previous_state = group.status + fallback_status = application.previous_status + application.active = False + application.retained_at = None + application.released_at = now + application.release_reason = reason + application.save() + if application.behavior == PAUSE_ESCALATION_BEHAVIOR: + group.reminder_count = 0 + group.save(only=[group.__class__.reminder_count]) + _recompute_group_effects( + group, + now=now, + fallback_status=fallback_status, + notify_on_resume=application.behavior in { + SUPPRESS_NOTIFICATIONS_BEHAVIOR, + MAINTENANCE_INCIDENT_BEHAVIOR, + }, + ) + alerts_repo.create_alert_event( + group_id=group.id, + event_type="maintenance_released", + message=f"Maintenance effect released: {application.maintenance_window.name}", + ) + _write_application_audit( + "maintenance_window.alert_group_released", + application, + trigger_source=trigger_source, + actor_user_id=actor_user_id, + previous_state=previous_state, + new_state=AlertGroup.get_by_id(group.id).status, + ) + return True + + +def _retain_application( + application: MaintenanceWindowAlertApplication, + *, + now: datetime, + trigger_source: str, + actor_user_id: int | None, +) -> bool: + if application.retained_at is not None: + return False + application.retained_at = now + application.save(only=[application.__class__.retained_at]) + alerts_repo.create_alert_event( + group_id=application.alert_group_id, + event_type="maintenance_effect_retained", + message=( + "Maintenance ended, but its effect remains active because automatic " + f"reactivation is disabled: {application.maintenance_window.name}" + ), + ) + _write_application_audit( + "maintenance_window.alert_group_retained", + application, + trigger_source=trigger_source, + actor_user_id=actor_user_id, + previous_state=application.alert_group.status, + new_state=application.alert_group.status, + ) + return True + + +def reconcile_maintenance_window( + window: MaintenanceWindow, + *, + now: datetime | None = None, + trigger_source: str = "scheduler", + actor_user_id: int | None = None, + limit: int | None = None, + force_release: bool = False, +) -> dict[str, int]: + """Apply or release one window against unresolved groups idempotently.""" + now = now or utc_now() + active = maintenance_repo.is_window_active_now(window, now=now) + occurrence_start = _occurrence_start_utc(window, now=now) if active else None + applied = released = retained = 0 + + if active and window.behavior != SUPPRESS_INCIDENT_BEHAVIOR: + candidates = maintenance_repo.list_unresolved_alert_groups_for_window(window) + for batch in _iter_batches(candidates, limit): + for group in batch: + if not should_apply_window_to_group(window, group, now=now): + continue + if _apply_window_to_group( + window, + group, + now=now, + occurrence_start=occurrence_start, + trigger_source=trigger_source, + actor_user_id=actor_user_id, + ): + applied += 1 + + applications = maintenance_repo.list_window_applications(window, active_only=True) + for batch in _iter_batches(applications, limit): + for application in batch: + group = application.alert_group + still_applicable = bool( + active + and window.behavior != SUPPRESS_INCIDENT_BEHAVIOR + and group.status != "resolved" + and _group_matches_window_scope(window, group) + and should_apply_window_to_group(window, group, now=now) + ) + if still_applicable: + continue + + configuration_removed_effect = bool( + active + and ( + window.behavior == SUPPRESS_INCIDENT_BEHAVIOR + or not _group_matches_window_scope(window, group) + or not should_apply_window_to_group(window, group, now=now) + ) + ) + release_now = bool( + force_release + or group.status == "resolved" + or configuration_removed_effect + or window.reactivate_on_end + ) + + if release_now: + if _release_application( + application, + now=now, + reason=( + "deleted" + if force_release + else "resolved" + if group.status == "resolved" + else "configuration_changed" + if configuration_removed_effect + else "window_inactive" + ), + trigger_source=trigger_source, + actor_user_id=actor_user_id, + ): + released += 1 + elif _retain_application( + application, + now=now, + trigger_source=trigger_source, + actor_user_id=actor_user_id, + ): + retained += 1 + + window.reconciled_at = now + window.save(only=[window.__class__.reconciled_at]) + return {"applied": applied, "released": released, "retained": retained} + + +def reconcile_alert_group_maintenance( + group: AlertGroup, + *, + now: datetime | None = None, + trigger_source: str = "intake", +) -> dict[str, int]: + """Apply every currently active matching window to one alert group.""" + now = now or utc_now() + windows = maintenance_repo.list_active_maintenance_windows( + group_id=group.team.group_id if group.team_id and group.team else None, + team_id=group.team_id, + service_id=group.service_id, + route_id=group.route_id, + now=now, + ) + applied = 0 + matched_ids = set() + for window in windows: + if not should_apply_window_to_group(window, group, now=now): + continue + matched_ids.add(window.id) + if _apply_window_to_group( + window, + group, + now=now, + occurrence_start=_occurrence_start_utc(window, now=now), + trigger_source=trigger_source, + actor_user_id=None, + ): + applied += 1 + return {"applied": applied, "matched": len(matched_ids)} + + +def process_maintenance_lifecycle( + *, + now: datetime | None = None, + limit: int | None = None, +) -> dict[str, int]: + """Reconcile all windows for scheduled start/end and configuration changes.""" + now = now or utc_now() + result = {"windows": 0, "applied": 0, "released": 0, "retained": 0} + windows = maintenance_repo.list_maintenance_windows( + include_deleted=True, + include_finished=True, + ) + for window in windows: + item = reconcile_maintenance_window( + window, + now=now, + trigger_source="scheduler", + limit=limit, + ) + result["windows"] += 1 + for key in ("applied", "released", "retained"): + result[key] += item[key] + return result + + +def maybe_apply_maintenance_to_group(group: AlertGroup, maintenance_decision) -> None: + if not group: return + reconcile_alert_group_maintenance(group, trigger_source="intake") - window = maintenance_decision.window +def record_maintenance_match( + group: AlertGroup, + maintenance_decision, + *, + alert_id: int | None = None, +) -> None: + if not group or not maintenance_decision or not maintenance_decision.matched: + return + window = maintenance_decision.window alerts_repo.create_alert_event( alert_id=alert_id, group_id=group.id, event_type="maintenance_matched", - message=( - f"Matched maintenance window: {window.name}" - if window - else "Matched maintenance window" - ), + message=f"Matched maintenance window: {window.name}", ) diff --git a/app/services/alerts/notification_queue.py b/app/services/alerts/notification_queue.py index 9459132..92b730b 100644 --- a/app/services/alerts/notification_queue.py +++ b/app/services/alerts/notification_queue.py @@ -4,6 +4,12 @@ from app import Config from app.modules.db import alerts_repo from app.services.notifications.delivery import notify_alert +from app.services.alerts.maintenance_state import ( + is_notification_lifecycle_suppressed, + pause_notification_lifecycle, + resume_notification_lifecycle, +) +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -19,7 +25,13 @@ def _alert_group_interval_seconds(): def schedule_group_notification(group, reason="notification", now=None): """Schedule group notification according to group_wait/group_interval.""" - now = now or datetime.utcnow() + now = now or utc_now() + + if is_notification_lifecycle_suppressed(group, now=now): + pause_notification_lifecycle(group) + return group + + resume_notification_lifecycle(group, now=now) if group.status != "firing": alerts_repo.clear_alert_group_notification(group) @@ -45,7 +57,7 @@ def schedule_group_notification(group, reason="notification", now=None): def process_due_alert_group_notifications(limit=100): """Send due alert group notifications.""" - now = datetime.utcnow() + now = utc_now() sent = 0 skipped = 0 failed = 0 @@ -59,6 +71,13 @@ def process_due_alert_group_notifications(limit=100): try: group = alerts_repo.recalculate_alert_group(group) + if is_notification_lifecycle_suppressed(group, now=now): + pause_notification_lifecycle(group) + skipped += 1 + continue + + resume_notification_lifecycle(group, now=now) + if group.status != "firing": alerts_repo.clear_alert_group_notification(group) skipped += 1 diff --git a/app/services/alerts/priority.py b/app/services/alerts/priority.py index 92ea4e4..9d96171 100644 --- a/app/services/alerts/priority.py +++ b/app/services/alerts/priority.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db import incidents_repo, alerts_repo +from app.modules.common import utc_now PRIORITY_DISPLAY_LABELS = { "p1": "P1 Critical", @@ -147,7 +148,7 @@ def _save_auto_priority(group, priority, *, event_type, message): group.priority_slug = priority.slug group.priority_order = priority.level group.priority_set_manually = False - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save(only=[ group.__class__.priority, diff --git a/app/services/alerts/reminders.py b/app/services/alerts/reminders.py index 2bb8f2c..5d74151 100644 --- a/app/services/alerts/reminders.py +++ b/app/services/alerts/reminders.py @@ -4,7 +4,15 @@ from app.modules.db import alerts_repo from app.services import escalation_policies as escalation_policy_service from app.services.alerts.escalation import maybe_escalate_alert +from app.services.alerts.maintenance_state import ( + is_escalation_lifecycle_paused, + is_notification_lifecycle_suppressed, + pause_notification_lifecycle, + resume_notification_lifecycle, +) +from app.services.alerts.notification_queue import schedule_group_notification from app.services.notifications.delivery import has_matching_notification_channel, notify_alert +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -42,11 +50,28 @@ def send_unacked_reminders(): policy escalation must work even when reminder interval is disabled. """ - now = datetime.utcnow() + now = utc_now() count = 0 for group in alerts_repo.list_firing_alert_groups(): - if group.escalation_policy_id: + if is_notification_lifecycle_suppressed(group, now=now): + pause_notification_lifecycle(group) + logger.debug( + "reminder and escalation skipped during maintenance", + extra={"extra": {"alert_group_id": group.id}}, + ) + continue + + if resume_notification_lifecycle(group, now=now): + schedule_group_notification( + group, + reason="maintenance_ended", + now=now, + ) + continue + escalation_paused = is_escalation_lifecycle_paused(group, now=now) + + if group.escalation_policy_id and not escalation_paused: if maybe_escalate_alert(group): count += 1 continue @@ -124,7 +149,11 @@ def send_unacked_reminders(): if not should_send_reminder(group, now): continue - if not group.escalation_policy_id and maybe_escalate_alert(group): + if ( + not group.escalation_policy_id + and not escalation_paused + and maybe_escalate_alert(group) + ): count += 1 continue diff --git a/app/services/api_token_scopes.py b/app/services/api_token_scopes.py new file mode 100644 index 0000000..03b5faf --- /dev/null +++ b/app/services/api_token_scopes.py @@ -0,0 +1,256 @@ +"""Central API-token scope definitions and request-to-scope mapping. + +Keep this module dependency-free: it is imported by middleware, profile token +creation, integration authentication, tests, and documentation helpers. +""" + +READ_METHODS = frozenset({"GET", "HEAD"}) +WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +PUBLIC_API_PATHS = frozenset({ + "/api/auth/login", + "/api/auth/logout", + "/api/push/actions", +}) + +PUBLIC_API_PREFIXES = ( + "/api/auth/sso", + "/api/integrations", + "/api/heartbeats/ping", + "/api/version", +) + +# Stable, user-facing granular scopes. Keep the order intentional because the +# profile API returns this sequence to the UI. +READ_WRITE_SCOPE_DOMAINS = ( + "alerts", + "incidents", + "services", + "teams", + "groups", + "users", + "rotations", + "calendar", + "routes", + "channels", + "maintenance", + "heartbeats", + "policies", + "orchestrations", + "sso", + "profile", +) +READ_ONLY_SCOPE_DOMAINS = ("audit",) + +GRANULAR_READ_SCOPES = tuple( + f"{domain}:read" + for domain in (*READ_WRITE_SCOPE_DOMAINS, *READ_ONLY_SCOPE_DOMAINS) +) +GRANULAR_WRITE_SCOPES = tuple( + f"{domain}:write" + for domain in READ_WRITE_SCOPE_DOMAINS +) + +LEGACY_AGGREGATE_SCOPES = ("resources:read", "resources:write") + +PROFILE_TOKEN_SCOPE_OPTIONS = ( + *GRANULAR_READ_SCOPES, + *GRANULAR_WRITE_SCOPES, + *LEGACY_AGGREGATE_SCOPES, + "*", +) +ALLOWED_PROFILE_TOKEN_SCOPES = frozenset(PROFILE_TOKEN_SCOPE_OPTIONS) + +# resources:* predates granular entity scopes. Preserve it as an aggregate for +# every non-alert, non-profile resource domain so existing tokens keep working. +# alerts:* and profile:* remain separate exactly as they were before. +LEGACY_RESOURCE_DOMAINS = frozenset({ + "incidents", + "services", + "teams", + "groups", + "users", + "rotations", + "calendar", + "routes", + "channels", + "maintenance", + "heartbeats", + "policies", + "orchestrations", + "audit", + "sso", +}) + +# Ordered longest/specialized prefixes first. Values are scope domains; method +# determines :read vs :write. Related internal resources deliberately share a +# stable public domain (e.g. business services -> services, silences -> +# maintenance, matcher/policy endpoints -> policies). +API_SCOPE_RULES = ( + # OpenAPI specification follows the authenticated documentation boundary. + # Reuse profile:read rather than adding a one-off docs scope. + ("/api/openapi.json", "profile"), + ("/api/admin/audit-logs", "audit"), + ("/api/admin/sso", "sso"), + ("/api/admin/users", "users"), + ("/api/orchestration-webhook-actions", "orchestrations"), + ("/api/event-orchestrations", "orchestrations"), + ("/api/notification-policies", "policies"), + ("/api/priority-policies", "policies"), + ("/api/escalation-policies", "policies"), + ("/api/matcher-presets", "policies"), + ("/api/matchers", "policies"), + ("/api/maintenance-windows", "maintenance"), + ("/api/business-services", "services"), + ("/api/notification-center", "profile"), + ("/api/oncall-health", "rotations"), + ("/api/heartbeats", "heartbeats"), + ("/api/incidents", "incidents"), + ("/api/services", "services"), + ("/api/silences", "maintenance"), + ("/api/rotations", "rotations"), + ("/api/calendar", "calendar"), + ("/api/channels", "channels"), + ("/api/routes", "routes"), + ("/api/teams", "teams"), + ("/api/groups", "groups"), + ("/api/users", "users"), + ("/api/alerts", "alerts"), + ("/api/profile", "profile"), + # /api/auth/me and /api/auth/change-password are JWT-only at the view + # layer. Mapping them to profile still keeps middleware fail-closed and + # produces a meaningful scope error before the JWT-only decorator runs. + ("/api/auth", "profile"), +) + + +def path_matches_prefix(path, prefix): + """Return True when path is exactly prefix or is a child path.""" + return path == prefix or path.startswith(prefix + "/") + + +def is_public_calendar_feed_path(path, method): + """Return True for tokenized public ICS subscription URLs only.""" + method = str(method or "").upper() + + if method not in READ_METHODS: + return False + + prefix = "/api/calendar/feeds/" + if not path.startswith(prefix): + return False + + rest = path[len(prefix):] + return bool(rest) and rest.endswith(".ics") and "/" not in rest + + +def is_public_api_request(path, method): + """Return True when global API auth middleware must not handle request.""" + if path in PUBLIC_API_PATHS: + return True + + if is_public_calendar_feed_path(path, method): + return True + + return any(path_matches_prefix(path, prefix) for prefix in PUBLIC_API_PREFIXES) + + +def required_scopes_for_path(path, method): + """Return required scopes for one protected API request. + + None means the request has no configured API-token scope mapping and must + therefore be denied for API-token principals (fail closed). + """ + method = str(method or "").upper() + action = None + if method in READ_METHODS: + action = "read" + elif method in WRITE_METHODS: + action = "write" + + if action is None: + return None + + for prefix, domain in API_SCOPE_RULES: + if not path_matches_prefix(path, prefix): + continue + + scope = f"{domain}:{action}" + if scope not in ALLOWED_PROFILE_TOKEN_SCOPES: + # Read-only domains (currently audit) intentionally have no write + # scope. A write request therefore remains fail closed. + return None + + return [scope] + + return None + + +def expand_token_scopes(scopes): + """Expand legacy aggregate scopes into their granular effective scopes.""" + raw = set(scopes or []) + effective = set(raw) + + if "*" in raw: + effective.update(ALLOWED_PROFILE_TOKEN_SCOPES) + return effective + + if "resources:read" in raw: + effective.update( + f"{domain}:read" + for domain in LEGACY_RESOURCE_DOMAINS + if f"{domain}:read" in ALLOWED_PROFILE_TOKEN_SCOPES + ) + + if "resources:write" in raw: + effective.update( + f"{domain}:write" + for domain in LEGACY_RESOURCE_DOMAINS + if f"{domain}:write" in ALLOWED_PROFILE_TOKEN_SCOPES + ) + + return effective + + +def token_has_scopes(token_scopes, required_scopes): + """Return True when token scopes satisfy every required scope.""" + if not required_scopes: + return True + + raw = set(token_scopes or []) + if "*" in raw: + # Wildcard keeps its historical meaning for explicitly declared view + # scopes. Unmapped API routes are rejected before this check. + return True + + effective = expand_token_scopes(raw) + return all(scope in effective for scope in required_scopes) + + +def token_can_grant_scopes(current_scopes, requested_scopes): + """Return True when a token may mint another token with requested scopes. + + Granular scopes may be delegated through a matching legacy resources:* + aggregate. Aggregate scopes themselves may only be delegated by the same + aggregate (or wildcard) so a granular token cannot manufacture a broader + future-facing aggregate scope. + """ + current = set(current_scopes or []) + requested = set(requested_scopes or []) + + if "*" in current: + return True + + for scope in requested: + if scope == "*": + return False + + if scope in LEGACY_AGGREGATE_SCOPES: + if scope not in current: + return False + continue + + if not token_has_scopes(current, [scope]): + return False + + return True diff --git a/app/services/business_services/status.py b/app/services/business_services/status.py index 6a72423..accbfcb 100644 --- a/app/services/business_services/status.py +++ b/app/services/business_services/status.py @@ -11,6 +11,7 @@ combined_impact_score, status_from_impact_score, ) +from app.modules.common import utc_now logger = logging.getLogger("oncall.business_services") @@ -50,7 +51,7 @@ def is_business_service_manual_status_active(business_service, now=None): if until is None: return True - now = now or datetime.utcnow() + now = now or utc_now() return until > now @@ -64,7 +65,7 @@ def clear_expired_business_service_manual_status(business_service, now=None): if until is None: return False - now = now or datetime.utcnow() + now = now or utc_now() if until > now: return False @@ -331,7 +332,7 @@ def apply_business_service_status(business_service): or old_message != new_message ) - now = datetime.utcnow() + now = utc_now() business_service.status = new_status business_service.status_source = new_source diff --git a/app/services/caldav/auth.py b/app/services/caldav/auth.py index ce88ba9..3c6146b 100644 --- a/app/services/caldav/auth.py +++ b/app/services/caldav/auth.py @@ -7,6 +7,7 @@ from app.modules.db import tokens_repo from app.modules.db.models import User from app.services.integrations.auth import hash_token, token_has_scope +from app.modules.common import utc_now CALDAV_REQUIRED_SCOPES = ["calendar:read"] @@ -63,7 +64,7 @@ def authenticate_caldav_user(username, token): if not api_token: return None - if api_token.expires_at and api_token.expires_at <= datetime.utcnow(): + if api_token.expires_at and api_token.expires_at <= utc_now(): return None if not api_token.user or api_token.user.id != user.id: diff --git a/app/services/caldav/service.py b/app/services/caldav/service.py index 0e4e53c..86cf3a4 100644 --- a/app/services/caldav/service.py +++ b/app/services/caldav/service.py @@ -1,8 +1,9 @@ -from datetime import datetime, timedelta, timezone +from datetime import timedelta from app.modules.db.models import Team, TeamUser from app.services.calendar_service import build_team_calendar from app.services.caldav.ics import event_uid +from app.modules.common import utc_now DEFAULT_PAST_DAYS = 7 DEFAULT_FUTURE_DAYS = 90 @@ -53,7 +54,7 @@ def get_user_team_or_none(user, team_id): def list_team_caldav_events(team): - now = datetime.now(timezone.utc).replace(tzinfo=None) + now = utc_now() start_at = now - timedelta(days=DEFAULT_PAST_DAYS) end_at = now + timedelta(days=DEFAULT_FUTURE_DAYS) diff --git a/app/services/calendar_feeds.py b/app/services/calendar_feeds.py index c349ebc..187f88d 100644 --- a/app/services/calendar_feeds.py +++ b/app/services/calendar_feeds.py @@ -8,6 +8,7 @@ from app.modules.db import calendar_feeds_repo from app.services.calendar_service import build_team_calendar from app.services.caldav.ics import build_calendar_ics +from app.modules.common import utc_now CALENDAR_FEED_TOKEN_PREFIX_LENGTH = 12 @@ -83,7 +84,7 @@ def serialize_calendar_feed(feed, base_url=None, token=None): def build_ics_for_calendar_feed(feed): - now = datetime.utcnow() + now = utc_now() start_at = now - timedelta(days=int(feed.past_days or 7)) end_at = now + timedelta(days=int(feed.future_days or 90)) diff --git a/app/services/calendar_service.py b/app/services/calendar_service.py index edb71b8..31a23ac 100644 --- a/app/services/calendar_service.py +++ b/app/services/calendar_service.py @@ -1,8 +1,14 @@ -from datetime import datetime, time, timedelta, timezone as dt_timezone +from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo from app.modules.db import rotations_repo -from app.api.schemas.base import as_utc_aware +from app.modules.common import as_utc_aware, as_utc_naive +from app.services.rotation_schedule import ( + effective_layer_value, + layer_slot_index, + layer_start_utc_naive, + next_layer_boundary_utc, +) _OVERRIDE_PRIORITY = 1_000_000 @@ -129,16 +135,7 @@ def build_layer_candidate_events(rotation, layer, start_at, end_at): if not members: return [] - timezone_name = effective_layer_value( - layer, - "timezone", - rotation.timezone, - ) or "UTC" - - layer_start_at = as_rotation_timezone_utc_naive( - effective_layer_value(layer, "start_at", rotation.start_at), - timezone_name, - ) + layer_start_at = layer_start_utc_naive(layer) duration_seconds = int( effective_layer_value(layer, "duration_seconds", rotation.duration_seconds) @@ -160,15 +157,9 @@ def build_layer_candidate_events(rotation, layer, start_at, end_at): layer=layer, members=members, at=cursor, - duration_seconds=duration_seconds, - layer_start_at=layer_start_at, ) - next_boundary = get_next_layer_boundary( - at=cursor, - duration_seconds=duration_seconds, - layer_start_at=layer_start_at, - ) + next_boundary = next_layer_boundary_utc(layer, cursor) next_member_boundary = get_next_layer_member_boundary( members=members, @@ -464,7 +455,7 @@ def merge_windows(windows): return merged -def get_layer_user_at(layer, members, at, duration_seconds, layer_start_at): +def get_layer_user_at(layer, members, at): """Return scheduled user for a layer at a given UTC time.""" effective_members = get_effective_layer_members_at(members, at) @@ -472,76 +463,15 @@ def get_layer_user_at(layer, members, at, duration_seconds, layer_start_at): if not effective_members: return None - elapsed = int((at - layer_start_at).total_seconds()) - - if elapsed < 0: - return effective_members[0].user + slot = layer_slot_index(layer, at) + if slot is None: + return None - slot = elapsed // duration_seconds return effective_members[slot % len(effective_members)].user -def get_next_layer_boundary(at, duration_seconds, layer_start_at): - """Return next rotation slot boundary.""" - - elapsed = int((at - layer_start_at).total_seconds()) - - if elapsed < 0: - return layer_start_at - - next_slot = (elapsed // duration_seconds) + 1 - - return layer_start_at + timedelta(seconds=next_slot * duration_seconds) - - -def effective_layer_value(layer, field_name, default=None): - """Return layer value with fallback to parent rotation.""" - - value = getattr(layer, field_name, None) - - if value not in (None, ""): - return value - - rotation = getattr(layer, "rotation", None) - - if rotation is not None: - rotation_value = getattr(rotation, field_name, None) - - if rotation_value not in (None, ""): - return rotation_value - - return default - - def minutes_from_hhmm(value): """Convert HH:MM to minutes from midnight.""" hour_raw, minute_raw = str(value).split(":", 1) return int(hour_raw) * 60 + int(minute_raw) - - -def as_utc_naive(value): - """Return UTC datetime without tzinfo for DB-compatible comparisons.""" - - return as_utc_aware(value).replace(tzinfo=None) - - -def as_rotation_timezone_utc_naive(value, timezone_name): - """ - Convert rotation/layer local datetime to UTC naive. - - If value is naive, treat it as local time in rotation/layer timezone. - If value is aware, convert it to UTC. - """ - if value is None: - return None - - try: - zone = ZoneInfo(timezone_name or "UTC") - except Exception: - zone = ZoneInfo("UTC") - - if value.tzinfo is None: - return value.replace(tzinfo=zone).astimezone(dt_timezone.utc).replace(tzinfo=None) - - return value.astimezone(dt_timezone.utc).replace(tzinfo=None) diff --git a/app/services/escalation_policies.py b/app/services/escalation_policies.py index 0557cc0..f316bde 100644 --- a/app/services/escalation_policies.py +++ b/app/services/escalation_policies.py @@ -28,18 +28,19 @@ def list_enabled_rules(policy): def get_first_enabled_rule(policy): - """Return first enabled rule for a policy.""" - rules = list_enabled_rules(policy) + """Return the first enabled rule for an enabled policy.""" + if not policy or not policy.enabled: + return None - return rules[0] if rules else None + return escalation_policies_repo.get_first_rule(policy.id) def get_rule_delay_seconds(rule): - """Return normalized rule delay in seconds.""" + """Return a safe non-negative delay for a rule.""" if not rule: return 0 - return max(0, int(rule.delay_seconds or 0)) + return max(int(rule.delay_seconds or 0), 0) def get_next_escalation_at(rule, now): @@ -51,15 +52,15 @@ def get_next_escalation_at(rule, now): def resolve_rule_user(rule): - """Resolve policy rule target to a user.""" - if not rule: + """Resolve the active user that should receive the rule notification.""" + if not rule or not rule.enabled: return None if rule.target_type == "user": - return rule.target_user + return rule.target_user if rule.target_user and rule.target_user.active else None - if rule.target_type == "rotation" and rule.target_rotation: - return get_current_oncall_user(rule.target_rotation) + if rule.target_type == "rotation": + return get_current_oncall_user(rule.target_rotation) if rule.target_rotation else None return None @@ -155,36 +156,6 @@ def serialize_rule(rule): } -def get_first_enabled_rule(policy): - """Return the first enabled rule for a policy object.""" - if not policy or not policy.enabled: - return None - - return escalation_policies_repo.get_first_rule(policy.id) - - -def resolve_rule_user(rule): - """Resolve the user that should receive the rule notification.""" - if not rule or not rule.enabled: - return None - - if rule.target_type == "user": - return rule.target_user if rule.target_user and rule.target_user.active else None - - if rule.target_type == "rotation": - return get_current_oncall_user(rule.target_rotation) if rule.target_rotation else None - - return None - - -def get_rule_delay_seconds(rule): - """Return a safe delay for the rule.""" - if not rule: - return 0 - - return max(int(rule.delay_seconds or 0), 0) - - def get_policy_reminder_interval(alert): """Return reminder interval for a policy-driven alert.""" if not alert.escalation_policy: @@ -195,25 +166,3 @@ def get_policy_reminder_interval(alert): first_rule = get_first_enabled_rule(alert.escalation_policy) return max(get_rule_delay_seconds(first_rule), 60) if first_rule else 0 - - -def _next_rule_for_alert(alert): - policy = alert.escalation_policy - - if not policy or not policy.enabled: - return None, False - - current_rule = alert.escalation_rule - - if not current_rule: - return get_first_enabled_rule(policy), False - - next_rule = escalation_policies_repo.get_next_rule(policy.id, current_rule.position) - - if next_rule: - return next_rule, False - - if alert.escalation_repeat_count < policy.repeat_count: - return get_first_enabled_rule(policy), True - - return None, False diff --git a/app/services/heartbeats/service.py b/app/services/heartbeats/service.py index 5bab01c..94a8351 100644 --- a/app/services/heartbeats/service.py +++ b/app/services/heartbeats/service.py @@ -1,14 +1,16 @@ import calendar import logging -from datetime import datetime, time, timedelta, timezone +from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo -from app.modules.common import as_utc_aware +from app.modules.common import as_utc_aware, as_utc_naive_seconds, utc_now_seconds from app.modules.db import alerts_repo, heartbeats_repo from app.modules.db.models import AlertGroup, Heartbeat, HeartbeatInstance from app.services.alerts.actions import resolve_alert from app.services.alerts.lifecycle import upsert_alert from app.services.integrations.auth import create_raw_token, hash_token +from app.services.notifications.delivery import notify_alert +from app.services.serializers.common import serialize_utc_datetime logger = logging.getLogger("oncall.heartbeats") @@ -22,19 +24,6 @@ DEFAULT_GRACE_SECONDS = 300 -def _utcnow(): - return datetime.utcnow().replace(microsecond=0) - - -def _as_naive_utc(value): - aware = as_utc_aware(value) - - if aware is None: - return None - - return aware.replace(tzinfo=None, microsecond=0) - - def _zone(name): try: return ZoneInfo(name or "UTC") @@ -51,7 +40,7 @@ def _parse_schedule_time(value): def _local_to_naive_utc(local_dt): - return local_dt.astimezone(timezone.utc).replace(tzinfo=None, microsecond=0) + return as_utc_naive_seconds(local_dt) def _monthly_date(year, month, day): @@ -131,34 +120,20 @@ def _scheduled_next_due_local(heartbeat, latest_due_local, now_local): return candidate -def _scheduled_window_start_local(heartbeat, due_local): - kind = heartbeat.schedule_kind or "daily" - - if kind == "weekly": - return due_local - timedelta(days=7) - - if kind == "monthly": - year, month = _previous_month(due_local.year, due_local.month) - day = _monthly_date(year, month, heartbeat.schedule_monthday or 1) - return due_local.replace(year=year, month=month, day=day) - - return due_local - timedelta(days=1) - - def compute_next_expected_at(heartbeat, now=None): """Return the next expected ping deadline before grace is applied.""" - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) if heartbeat.mode == "scheduled": zone = _zone(heartbeat.timezone) - now_local = now.replace(tzinfo=timezone.utc).astimezone(zone) + now_local = as_utc_aware(now).astimezone(zone) latest_due = _scheduled_due_local(heartbeat, now_local) next_due = _scheduled_next_due_local(heartbeat, latest_due, now_local) return _local_to_naive_utc(next_due) interval = int(heartbeat.expected_interval_seconds or DEFAULT_INTERVAL_SECONDS) anchor = heartbeat.last_seen_at or heartbeat.created_at or now - return _as_naive_utc(anchor) + timedelta(seconds=interval) + return as_utc_naive_seconds(anchor) + timedelta(seconds=interval) def heartbeat_deadline_at(heartbeat, expected_at=None): @@ -168,7 +143,7 @@ def heartbeat_deadline_at(heartbeat, expected_at=None): def heartbeat_is_overdue(heartbeat, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) if heartbeat_tracks_instances(heartbeat): return False @@ -178,7 +153,7 @@ def heartbeat_is_overdue(heartbeat, now=None): if heartbeat.mode == "scheduled": zone = _zone(heartbeat.timezone) - now_local = now.replace(tzinfo=timezone.utc).astimezone(zone) + now_local = as_utc_aware(now).astimezone(zone) due_local = _scheduled_due_local(heartbeat, now_local) deadline = _local_to_naive_utc(due_local) + timedelta(seconds=int(heartbeat.grace_period_seconds or 0)) @@ -186,7 +161,7 @@ def heartbeat_is_overdue(heartbeat, now=None): return False due_at = _local_to_naive_utc(due_local) - last_seen_at = _as_naive_utc(heartbeat.last_seen_at) + last_seen_at = as_utc_naive_seconds(heartbeat.last_seen_at) return not last_seen_at or last_seen_at < due_at @@ -265,14 +240,14 @@ def extract_heartbeat_instance_key(heartbeat, payload): def compute_instance_next_expected_at(heartbeat, instance, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) if heartbeat.mode == "scheduled": return compute_next_expected_at(heartbeat, now=now) interval = int(heartbeat.expected_interval_seconds or DEFAULT_INTERVAL_SECONDS) anchor = instance.last_seen_at or instance.created_at or now - return _as_naive_utc(anchor) + timedelta(seconds=interval) + return as_utc_naive_seconds(anchor) + timedelta(seconds=interval) def heartbeat_instance_deadline_at(heartbeat, instance, expected_at=None): @@ -282,7 +257,7 @@ def heartbeat_instance_deadline_at(heartbeat, instance, expected_at=None): def heartbeat_instance_is_overdue(heartbeat, instance, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) if heartbeat.status == "paused" or not heartbeat.enabled or heartbeat.deleted: return False @@ -291,7 +266,7 @@ def heartbeat_instance_is_overdue(heartbeat, instance, now=None): if heartbeat.mode == "scheduled": zone = _zone(heartbeat.timezone) - now_local = now.replace(tzinfo=timezone.utc).astimezone(zone) + now_local = as_utc_aware(now).astimezone(zone) due_local = _scheduled_due_local(heartbeat, now_local) due_at = _local_to_naive_utc(due_local) deadline = due_at + timedelta(seconds=int(heartbeat.grace_period_seconds or 0)) @@ -299,7 +274,7 @@ def heartbeat_instance_is_overdue(heartbeat, instance, now=None): if now < deadline: return False - last_seen_at = _as_naive_utc(instance.last_seen_at) + last_seen_at = as_utc_naive_seconds(instance.last_seen_at) return not last_seen_at or last_seen_at < due_at @@ -308,7 +283,7 @@ def heartbeat_instance_is_overdue(heartbeat, instance, now=None): def refresh_heartbeat_instance_rollup(heartbeat, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) instances = heartbeats_repo.list_heartbeat_instances(heartbeat.id, enabled_only=True) if not heartbeat_tracks_instances(heartbeat): @@ -335,7 +310,7 @@ def refresh_heartbeat_instance_rollup(heartbeat, now=None): def sync_heartbeat_static_instances(heartbeat, expected_instances, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) desired = [] seen = set() @@ -391,9 +366,9 @@ def heartbeat_expected_instances_from_metadata(heartbeat): def _heartbeat_alert_payload(heartbeat, now): - last_seen = heartbeat.last_seen_at.isoformat() + "Z" if heartbeat.last_seen_at else None - next_expected = heartbeat.next_expected_at.isoformat() + "Z" if heartbeat.next_expected_at else None - overdue_since = heartbeat.overdue_since.isoformat() + "Z" if heartbeat.overdue_since else now.isoformat() + "Z" + last_seen = serialize_utc_datetime(heartbeat.last_seen_at) + next_expected = serialize_utc_datetime(heartbeat.next_expected_at) + overdue_since = serialize_utc_datetime(heartbeat.overdue_since or now) labels = dict(heartbeat.labels or {}) labels.update({ @@ -451,35 +426,39 @@ def _heartbeat_alert_payload(heartbeat, now): def _heartbeat_overdue_message(heartbeat, now): - last_seen = heartbeat.last_seen_at.isoformat() + "Z" if heartbeat.last_seen_at else "never" - expected = heartbeat.next_expected_at.isoformat() + "Z" if heartbeat.next_expected_at else "unknown" - deadline = heartbeat_deadline_at(heartbeat).isoformat() + "Z" if heartbeat.next_expected_at else "unknown" + last_seen = serialize_utc_datetime(heartbeat.last_seen_at) or "never" + expected = serialize_utc_datetime(heartbeat.next_expected_at) or "unknown" + deadline = ( + serialize_utc_datetime(heartbeat_deadline_at(heartbeat)) + if heartbeat.next_expected_at + else "unknown" + ) return ( f"Expected heartbeat ping did not arrive. " f"Last seen: {last_seen}. Expected at: {expected}. " - f"Deadline with grace: {deadline}. Detected at: {now.isoformat()}Z." + f"Deadline with grace: {deadline}. Detected at: {serialize_utc_datetime(now)}." ) def _heartbeat_instance_overdue_message(heartbeat, instance, now): - last_seen = instance.last_seen_at.isoformat() + "Z" if instance.last_seen_at else "never" - expected = instance.next_expected_at.isoformat() + "Z" if instance.next_expected_at else "unknown" + last_seen = serialize_utc_datetime(instance.last_seen_at) or "never" + expected = serialize_utc_datetime(instance.next_expected_at) or "unknown" deadline = ( - heartbeat_instance_deadline_at(heartbeat, instance).isoformat() + "Z" + serialize_utc_datetime(heartbeat_instance_deadline_at(heartbeat, instance)) if instance.next_expected_at else "unknown" ) return ( f"Expected heartbeat ping did not arrive for instance {instance.instance_key}. " f"Last seen: {last_seen}. Expected at: {expected}. " - f"Deadline with grace: {deadline}. Detected at: {now.isoformat()}Z." + f"Deadline with grace: {deadline}. Detected at: {serialize_utc_datetime(now)}." ) def _heartbeat_instance_alert_payload(heartbeat, instance, now): - last_seen = instance.last_seen_at.isoformat() + "Z" if instance.last_seen_at else None - next_expected = instance.next_expected_at.isoformat() + "Z" if instance.next_expected_at else None - overdue_since = instance.overdue_since.isoformat() + "Z" if instance.overdue_since else now.isoformat() + "Z" + last_seen = serialize_utc_datetime(instance.last_seen_at) + next_expected = serialize_utc_datetime(instance.next_expected_at) + overdue_since = serialize_utc_datetime(instance.overdue_since or now) labels = dict(heartbeat.labels or {}) labels.update({ @@ -546,17 +525,19 @@ def _heartbeat_instance_alert_payload(heartbeat, instance, now): def mark_heartbeat_overdue(heartbeat, now=None): """Create or keep the current overdue alert for a heartbeat.""" - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) status_before = heartbeat.status + transitioned_to_overdue = status_before != "overdue" - if status_before == "overdue" and heartbeat.current_alert_group_id: + if not transitioned_to_overdue and heartbeat.current_alert_group_id: group = AlertGroup.get_or_none(AlertGroup.id == heartbeat.current_alert_group_id) if group and group.status != "resolved": return heartbeat, None heartbeat.status = "overdue" heartbeat.overdue_since = heartbeat.overdue_since or now - heartbeat.last_overdue_at = now + if transitioned_to_overdue: + heartbeat.last_overdue_at = now heartbeat.updated_at = now heartbeat.next_expected_at = compute_next_expected_at(heartbeat, now=now) heartbeat.save() @@ -573,41 +554,43 @@ def mark_heartbeat_overdue(heartbeat, now=None): message=f"Heartbeat overdue: {heartbeat.name}", ) - heartbeats_repo.record_ping( - heartbeat, - event_type="overdue", - status_before=status_before, - status_after=heartbeat.status, - message="Heartbeat became overdue", - alert_group_id=group.id if group else None, - received_at=now, - ) + if transitioned_to_overdue: + heartbeats_repo.record_ping( + heartbeat, + event_type="overdue", + status_before=status_before, + status_after=heartbeat.status, + message="Heartbeat became overdue", + alert_group_id=group.id if group else None, + received_at=now, + ) - logger.warning( - "heartbeat became overdue", - extra={ - "extra": { - "event_type": "heartbeat_overdue", - "heartbeat_id": heartbeat.id, - "heartbeat_uid": str(heartbeat.uid), - "team_id": heartbeat.team_id, - "route_id": heartbeat.route_id, - "service_id": heartbeat.service_id, - "alert_group_id": group.id if group else None, - } - }, - ) + logger.warning( + "heartbeat became overdue", + extra={ + "extra": { + "event_type": "heartbeat_overdue", + "heartbeat_id": heartbeat.id, + "heartbeat_uid": str(heartbeat.uid), + "team_id": heartbeat.team_id, + "route_id": heartbeat.route_id, + "service_id": heartbeat.service_id, + "alert_group_id": group.id if group else None, + } + }, + ) return heartbeat, group def mark_heartbeat_instance_overdue(instance, now=None): """Create or keep the current overdue alert for one heartbeat producer.""" - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) heartbeat = instance.heartbeat status_before = instance.status + transitioned_to_overdue = status_before != "overdue" - if status_before == "overdue" and instance.current_alert_group_id: + if not transitioned_to_overdue and instance.current_alert_group_id: group = AlertGroup.get_or_none(AlertGroup.id == instance.current_alert_group_id) if group and group.status != "resolved": refresh_heartbeat_instance_rollup(heartbeat, now=now) @@ -615,7 +598,8 @@ def mark_heartbeat_instance_overdue(instance, now=None): instance.status = "overdue" instance.overdue_since = instance.overdue_since or now - instance.last_overdue_at = now + if transitioned_to_overdue: + instance.last_overdue_at = now instance.next_expected_at = compute_instance_next_expected_at(heartbeat, instance, now=now) instance.updated_at = now instance.save() @@ -632,39 +616,75 @@ def mark_heartbeat_instance_overdue(instance, now=None): message=f"Heartbeat overdue: {heartbeat.name} / {instance.instance_key}", ) - heartbeats_repo.record_ping( - heartbeat, - event_type="instance_overdue", - instance_key=instance.instance_key, - status_before=status_before, - status_after=instance.status, - message=f"Heartbeat instance became overdue: {instance.instance_key}", - alert_group_id=group.id if group else None, - received_at=now, - ) + if transitioned_to_overdue: + heartbeats_repo.record_ping( + heartbeat, + event_type="instance_overdue", + instance_key=instance.instance_key, + status_before=status_before, + status_after=instance.status, + message=f"Heartbeat instance became overdue: {instance.instance_key}", + alert_group_id=group.id if group else None, + received_at=now, + ) refresh_heartbeat_instance_rollup(heartbeat, now=now) - logger.warning( - "heartbeat instance became overdue", - extra={ - "extra": { - "event_type": "heartbeat_instance_overdue", - "heartbeat_id": heartbeat.id, - "heartbeat_uid": str(heartbeat.uid), - "heartbeat_instance": instance.instance_key, - "team_id": heartbeat.team_id, - "route_id": heartbeat.route_id, - "service_id": heartbeat.service_id, - "alert_group_id": group.id if group else None, - } - }, - ) + if transitioned_to_overdue: + logger.warning( + "heartbeat instance became overdue", + extra={ + "extra": { + "event_type": "heartbeat_instance_overdue", + "heartbeat_id": heartbeat.id, + "heartbeat_uid": str(heartbeat.uid), + "heartbeat_instance": instance.instance_key, + "team_id": heartbeat.team_id, + "route_id": heartbeat.route_id, + "service_id": heartbeat.service_id, + "alert_group_id": group.id if group else None, + } + }, + ) return instance, group -def _resolve_current_instance_overdue_alert(instance, now): +def _resolve_heartbeat_alert( + group: AlertGroup, + *, + recovery_event_type: str, + recovery_message: str, +) -> AlertGroup: + """Resolve a heartbeat alert and complete the resolved delivery flow.""" + had_notification = bool(group.last_notification_at) + + resolved = resolve_alert( + group.id, + user_id=None, + update_messages=False, + ) + + alerts_repo.clear_alert_group_notification(resolved) + + alerts_repo.create_alert_event( + group_id=resolved.id, + event_type=recovery_event_type, + message=recovery_message, + ) + + if had_notification: + notify_alert( + resolved, + event_type="resolved", + ) + + return resolved + + +def _resolve_current_instance_overdue_alert( + instance: HeartbeatInstance, +) -> AlertGroup | None: if not instance.current_alert_group_id: return None @@ -673,16 +693,19 @@ def _resolve_current_instance_overdue_alert(instance, now): if not group or group.status == "resolved": return group - resolved = resolve_alert(group.id, user_id=None) - alerts_repo.create_alert_event( - group_id=resolved.id, - event_type="heartbeat_instance_recovered", - message=f"Heartbeat recovered: {instance.heartbeat.name} / {instance.instance_key}", + return _resolve_heartbeat_alert( + group, + recovery_event_type="heartbeat_instance_recovered", + recovery_message=( + f"Heartbeat recovered: " + f"{instance.heartbeat.name} / {instance.instance_key}" + ), ) - return resolved -def _resolve_current_overdue_alert(heartbeat, now): +def _resolve_current_overdue_alert( + heartbeat: Heartbeat, +) -> AlertGroup | None: if not heartbeat.current_alert_group_id: return None @@ -691,13 +714,11 @@ def _resolve_current_overdue_alert(heartbeat, now): if not group or group.status == "resolved": return group - resolved = resolve_alert(group.id, user_id=None) - alerts_repo.create_alert_event( - group_id=resolved.id, - event_type="heartbeat_recovered", - message=f"Heartbeat recovered: {heartbeat.name}", + return _resolve_heartbeat_alert( + group, + recovery_event_type="heartbeat_recovered", + recovery_message=f"Heartbeat recovered: {heartbeat.name}", ) - return resolved def receive_heartbeat_ping( @@ -709,7 +730,7 @@ def receive_heartbeat_ping( now=None, ): """Record a heartbeat ping and auto-resolve overdue incidents if needed.""" - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) heartbeat = heartbeats_repo.get_heartbeat_by_token(raw_token) if not heartbeat: @@ -796,7 +817,7 @@ def receive_heartbeat_ping( if instance.status == "overdue": if heartbeat.auto_resolve: - recovered_group = _resolve_current_instance_overdue_alert(instance, now) + recovered_group = _resolve_current_instance_overdue_alert(instance) instance.status = "ok" instance.overdue_since = None instance.last_recovered_at = now @@ -854,7 +875,7 @@ def receive_heartbeat_ping( if heartbeat.status == "overdue": if heartbeat.auto_resolve: - recovered_group = _resolve_current_overdue_alert(heartbeat, now) + recovered_group = _resolve_current_overdue_alert(heartbeat) heartbeat.status = "ok" heartbeat.overdue_since = None heartbeat.last_recovered_at = now @@ -899,7 +920,7 @@ def receive_heartbeat_ping( def process_overdue_heartbeats(now=None, limit=100, team_ids=None): """Check due heartbeat candidates and open overdue alerts.""" - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) result = { "processed": 0, "overdue": 0, @@ -973,7 +994,7 @@ def process_overdue_heartbeats(now=None, limit=100, team_ids=None): def expire_auto_discovered_instances(now=None, team_ids=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) candidates = heartbeats_repo.list_heartbeats(team_ids=team_ids, enabled_only=True) expired = 0 @@ -1014,7 +1035,7 @@ def expire_auto_discovered_instances(now=None, team_ids=None): return expired def pause_heartbeat(heartbeat, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) before = heartbeat.status heartbeat.status = "paused" heartbeat.updated_at = now @@ -1038,7 +1059,7 @@ def pause_heartbeat(heartbeat, now=None): def resume_heartbeat(heartbeat, now=None): - now = _as_naive_utc(now or _utcnow()) + now = as_utc_naive_seconds(now or utc_now_seconds()) before = heartbeat.status if heartbeat_tracks_instances(heartbeat): diff --git a/app/services/incidents/manual.py b/app/services/incidents/manual.py index 9f3f4ae..4d6f47a 100644 --- a/app/services/incidents/manual.py +++ b/app/services/incidents/manual.py @@ -9,6 +9,7 @@ from app.services.alerts.priority import incident_priority_create_kwargs, incident_priority_from_alert from app.services.incidents.stakeholders import notify_stakeholders from app.services.routing.service_resolution import get_effective_escalation_policy, get_effective_route_rotation +from app.modules.common import utc_now def _get_manual_incident_team(team_id): @@ -68,7 +69,7 @@ def create_manual_incident(payload, *, user_id=None): manual_id = uuid4().hex group_key = f"manual:{manual_id}" - now = datetime.utcnow() + now = utc_now() alert_data = { "source": "manual", diff --git a/app/services/incidents/priority_policies/resolver.py b/app/services/incidents/priority_policies/resolver.py index ccaf270..85c358e 100644 --- a/app/services/incidents/priority_policies/resolver.py +++ b/app/services/incidents/priority_policies/resolver.py @@ -126,40 +126,53 @@ def resolve_incident_priority( team_id = team.id if team else None explicit_source_value = _source_priority_value(alert_data) - if alert_data.get("priority_set_manually") and explicit_source_value: + explicit_priority_requested = ( + alert_data.get("priority_set_manually") + or alert_data.get("priority_set_by_orchestration") + ) + if explicit_priority_requested and explicit_source_value: explicit_priority = _priority_from_source_value(explicit_source_value) if explicit_priority: return PriorityResolution( priority=explicit_priority, - source="explicit_source_priority", + source=( + "orchestration" + if alert_data.get("priority_set_by_orchestration") + else "explicit_source_priority" + ), source_priority_value=explicit_source_value, ) - policy = policy_service.get_effective_policy( - team_id=team_id, - service=service, - ) + orchestration_policy_id = alert_data.get("orchestration_priority_policy_id") + if orchestration_policy_id: + policy = priority_policies_repo.get_priority_policy_or_none( + orchestration_policy_id + ) + if policy and (not policy.enabled or policy.team_id != team_id): + policy = None + else: + policy = policy_service.get_effective_policy( + team_id=team_id, + service=service, + ) if not policy: - update_mode = ( - getattr(policy, "update_mode", None) - or "raise_only" - ) return PriorityResolution( priority=_severity_fallback(alert_data), source="severity_mapping", - update_mode=update_mode, + update_mode="raise_only", ) - policy_source = "team_default" + policy_source = "orchestration" if orchestration_policy_id else "team_default" - if service and getattr(service, "priority_policy_id", None) == policy.id: + if ( + not orchestration_policy_id + and service + and getattr(service, "priority_policy_id", None) == policy.id + ): policy_source = "service" - update_mode = ( - getattr(policy, "update_mode", None) - or "raise_only" - ) + update_mode = getattr(policy, "update_mode", None) or "raise_only" result = PriorityResolution( priority=None, diff --git a/app/services/incidents/priority_policies/service.py b/app/services/incidents/priority_policies/service.py index e8bcaf7..088aa52 100644 --- a/app/services/incidents/priority_policies/service.py +++ b/app/services/incidents/priority_policies/service.py @@ -5,6 +5,7 @@ from app.services.incidents.priority_policies.constants import FALLBACK_FIXED_PRIORITY, FALLBACK_SEVERITY_MAPPING from app.services.routing.matcher import service as matcher_preset_service from app.services.serializers.common import attach_team_permissions +from app.services.payloads import payload_to_dict class PriorityPolicyError(ValueError): @@ -23,13 +24,6 @@ class PriorityPolicyInUseError(PriorityPolicyConflictError): """Priority policy is still assigned to a service.""" -def _payload_dict(payload): - if hasattr(payload, "model_dump"): - return payload.model_dump(exclude_unset=True) - - return dict(payload or {}) - - def _clean_name(value): name = str(value or "").strip() @@ -116,7 +110,7 @@ def _resolve_fallback_priority(fallback_mode, fallback_priority_id): def create_policy(payload): """Create or restore a priority policy.""" - data = _payload_dict(payload) + data = payload_to_dict(payload) team = _require_team(data.get("team_id")) name = _clean_name(data.get("name")) @@ -182,7 +176,7 @@ def create_policy(payload): def update_policy(policy_id, payload): """Update a priority policy.""" policy = get_policy(policy_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) if "name" in data: name = _clean_name(data["name"]) @@ -306,7 +300,7 @@ def get_effective_policy(*, team_id, service=None): def create_rule(policy_id, payload): """Create one ordered priority policy rule.""" policy = get_policy(policy_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) priority = _require_priority(data.get("priority_id")) preset = _validate_matcher_preset_assignment(data.get("matcher_preset_id"), policy.team_id) @@ -344,7 +338,7 @@ def create_rule(policy_id, payload): def update_rule(rule_id, payload): """Update a priority policy rule.""" rule = get_rule(rule_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) if "matcher_preset_id" in data: preset = _validate_matcher_preset_assignment(data.pop("matcher_preset_id"), rule.policy.team_id) diff --git a/app/services/integrations/auth.py b/app/services/integrations/auth.py index 42712a6..6b69d3c 100644 --- a/app/services/integrations/auth.py +++ b/app/services/integrations/auth.py @@ -8,6 +8,8 @@ from app.middleware import load_jwt_user from app.modules.db import routes_repo, tokens_repo from app.settings import Config +from app.modules.common import utc_now +from app.services.api_token_scopes import token_has_scopes def create_raw_token(): @@ -73,7 +75,7 @@ def authenticate_api_token(raw_token=None): request.current_api_token = None return None - if api_token.expires_at and api_token.expires_at <= datetime.utcnow(): + if api_token.expires_at and api_token.expires_at <= utc_now(): request.current_api_token = None return None @@ -133,19 +135,8 @@ def authenticate_request(): def token_has_scope(api_token, required_scopes): - """ - Return True when an API token has all required scopes. - """ - - if not required_scopes: - return True - - token_scopes = api_token.scopes or [] - - if "*" in token_scopes: - return True - - return all(scope in token_scopes for scope in required_scopes) + """Return True when an API token has all required effective scopes.""" + return token_has_scopes(api_token.scopes or [], required_scopes) def require_api_token(scopes=None, required=None): diff --git a/app/services/integrations/normalizers/aws_sns.py b/app/services/integrations/normalizers/aws_sns.py index 7b7fedf..0caa470 100644 --- a/app/services/integrations/normalizers/aws_sns.py +++ b/app/services/integrations/normalizers/aws_sns.py @@ -2,6 +2,7 @@ import re from app.services.integrations.normalizers.common import ( + clean_string, first_non_empty, make_dedup_key, ) @@ -17,15 +18,6 @@ } -def _clean(value): - if value is None: - return None - - value = str(value).strip() - - return value or None - - def _label_key(value): value = str(value or "").strip().lower() value = re.sub(r"[^a-z0-9_]+", "_", value) @@ -34,7 +26,7 @@ def _label_key(value): def _set_label(labels, key, value): key = _label_key(key) - value = _clean(value) + value = clean_string(value) if key and value is not None: labels.setdefault(key, value) @@ -62,7 +54,7 @@ def _message_attribute_value( item.get("StringValue"), ) - return _clean(item) + return clean_string(item) def _sns_labels(envelope): @@ -157,14 +149,14 @@ def _normalize_cloudwatch_alarm( "CloudWatch alarm", ) - alarm_arn = _clean( + alarm_arn = clean_string( message.get("AlarmArn") ) - account_id = _clean( + account_id = clean_string( message.get("AWSAccountId") ) - region = _clean( + region = clean_string( message.get("Region") ) @@ -389,7 +381,7 @@ def _normalize_generic_sns( sort_keys=True, ) - external_id = _clean( + external_id = clean_string( envelope.get("MessageId") ) diff --git a/app/services/integrations/normalizers/common.py b/app/services/integrations/normalizers/common.py index dec0417..fcb8fa8 100644 --- a/app/services/integrations/normalizers/common.py +++ b/app/services/integrations/normalizers/common.py @@ -1,17 +1,29 @@ import hashlib +import json +import re -def normalize_event_link(value): - """Return a clean event/source link value.""" +PRIORITY_SEVERITY = { + "p1": "critical", + "p2": "high", + "p3": "medium", + "p4": "warning", + "p5": "info", +} + + +def clean_string(value): + """Return a stripped string or ``None`` for an empty value.""" if value is None: return None value = str(value).strip() + return value or None - if not value: - return None - return value +def normalize_event_link(value): + """Return a clean event/source link value.""" + return clean_string(value) def first_event_link(*values): @@ -64,3 +76,60 @@ def first_non_empty(*values): return value return None + + +def first_present(*values): + """Return the first present value while preserving ``0`` and ``False``.""" + for value in values: + if value is None: + continue + + if isinstance(value, str) and not value.strip(): + continue + + return value + + return None + + +def canonical_label_key(value): + """Convert an arbitrary label key to lower snake_case.""" + return re.sub( + r"[^a-z0-9]+", + "_", + str(value or "").strip().lower(), + ).strip("_") + + +def normalize_label_value(value): + """Keep scalar label values and serialize complex values deterministically.""" + if value is None: + return None + + if isinstance(value, (str, int, float, bool)): + return value + + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + except (TypeError, ValueError): + return str(value) + + +def stable_labels(labels, *, exclude=()): + """Return deterministically ordered labels without volatile keys.""" + excluded = {str(key) for key in exclude} + + return { + str(key): labels[key] + for key in sorted(labels, key=lambda item: str(item)) + if str(key) not in excluded + } + + +def severity_from_priority(value): + """Map a P1-P5 priority to IncidentRelay severity, or return ``None``.""" + priority = clean_string(value) + if not priority: + return None + + return PRIORITY_SEVERITY.get(priority.lower()) diff --git a/app/services/integrations/normalizers/datadog.py b/app/services/integrations/normalizers/datadog.py index 92d9529..924ce38 100644 --- a/app/services/integrations/normalizers/datadog.py +++ b/app/services/integrations/normalizers/datadog.py @@ -1,11 +1,13 @@ -import json -import re - from app.services.integrations.normalizers.common import ( add_event_link_label, + canonical_label_key, + clean_string, first_event_link, first_non_empty, make_dedup_key, + normalize_label_value, + severity_from_priority, + stable_labels, ) from app.services.severity import normalize_severity @@ -20,24 +22,12 @@ "success", } -PRIORITY_SEVERITY = { - "p1": "critical", - "p2": "high", - "p3": "medium", - "p4": "warning", - "p5": "info", -} - - -def _canonical_key(value): - return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_") - def _canonical_payload(payload): result = {} for key, value in (payload or {}).items(): - normalized = _canonical_key(key) + normalized = canonical_label_key(key) if not normalized: continue @@ -49,30 +39,9 @@ def _canonical_payload(payload): return result -def _clean(value): - if value is None: - return None - - value = str(value).strip() - return value or None - - -def _label_value(value): - if value is None: - return None - - if isinstance(value, (str, int, float, bool)): - return value - - try: - return json.dumps(value, ensure_ascii=False, sort_keys=True) - except (TypeError, ValueError): - return str(value) - - def _get(data, *names): for name in names: - value = data.get(_canonical_key(name)) + value = data.get(canonical_label_key(name)) if value is not None: return value @@ -82,7 +51,7 @@ def _get(data, *names): def normalize_datadog_status(transition, alert_type=None): """Convert Datadog transitions to IncidentRelay firing/resolved state.""" - transition = _clean(transition) + transition = clean_string(transition) normalized = str(transition or "").lower().replace("_", " ").replace("-", " ") normalized = " ".join(normalized.split()) @@ -99,13 +68,13 @@ def normalize_datadog_status(transition, alert_type=None): def normalize_datadog_severity(explicit, alert_type=None, priority=None): """Map Datadog severity/type/monitor priority to IncidentRelay severity.""" - explicit = _clean(explicit) + explicit = clean_string(explicit) if explicit: return normalize_severity(explicit) or "info" - priority_key = str(priority or "").strip().lower() - if priority_key in PRIORITY_SEVERITY: - return PRIORITY_SEVERITY[priority_key] + priority_severity = severity_from_priority(priority) + if priority_severity: + return priority_severity return normalize_severity(alert_type) or "info" @@ -120,8 +89,8 @@ def normalize_datadog_tags(value): if isinstance(value, dict): for key, item in value.items(): - key = _clean(key) - item = _label_value(item) + key = clean_string(key) + item = normalize_label_value(item) if key and item is not None: labels[key] = item return labels @@ -136,7 +105,7 @@ def normalize_datadog_tags(value): labels.update(normalize_datadog_tags(item)) continue - item = _clean(item) + item = clean_string(item) if not item: continue @@ -152,20 +121,6 @@ def normalize_datadog_tags(value): return labels -def _stable_dedup_labels(labels): - ignored = { - "event_link", - "datadog_alert_transition", - "datadog_alert_status", - } - - return { - key: labels[key] - for key in sorted(labels) - if key not in ignored - } - - def normalize_datadog(payload): """Normalize a Datadog Webhooks integration payload.""" @@ -175,25 +130,25 @@ def normalize_datadog(payload): raw_labels = _get(data, "labels") if isinstance(raw_labels, dict): for key, value in raw_labels.items(): - key = _clean(key) - value = _label_value(value) + key = clean_string(key) + value = normalize_label_value(value) if key and value is not None: labels[key] = value labels.update(normalize_datadog_tags(_get(data, "tags"))) labels.update(normalize_datadog_tags(_get(data, "alert_scope", "scope"))) - alert_id = _clean(_get(data, "alert_id", "monitor_id")) - event_id = _clean(_get(data, "id", "event_id")) - alert_cycle_key = _clean(_get(data, "alert_cycle_key")) - aggreg_key = _clean(_get(data, "aggreg_key", "aggregation_key")) - transition = _clean(_get(data, "alert_transition", "transition")) - alert_type = _clean(_get(data, "alert_type", "event_alert_type")) - priority = _clean(_get(data, "alert_priority", "priority")) - alert_scope = _clean(_get(data, "alert_scope", "scope")) - hostname = _clean(_get(data, "hostname", "host")) - event_type = _clean(_get(data, "event_type")) - metric = _clean(_get(data, "alert_metric", "metric")) + alert_id = clean_string(_get(data, "alert_id", "monitor_id")) + event_id = clean_string(_get(data, "id", "event_id")) + alert_cycle_key = clean_string(_get(data, "alert_cycle_key")) + aggreg_key = clean_string(_get(data, "aggreg_key", "aggregation_key")) + transition = clean_string(_get(data, "alert_transition", "transition")) + alert_type = clean_string(_get(data, "alert_type", "event_alert_type")) + priority = clean_string(_get(data, "alert_priority", "priority")) + alert_scope = clean_string(_get(data, "alert_scope", "scope")) + hostname = clean_string(_get(data, "hostname", "host")) + event_type = clean_string(_get(data, "event_type")) + metric = clean_string(_get(data, "alert_metric", "metric")) fixed_labels = { "datadog_alert_id": alert_id, @@ -263,7 +218,14 @@ def normalize_datadog(payload): "datadog", external_id=alert_id or event_id, title=title, - labels=_stable_dedup_labels(labels), + labels=stable_labels( + labels, + exclude={ + "event_link", + "datadog_alert_transition", + "datadog_alert_status", + }, + ), ) team_slug = first_non_empty( diff --git a/app/services/integrations/normalizers/registry.py b/app/services/integrations/normalizers/registry.py new file mode 100644 index 0000000..9d0a8f4 --- /dev/null +++ b/app/services/integrations/normalizers/registry.py @@ -0,0 +1,117 @@ +"""Shared registry for integration payload normalizers. + +Both production ingestion and Event Orchestration simulation use this module, +so adding a normalizer requires updating one registry only. +""" + +from __future__ import annotations + +import copy +from typing import Any, Callable, Dict, Mapping, Optional + +from app.services.integrations.normalizers.alertmanager import normalize_alertmanager +from app.services.integrations.normalizers.aws_sns import normalize_aws_sns +from app.services.integrations.normalizers.datadog import normalize_datadog +from app.services.integrations.normalizers.grafana import normalize_grafana +from app.services.integrations.normalizers.librenms import normalize_librenms +from app.services.integrations.normalizers.rmon import normalize_rmon +from app.services.integrations.normalizers.sentry import normalize_sentry +from app.services.integrations.normalizers.uptime_kuma import normalize_uptime_kuma +from app.services.integrations.normalizers.webhook import normalize_webhook +from app.services.integrations.normalizers.zabbix import normalize_zabbix + + +class UnknownNormalizerSource(ValueError): + """Raised when no payload normalizer is registered for a source.""" + + +Normalizer = Callable[ + [Mapping[str, Any], Mapping[str, Any], Mapping[str, Any]], + Any, +] + + +def _payload_only(normalizer: Callable[[Mapping[str, Any]], Any]) -> Normalizer: + def wrapped( + payload: Mapping[str, Any], + headers: Mapping[str, Any], + route_config: Mapping[str, Any], + ) -> Any: + del headers, route_config + return normalizer(payload) + + return wrapped + + +def _normalize_sentry( + payload: Mapping[str, Any], + headers: Mapping[str, Any], + route_config: Mapping[str, Any], +) -> Any: + return normalize_sentry( + payload, + headers=dict(headers), + route_config=dict(route_config), + ) + + +_NORMALIZERS: Dict[str, Normalizer] = { + "alertmanager": _payload_only(normalize_alertmanager), + "aws_sns": _payload_only(normalize_aws_sns), + "datadog": _payload_only(normalize_datadog), + "grafana": _payload_only(normalize_grafana), + "librenms": _payload_only(normalize_librenms), + "rmon": _payload_only(normalize_rmon), + "sentry": _normalize_sentry, + "uptime_kuma": _payload_only(normalize_uptime_kuma), + "webhook": _payload_only(normalize_webhook), + "zabbix": _payload_only(normalize_zabbix), +} + +SUPPORTED_NORMALIZER_SOURCES = frozenset(_NORMALIZERS) + + +def get_normalizer(source: str) -> Normalizer: + normalized_source = str(source or "").strip().lower() + normalizer = _NORMALIZERS.get(normalized_source) + if normalizer is None: + raise UnknownNormalizerSource( + f"Unsupported integration source: {normalized_source or ''}" + ) + return normalizer + + +def normalize_for_source( + source: str, + payload: Mapping[str, Any], + *, + headers: Optional[Mapping[str, Any]] = None, + route_config: Optional[Mapping[str, Any]] = None, + copy_payload: bool = False, +) -> Any: + """Normalize a payload through the registered source adapter. + + Production ingestion may pass validated payloads directly. Simulation sets + ``copy_payload=True`` to guarantee that a normalizer cannot mutate the + caller's input. + """ + if not isinstance(payload, Mapping): + raise TypeError("payload must be an object") + + source_key = str(source or "").strip().lower() + normalizer = get_normalizer(source_key) + normalized_payload = copy.deepcopy(dict(payload)) if copy_payload else payload + + return normalizer( + normalized_payload, + dict(headers or {}), + dict(route_config or {}), + ) + + +__all__ = [ + "SUPPORTED_NORMALIZER_SOURCES", + "UnknownNormalizerSource", + "get_normalizer", + "normalize_for_source", +] diff --git a/app/services/integrations/normalizers/uptime_kuma.py b/app/services/integrations/normalizers/uptime_kuma.py new file mode 100644 index 0000000..7817e73 --- /dev/null +++ b/app/services/integrations/normalizers/uptime_kuma.py @@ -0,0 +1,302 @@ +from typing import Any, Mapping + +from app.services.integrations.normalizers.common import ( + add_event_link_label, + canonical_label_key, + clean_string, + first_event_link, + first_non_empty, + first_present, + make_dedup_key, + severity_from_priority, + stable_labels, +) +from app.services.severity import normalize_severity + + +UP_STATUSES = {1, "1", "up", "ok", "healthy", "resolved", "recovered"} +DOWN_STATUSES = {0, "0", "down", "fail", "failed", "failing", "unhealthy"} +PENDING_STATUSES = {2, "2", "pending", "retrying"} +MAINTENANCE_STATUSES = {3, "3", "maintenance", "paused"} + +STATUS_LABELS = { + "resolved": "up", + "firing": "down", + "pending": "pending", + "maintenance": "maintenance", + "unknown": "unknown", +} + + +def _set_label(labels: dict[str, Any], key: str, value: Any) -> None: + if value is None: + return + + if isinstance(value, bool): + labels.setdefault(key, "true" if value else "false") + return + + if isinstance(value, (str, int, float)): + value = str(value).strip() + if value: + labels.setdefault(key, value) + + +def normalize_uptime_kuma_state(value: Any) -> str: + """Return a descriptive Uptime Kuma state name.""" + + normalized: Any = value + if isinstance(value, str): + normalized = value.strip().lower() + + if normalized in UP_STATUSES: + return "resolved" + if normalized in MAINTENANCE_STATUSES: + return "maintenance" + if normalized in PENDING_STATUSES: + return "pending" + if normalized in DOWN_STATUSES: + return "firing" + + return "unknown" + + +def normalize_uptime_kuma_status(value: Any) -> str: + """Map Uptime Kuma status to IncidentRelay firing/resolved lifecycle.""" + + state = normalize_uptime_kuma_state(value) + if state in {"resolved", "maintenance"}: + return "resolved" + return "firing" + + +def normalize_uptime_kuma_severity(value: Any, default: str = "critical") -> str: + """Map Uptime Kuma tags or custom priority values to severity.""" + + priority_severity = severity_from_priority(value) + if priority_severity: + return priority_severity + + normalized = str(value or "").strip().lower() + return normalize_severity(normalized) or default + + +def normalize_uptime_kuma_tags(value: Any) -> dict[str, str]: + """Convert Uptime Kuma monitor tags to matcher-friendly labels.""" + + result: dict[str, str] = {} + + if value is None: + return result + + if isinstance(value, Mapping): + items = [value] + elif isinstance(value, (list, tuple, set)): + items = list(value) + else: + items = [value] + + for item in items: + name = None + tag_value = None + + if isinstance(item, Mapping): + name = first_non_empty( + item.get("name"), + item.get("tag_name"), + item.get("key"), + ) + tag_value = first_non_empty( + item.get("value"), + item.get("tag_value"), + ) + else: + text = clean_string(item) + if not text: + continue + if ":" in text: + name, tag_value = text.split(":", 1) + elif "=" in text: + name, tag_value = text.split("=", 1) + else: + name = text + + key = canonical_label_key(name) + if not key: + continue + + value_text = clean_string(tag_value) or "true" + result.setdefault(f"uptime_kuma_tag_{key}", value_text) + + # Common routing tags are also exposed without the integration prefix. + if key in { + "team", + "oncall_team", + "service", + "environment", + "env", + "severity", + "priority", + "region", + "cluster", + }: + result.setdefault(key, value_text) + + return result + + +def _monitor_target(monitor: Mapping[str, Any]) -> str | None: + url = clean_string(monitor.get("url")) + if url: + return url + + hostname = first_non_empty( + monitor.get("hostname"), + monitor.get("host"), + monitor.get("dns_resolve_server"), + ) + port = clean_string(monitor.get("port")) + + if hostname and port: + return f"{hostname}:{port}" + + return clean_string(hostname) + + +def normalize_uptime_kuma(payload: Mapping[str, Any]) -> list[dict[str, Any]]: + """Normalize the standard Uptime Kuma Webhook notification payload.""" + + monitor = dict(payload.get("monitor") or {}) + heartbeat = dict(payload.get("heartbeat") or {}) + labels = dict(payload.get("labels") or {}) + + labels.update(normalize_uptime_kuma_tags(monitor.get("tags"))) + + monitor_id = first_non_empty( + monitor.get("id"), + heartbeat.get("monitorID"), + heartbeat.get("monitorId"), + payload.get("monitor_id"), + ) + monitor_name = first_non_empty( + monitor.get("name"), + payload.get("name"), + ) + monitor_type = first_non_empty( + monitor.get("type"), + payload.get("monitor_type"), + ) + target = _monitor_target(monitor) + raw_status = first_present( + heartbeat.get("status"), + payload.get("status"), + ) + normalized_state = normalize_uptime_kuma_state(raw_status) + status = normalize_uptime_kuma_status(raw_status) + + _set_label(labels, "uptime_kuma_monitor_id", monitor_id) + _set_label(labels, "uptime_kuma_monitor_name", monitor_name) + _set_label(labels, "uptime_kuma_monitor_type", monitor_type) + _set_label(labels, "uptime_kuma_status", STATUS_LABELS[normalized_state]) + _set_label(labels, "uptime_kuma_status_code", raw_status) + _set_label(labels, "uptime_kuma_target", target) + _set_label(labels, "uptime_kuma_hostname", monitor.get("hostname")) + _set_label(labels, "uptime_kuma_port", monitor.get("port")) + _set_label(labels, "uptime_kuma_ping_ms", heartbeat.get("ping")) + _set_label( + labels, + "uptime_kuma_duration_seconds", + heartbeat.get("duration"), + ) + _set_label( + labels, + "uptime_kuma_local_datetime", + first_non_empty( + heartbeat.get("localDateTime"), + heartbeat.get("time"), + ), + ) + + event_link = first_event_link( + payload.get("event_link"), + payload.get("monitor_url"), + monitor.get("dashboardURL"), + monitor.get("dashboard_url"), + target if str(target or "").startswith(("http://", "https://")) else None, + ) + add_event_link_label(labels, event_link) + + title = first_non_empty( + monitor_name, + payload.get("title"), + "Uptime Kuma notification", + ) + message = first_non_empty( + heartbeat.get("msg"), + payload.get("msg"), + payload.get("message"), + target, + "", + ) + + explicit_severity = first_non_empty( + payload.get("severity"), + monitor.get("severity"), + labels.get("severity"), + labels.get("priority"), + ) + default_severity = "info" if not monitor and not heartbeat else "critical" + severity = normalize_uptime_kuma_severity( + explicit_severity, + default=default_severity, + ) + + external_id = clean_string(monitor_id) + if external_id: + dedup_key = f"uptime-kuma:{external_id}" + else: + dedup_key = make_dedup_key( + "uptime_kuma", + external_id=first_non_empty(monitor_name, target), + title=title, + labels=stable_labels( + labels, + exclude={ + "event_link", + "uptime_kuma_status", + "uptime_kuma_status_code", + "uptime_kuma_ping_ms", + "uptime_kuma_duration_seconds", + "uptime_kuma_local_datetime", + }, + ), + ) + + return [{ + "source": "uptime_kuma", + "team_slug": ( + labels.get("team") + or labels.get("oncall_team") + or payload.get("team") + ), + "external_id": external_id, + "dedup_key": dedup_key, + "title": title, + "message": message or "", + "severity": severity, + "labels": labels, + "annotations": { + "uptime_kuma_state": STATUS_LABELS[normalized_state], + }, + "payload": dict(payload), + "status": status, + }] + + +__all__ = [ + "normalize_uptime_kuma", + "normalize_uptime_kuma_state", + "normalize_uptime_kuma_status", + "normalize_uptime_kuma_severity", + "normalize_uptime_kuma_tags", +] diff --git a/app/services/integrations/normalizers/webhook.py b/app/services/integrations/normalizers/webhook.py index a126476..e31d1bc 100644 --- a/app/services/integrations/normalizers/webhook.py +++ b/app/services/integrations/normalizers/webhook.py @@ -1,11 +1,12 @@ -import json from copy import deepcopy from app.services.integrations.normalizers.common import ( add_event_link_label, + clean_string, first_event_link, first_non_empty, make_dedup_key, + normalize_label_value, ) from app.services.severity import normalize_severity @@ -23,29 +24,6 @@ def is_pagerduty_events_v2(payload): return action in PAGERDUTY_EVENT_ACTIONS -def _clean_string(value): - if value is None: - return None - - value = str(value).strip() - return value or None - - -def _label_value(value): - """Convert custom detail values into matcher-friendly label values.""" - - if value is None: - return None - - if isinstance(value, (str, int, float, bool)): - return value - - try: - return json.dumps(value, ensure_ascii=False, sort_keys=True) - except (TypeError, ValueError): - return str(value) - - def _sanitized_payload(payload): """Copy the source payload without persisting the secret routing key.""" @@ -63,7 +41,7 @@ def _pagerduty_labels(payload, event_payload, action): labels.setdefault("pagerduty_format", "events_api_v2") for key in ("source", "component", "group", "class"): - value = _clean_string(event_payload.get(key)) + value = clean_string(event_payload.get(key)) if value is not None: labels.setdefault(key, value) @@ -72,17 +50,17 @@ def _pagerduty_labels(payload, event_payload, action): if isinstance(custom_details, dict): for key, value in custom_details.items(): - key = _clean_string(key) + key = clean_string(key) if not key or key in labels: continue - value = _label_value(value) + value = normalize_label_value(value) if value is not None: labels[key] = value - client = _clean_string(payload.get("client")) + client = clean_string(payload.get("client")) if client: labels.setdefault("pagerduty_client", client) @@ -116,9 +94,9 @@ def _normalize_pagerduty_events_v2(payload): event_link = _pagerduty_event_link(payload) add_event_link_label(labels, event_link) - dedup_key = _clean_string(payload.get("dedup_key")) - summary = _clean_string(event_payload.get("summary")) - source = _clean_string(event_payload.get("source")) + dedup_key = clean_string(payload.get("dedup_key")) + summary = clean_string(event_payload.get("summary")) + source = clean_string(event_payload.get("source")) title = summary or ( f"PagerDuty event {dedup_key}" diff --git a/app/services/maintenance.py b/app/services/maintenance.py index b46e680..01afd2b 100644 --- a/app/services/maintenance.py +++ b/app/services/maintenance.py @@ -344,6 +344,21 @@ def normalize_window_payload(payload, *, existing_window=None, partial=False): if rrule: rrule = normalize_rrule(rrule, starts_at) + apply_to_existing = payload.get("apply_to_existing") + if apply_to_existing is None and partial and existing_window is not None: + apply_to_existing = existing_window.apply_to_existing + apply_to_existing = bool(apply_to_existing) if apply_to_existing is not None else False + + reactivate_on_end = payload.get("reactivate_on_end") + if reactivate_on_end is None and partial and existing_window is not None: + reactivate_on_end = existing_window.reactivate_on_end + reactivate_on_end = True if reactivate_on_end is None else bool(reactivate_on_end) + + if behavior == "suppress_incident" and apply_to_existing: + raise ValueError( + "apply_to_existing is not supported for suppress_incident behavior" + ) + enabled = payload.get("enabled") if enabled is None and partial and existing_window is not None: enabled = existing_window.enabled @@ -366,6 +381,8 @@ def normalize_window_payload(payload, *, existing_window=None, partial=False): "starts_at": starts_at, "ends_at": ends_at, "enabled": enabled, + "apply_to_existing": apply_to_existing, + "reactivate_on_end": reactivate_on_end, "scopes": scopes, } @@ -384,6 +401,13 @@ def create_maintenance_window(payload, *, user_id): maintenance_repo.replace_maintenance_window_scopes(window, scopes) + from app.services.alerts.maintenance_state import reconcile_maintenance_window + reconcile_maintenance_window( + window, + trigger_source="api", + actor_user_id=user.id, + ) + return window @@ -417,6 +441,13 @@ def update_maintenance_window(window_id, payload, *, user_id): if scopes is not None: maintenance_repo.replace_maintenance_window_scopes(window, scopes) + from app.services.alerts.maintenance_state import reconcile_maintenance_window + reconcile_maintenance_window( + window, + trigger_source="api", + actor_user_id=user.id, + ) + return window @@ -435,12 +466,20 @@ def cancel_maintenance_window(window_id, payload=None, *, user_id): if isinstance(payload, dict): reason = payload.get("reason") - return maintenance_repo.cancel_maintenance_window( + window = maintenance_repo.cancel_maintenance_window( window, cancelled_by=user, reason=reason, ) + from app.services.alerts.maintenance_state import reconcile_maintenance_window + reconcile_maintenance_window( + window, + trigger_source="api", + actor_user_id=user.id, + ) + return window + def delete_maintenance_window(window_id, *, user_id): user = _get_user_or_raise(user_id) @@ -454,6 +493,13 @@ def delete_maintenance_window(window_id, *, user_id): maintenance_repo.soft_delete_maintenance_window(window) + from app.services.alerts.maintenance_state import reconcile_maintenance_window + reconcile_maintenance_window( + window, + trigger_source="api", + actor_user_id=user.id, + force_release=True, + ) return window diff --git a/app/services/notifications/delivery.py b/app/services/notifications/delivery.py index e298cfd..d2c4a9b 100644 --- a/app/services/notifications/delivery.py +++ b/app/services/notifications/delivery.py @@ -8,8 +8,10 @@ from app.services.routing.service_context import format_service_context_plain, service_display_name from app.services.notifications import rules from app.services.alerts.priority import alert_priority_label, format_alert_title_with_priority +from app.services.alerts.maintenance_state import is_notification_lifecycle_suppressed from app.services.notifications.policies.resolver import resolve_notification_channels from app.services.alerts.correlation import format_correlation_plain +from app.modules.common import utc_now EDITABLE_EVENTS = {"acknowledged", "resolved"} @@ -193,6 +195,22 @@ def notify_alert(group, event_type="notification"): group = _ensure_alert_group(group) + if ( + event_type in {"notification", "update", "reminder", "escalation"} + and is_notification_lifecycle_suppressed(group) + ): + logger.info( + "notification skipped during maintenance", + extra={ + "extra": { + "alert_group_id": group.id, + "event_type": event_type, + "maintenance_window_id": group.maintenance_window_id, + } + }, + ) + return 0 + text = format_alert_message(group, event_type) sent_count = 0 @@ -378,7 +396,7 @@ def notify_alert(group, event_type="notification"): ) if sent_count: - alerts_repo.record_group_notification_time(group, datetime.utcnow()) + alerts_repo.record_group_notification_time(group, utc_now()) return sent_count diff --git a/app/services/notifications/policies/resolver.py b/app/services/notifications/policies/resolver.py index cecd17a..422f265 100644 --- a/app/services/notifications/policies/resolver.py +++ b/app/services/notifications/policies/resolver.py @@ -71,34 +71,56 @@ def _add_route_channels(result, route): _add_channel(result, link.channel, "route") -def _add_service_policy_channels(result, group, event_type): - service = getattr(group, "service", None) - +def _selected_notification_policy(result, subject): + override_id = getattr(subject, "notification_policy_id", None) + if override_id: + policy = notification_policies_repo.get_notification_policy_or_none( + override_id, + include_deleted=True, + ) + result.policy_id = override_id + if not policy: + result.notes.append("orchestration_notification_policy_missing") + return None, "orchestration_policy" + if policy.deleted: + result.notes.append("orchestration_notification_policy_deleted") + return None, "orchestration_policy" + if not policy.enabled: + result.notes.append("orchestration_notification_policy_disabled") + return None, "orchestration_policy" + if policy.team_id != getattr(subject, "team_id", None): + result.notes.append("orchestration_notification_policy_team_mismatch") + return None, "orchestration_policy" + return policy, "orchestration_policy" + + service = getattr(subject, "service", None) if not service: result.notes.append("service_missing") - return + return None, "service_policy" result.service_id = service.id - policy_id = getattr(service, "notification_policy_id", None) - if not policy_id: result.notes.append("service_notification_policy_missing") - return + return None, "service_policy" policy = service.notification_policy result.policy_id = policy.id - if policy.deleted: result.notes.append("service_notification_policy_deleted") - return - + return None, "service_policy" if not policy.enabled: result.notes.append("service_notification_policy_disabled") - return - - if policy.team_id != getattr(group, "team_id", None): + return None, "service_policy" + if policy.team_id != getattr(subject, "team_id", None): result.notes.append("service_notification_policy_team_mismatch") + return None, "service_policy" + return policy, "service_policy" + + +def _add_service_policy_channels(result, group, event_type): + policy, source = _selected_notification_policy(result, group) + if policy is None: return matched = False @@ -129,7 +151,7 @@ def _add_service_policy_channels(result, group, event_type): ) for channel in channels: - _add_channel(result, channel, "service_policy", rule.id) + _add_channel(result, channel, source, rule.id) if not rule.continue_matching: break @@ -160,15 +182,26 @@ def resolve_notification_channels(group, event_type="notification"): getattr(route, "notification_channel_mode", None) or ROUTE_ONLY ) + has_orchestration_policy = bool( + getattr(group, "notification_policy_id", None) + ) - if configured_mode in NOTIFICATION_CHANNEL_MODES: + if has_orchestration_policy: + mode = ( + SERVICE_POLICY_PLUS_ROUTE + if configured_mode == SERVICE_POLICY_PLUS_ROUTE + else SERVICE_POLICY + ) + elif configured_mode in NOTIFICATION_CHANNEL_MODES: mode = configured_mode else: mode = ROUTE_ONLY result = NotificationChannelResolution(mode=mode) - if configured_mode != mode: + if has_orchestration_policy: + result.notes.append("orchestration_notification_policy_override") + elif configured_mode != mode: result.notes.append("unknown_channel_mode_fallback_to_route_only") if mode in {SERVICE_POLICY, SERVICE_POLICY_PLUS_ROUTE}: diff --git a/app/services/notifications/policies/service.py b/app/services/notifications/policies/service.py index f6717d8..b103e33 100644 --- a/app/services/notifications/policies/service.py +++ b/app/services/notifications/policies/service.py @@ -8,6 +8,7 @@ ) from app.services.serializers.common import attach_team_permissions from app.services.routing.matcher import service as matcher_preset_service +from app.services.payloads import payload_to_dict class NotificationPolicyError(ValueError): @@ -26,14 +27,6 @@ class NotificationPolicyInUseError(NotificationPolicyConflictError): """Policy cannot be deleted because services use it.""" -def _payload_dict(payload): - """Convert Pydantic schema or mapping to a mutable dict.""" - if hasattr(payload, "model_dump"): - return payload.model_dump(exclude_unset=True) - - return dict(payload or {}) - - def _clean_name(value): """Normalize and validate a resource name.""" name = str(value or "").strip() @@ -248,7 +241,7 @@ def list_policies( def create_policy(payload): """Create or restore a notification policy.""" - data = _payload_dict(payload) + data = payload_to_dict(payload) team = _require_team(data.get("team_id")) name = _clean_name(data.get("name")) existing = ( @@ -292,7 +285,7 @@ def create_policy(payload): def update_policy(policy_id, payload): """Update notification policy.""" policy = get_policy(policy_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) if "name" in data: name = _clean_name(data["name"]) @@ -364,7 +357,7 @@ def validate_policy_assignment( def create_rule(policy_id, payload): """Create rule and its channel links atomically.""" policy = get_policy(policy_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) preset = matcher_preset_service.validate_preset_assignment( data.get("matcher_preset_id"), @@ -430,7 +423,7 @@ def update_rule(rule_id, payload): """Update rule, channel links and position atomically.""" rule = get_rule(rule_id) policy = get_policy(rule.policy_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) current_channel_ids = notification_policies_repo.list_rule_channel_ids(rule.id) diff --git a/app/services/notifications/rules.py b/app/services/notifications/rules.py index 3c3f7a8..d2f7d34 100644 --- a/app/services/notifications/rules.py +++ b/app/services/notifications/rules.py @@ -13,6 +13,8 @@ from app.notifiers.voice.notifier import VoiceCallNotifier from app.services.severity import normalize_severity, normalize_severity_list from app.modules.db import alerts_repo +from app.modules.common import utc_now +from app.services.alerts.maintenance_state import is_notification_lifecycle_suppressed logger = logging.getLogger("oncall.notification_rules") @@ -63,6 +65,9 @@ def should_skip_delivery_for_group_status(delivery): group = AlertGroup.get_by_id(delivery.group_id) + if is_notification_lifecycle_suppressed(group): + return True + return group.status != "firing" @@ -123,8 +128,8 @@ def create_user_rule( severities=severities or [], event_types=event_types or [], enabled=enabled, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -143,7 +148,7 @@ def update_user_rule(user, rule_id, payload): rule.severities = severities or [] rule.event_types = event_types or [] rule.enabled = bool(payload.get("enabled", rule.enabled)) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -153,9 +158,9 @@ def delete_user_rule(user, rule_id): rule = get_user_rule(user, rule_id) rule.deleted = True - rule.deleted_at = datetime.utcnow() + rule.deleted_at = utc_now() rule.enabled = False - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -289,6 +294,12 @@ def enqueue_user_notifications(group, event_type="notification"): group = _ensure_alert_group(group) + if ( + event_type in SKIP_IF_NOT_FIRING_EVENT_TYPES + and is_notification_lifecycle_suppressed(group) + ): + return 0 + assignee = getattr(group, "assignee", None) assignee_id = getattr(group, "assignee_id", None) @@ -301,7 +312,7 @@ def enqueue_user_notifications(group, event_type="notification"): except User.DoesNotExist: return 0 - now = datetime.utcnow() + now = utc_now() # Default profile browser push when the user has no custom rules. if not has_custom_user_rules(assignee.id): @@ -354,14 +365,14 @@ def create_delivery(group, user, rule, method, event_type, scheduled_at): event_type=event_type, status="pending", scheduled_at=scheduled_at, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) def process_due_user_notifications(limit=100): """Process due user notification deliveries and return sent count.""" - now = datetime.utcnow() + now = utc_now() processed = 0 due_deliveries = ( @@ -380,7 +391,7 @@ def process_due_user_notifications(limit=100): UserNotificationDelivery .update( status="processing", - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( (UserNotificationDelivery.id == due_delivery.id) @@ -394,6 +405,18 @@ def process_due_user_notifications(limit=100): delivery = UserNotificationDelivery.get_by_id(due_delivery.id) + group = AlertGroup.get_by_id(delivery.group_id) + + if ( + delivery.event_type in SKIP_IF_NOT_FIRING_EVENT_TYPES + and is_notification_lifecycle_suppressed(group) + ): + mark_delivery_skipped( + delivery, + "maintenance_suppressed", + ) + continue + if should_skip_delivery_for_group_status(delivery): mark_delivery_skipped( delivery, @@ -408,7 +431,7 @@ def process_due_user_notifications(limit=100): def send_browser_push_delivery(delivery): """Send browser push delivery and update delivery history.""" - now = datetime.utcnow() + now = utc_now() group = delivery.group try: @@ -471,6 +494,10 @@ def send_delivery(delivery): return 0 if delivery.event_type in SKIP_IF_NOT_FIRING_EVENT_TYPES: + if is_notification_lifecycle_suppressed(group): + mark_delivery_skipped(delivery, "maintenance_suppressed") + return 0 + if group.status != "firing": mark_delivery_skipped(delivery, "alert_not_firing") return 0 @@ -493,14 +520,14 @@ def send_delivery(delivery): return 0 delivery.status = "sent" - delivery.sent_at = datetime.utcnow() + delivery.sent_at = utc_now() delivery.provider = result.get("provider") or delivery.method delivery.external_message_id = result.get("external_message_id") delivery.external_channel_id = result.get("external_channel_id") delivery.provider_status = result.get("provider_status") delivery.provider_payload = result.get("provider_payload") delivery.last_error = None - delivery.updated_at = datetime.utcnow() + delivery.updated_at = utc_now() delivery.save() return 1 @@ -555,7 +582,7 @@ def build_voice_rule_callback_url(delivery): def mark_delivery_skipped(delivery, reason): """Mark user notification delivery as skipped.""" - now = datetime.utcnow() + now = utc_now() ( UserNotificationDelivery @@ -573,5 +600,5 @@ def mark_delivery_skipped(delivery, reason): def mark_delivery_failed(delivery, error): delivery.status = "failed" delivery.last_error = str(error) - delivery.updated_at = datetime.utcnow() + delivery.updated_at = utc_now() delivery.save() diff --git a/app/services/notifications/shift_notifications.py b/app/services/notifications/shift_notifications.py index 70c3d8a..1a402cb 100644 --- a/app/services/notifications/shift_notifications.py +++ b/app/services/notifications/shift_notifications.py @@ -1,7 +1,7 @@ import logging import smtplib -from datetime import datetime, timedelta, timezone +from datetime import timedelta from email.message import EmailMessage from app import Config @@ -14,6 +14,7 @@ from app.notifiers.registry import get_notifier from app.notifiers.types import MATTERMOST_CHANNEL from app.services.calendar_service import build_rotation_calendar +from app.modules.common import as_utc_naive, utc_now logger = logging.getLogger("oncall.shift_notifications") @@ -30,19 +31,6 @@ def _team_display_name(event): return event.get("team_name") or event.get("team_slug") or "-" -def _event_dt(value): - """Parse calendar event datetime and return naive UTC datetime.""" - if isinstance(value, datetime): - parsed = value - else: - parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) - - if parsed.tzinfo is not None: - return parsed.astimezone(timezone.utc).replace(tzinfo=None) - - return parsed - - def _format_dt(value): return value.strftime("%Y-%m-%d %H:%M:%S UTC") @@ -77,30 +65,6 @@ def _mattermost_notification_fingerprint(event, event_type, mattermost_user_id): ) -def _event_contains_time(event, at): - start_at = _event_dt(event["start"]) - end_at = _event_dt(event["end"]) - - return start_at <= at < end_at - - -def _same_user_event_at(rotation, user_id, at): - events = build_rotation_calendar( - rotation, - at - timedelta(seconds=1), - at + timedelta(seconds=1), - ) - - for event in events: - if int(event.get("user_id") or 0) != int(user_id): - continue - - if _event_contains_time(event, at): - return event - - return None - - def _send_plain_email(to_email, subject, body): smtp_host = Config.SMTP_HOST smtp_port = int(Config.SMTP_PORT) @@ -133,8 +97,8 @@ def _build_shift_email(user, event, event_type): rotation_name = event.get("rotation_name") or f"Rotation #{event.get('rotation_id')}" team_name = _team_display_name(event) layer_name = event.get("layer_name") or "Override" if event.get("type") == "override" else "-" - start_at = _event_dt(event["start"]) - end_at = _event_dt(event["end"]) + start_at = as_utc_naive(event["start"]) + end_at = as_utc_naive(event["end"]) if event_type == SHIFT_START: subject = f"[On-call] Your shift has started: {rotation_name}" @@ -179,13 +143,13 @@ def _get_or_create_log(user, rotation, event, event_type): "user": user, "rotation": rotation, "event_type": event_type, - "slot_start_at": _event_dt(event["start"]), - "slot_end_at": _event_dt(event["end"]), + "slot_start_at": as_utc_naive(event["start"]), + "slot_end_at": as_utc_naive(event["end"]), "layer_id": event.get("layer_id"), "override_id": event.get("override_id"), "status": "pending", - "created_at": datetime.utcnow(), - "updated_at": datetime.utcnow(), + "created_at": utc_now(), + "updated_at": utc_now(), }, ) @@ -195,10 +159,10 @@ def _get_or_create_log(user, rotation, event, event_type): def _mark_log(log, status, error=None): log.status = status log.last_error = error - log.updated_at = datetime.utcnow() + log.updated_at = utc_now() if status == "sent": - log.sent_at = datetime.utcnow() + log.sent_at = utc_now() log.save() @@ -217,14 +181,14 @@ def _get_or_create_mattermost_log(user, rotation, event, event_type): "user": user, "rotation": rotation, "event_type": event_type, - "slot_start_at": _event_dt(event["start"]), - "slot_end_at": _event_dt(event["end"]), + "slot_start_at": as_utc_naive(event["start"]), + "slot_end_at": as_utc_naive(event["end"]), "layer_id": event.get("layer_id"), "override_id": event.get("override_id"), "mattermost_user_id": mattermost_user_id, "status": "pending", - "created_at": datetime.utcnow(), - "updated_at": datetime.utcnow(), + "created_at": utc_now(), + "updated_at": utc_now(), }, ) @@ -347,8 +311,8 @@ def _send_shift_event(event, event_type): def _event_due(event, event_type, window_start, now): - start_at = _event_dt(event["start"]) - end_at = _event_dt(event["end"]) + start_at = as_utc_naive(event["start"]) + end_at = as_utc_naive(event["end"]) if event_type == SHIFT_START: return window_start < start_at <= now @@ -366,7 +330,7 @@ def send_due_oncall_shift_email_notifications(now=None, lookback_seconds=None): The scheduler runs periodically, so this job looks back a small window and uses OnCallShiftEmailNotification.fingerprint to avoid duplicates. """ - now = now or datetime.utcnow() + now = now or utc_now() if lookback_seconds is None: lookback_seconds = int( @@ -414,7 +378,7 @@ def send_due_oncall_shift_mattermost_notifications(now=None, lookback_seconds=No User.mattermost_user_id is set. End-of-shift messages are intentionally not sent to avoid noisy direct messages. """ - now = now or datetime.utcnow() + now = now or utc_now() if lookback_seconds is None: lookback_seconds = int( diff --git a/app/services/oncall.py b/app/services/oncall.py index f3cd47d..01d82cf 100644 --- a/app/services/oncall.py +++ b/app/services/oncall.py @@ -1,37 +1,14 @@ -from datetime import datetime, timezone as dt_timezone -from zoneinfo import ZoneInfo - from app.modules.db import rotations_repo - - -def _effective_layer_value(layer, field_name): - value = getattr(layer, field_name, None) - if value is not None: - return value - return getattr(layer.rotation, field_name) - - -def _as_utc_naive(value): - if value.tzinfo is None: - return value - return value.astimezone(dt_timezone.utc).replace(tzinfo=None) +from app.modules.common import as_utc_aware, as_utc_naive, utc_now +from app.services.rotation_schedule import ( + effective_layer_value as _effective_layer_value, + layer_slot_index, + layer_timezone, +) def _to_layer_local(now, layer): - timezone_name = _effective_layer_value(layer, "timezone") or "UTC" - - try: - zone = ZoneInfo(timezone_name) - except Exception: - zone = ZoneInfo("UTC") - - now_utc = now - if now_utc.tzinfo is None: - now_utc = now_utc.replace(tzinfo=dt_timezone.utc) - else: - now_utc = now_utc.astimezone(dt_timezone.utc) - - return now_utc.astimezone(zone) + return as_utc_aware(now).astimezone(layer_timezone(layer)) def _parse_hhmm(value): @@ -93,7 +70,7 @@ def is_layer_active_now(layer, now): def get_scheduled_oncall_user_for_layer(layer, now=None): """Return scheduled user for one layer.""" - now = _as_utc_naive(now or datetime.utcnow()) + now = as_utc_naive(now or utc_now()) members = rotations_repo.list_rotation_layer_members( layer.id, @@ -104,19 +81,10 @@ def get_scheduled_oncall_user_for_layer(layer, now=None): if not members: return None - start_at = _effective_layer_value(layer, "start_at") - duration_seconds = _effective_layer_value(layer, "duration_seconds") - - if not start_at or not duration_seconds: - return members[0].user - - start_at = _as_utc_naive(start_at) - elapsed = int((now - start_at).total_seconds()) - - if elapsed < 0: + slot = layer_slot_index(layer, now) + if slot is None: return None - slot = elapsed // int(duration_seconds) return members[slot % len(members)].user @@ -126,7 +94,7 @@ def get_active_rotation_layer(rotation, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = as_utc_naive(now or utc_now()) layers = rotations_repo.list_rotation_layers( rotation.id, @@ -148,7 +116,7 @@ def get_scheduled_oncall_user(rotation, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = as_utc_naive(now or utc_now()) layer = get_active_rotation_layer(rotation, now) if not layer: @@ -165,7 +133,7 @@ def get_current_oncall_user(rotation, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = as_utc_naive(now or utc_now()) override = rotations_repo.get_active_override(rotation.id, now) @@ -181,7 +149,7 @@ def get_next_rotation_user(rotation, current_user=None, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = as_utc_naive(now or utc_now()) layer = get_active_rotation_layer(rotation, now) if not layer: diff --git a/app/services/oncall_health.py b/app/services/oncall_health.py index 1d4fd26..10db100 100644 --- a/app/services/oncall_health.py +++ b/app/services/oncall_health.py @@ -1,11 +1,13 @@ from dataclasses import dataclass -from datetime import datetime, timedelta, timezone as dt_timezone +from datetime import datetime, timedelta from typing import Iterable from peewee import DoesNotExist from app.modules.db import channels_repo, rotations_repo, routes_repo, teams_repo from app.services.oncall import get_current_oncall_user +from app.modules.common import as_utc_naive_seconds, utc_now_seconds +from app.services.serializers.common import serialize_utc_datetime DEFAULT_HEALTH_WINDOW_DAYS = 7 DEFAULT_HEALTH_SAMPLE_MINUTES = 60 @@ -60,31 +62,19 @@ def to_dict(self) -> dict: } -def utc_now_naive() -> datetime: - return datetime.utcnow().replace(microsecond=0) - - -def as_utc_naive(value: datetime | None) -> datetime | None: - if value is None: - return None - if value.tzinfo is None: - return value.replace(microsecond=0) - return value.astimezone(dt_timezone.utc).replace(tzinfo=None, microsecond=0) - - def serialize_health_datetime(value: datetime | None) -> str | None: - value = as_utc_naive(value) + value = as_utc_naive_seconds(value) if value is None: return None - return value.replace(tzinfo=dt_timezone.utc).isoformat().replace("+00:00", "Z") + return serialize_utc_datetime(value) def default_window( starts_at: datetime | None = None, ends_at: datetime | None = None, ) -> tuple[datetime, datetime]: - start = as_utc_naive(starts_at) or utc_now_naive() - end = as_utc_naive(ends_at) or (start + timedelta(days=DEFAULT_HEALTH_WINDOW_DAYS)) + start = as_utc_naive_seconds(starts_at) or utc_now_seconds() + end = as_utc_naive_seconds(ends_at) or (start + timedelta(days=DEFAULT_HEALTH_WINDOW_DAYS)) if end <= start: end = start + timedelta(days=DEFAULT_HEALTH_WINDOW_DAYS) return start, end diff --git a/app/services/orchestration/__init__.py b/app/services/orchestration/__init__.py new file mode 100644 index 0000000..903e901 --- /dev/null +++ b/app/services/orchestration/__init__.py @@ -0,0 +1,98 @@ +"""Event Orchestration condition, extraction and template services.""" + +from .conditions import ConditionResult, evaluate_condition_tree, validate_condition_tree +from .evaluator import RuleEvaluationResult, evaluate_rule +from .fields import MISSING, build_context, resolve_field +from .templates import TemplateRenderResult, render_template, validate_template +from .variables import ExtractionResult, extract_variables, validate_extractors + +__all__ = [ + "ConditionResult", + "ExtractionResult", + "MISSING", + "RuleEvaluationResult", + "TemplateRenderResult", + "build_context", + "evaluate_condition_tree", + "evaluate_rule", + "extract_variables", + "render_template", + "resolve_field", + "validate_condition_tree", + "validate_extractors", + "validate_template", +] + +# BEGIN EVENT ORCHESTRATION WS3 EXPORTS +from .actions import ( + ActionExecutionResult, + ActionStepResult, + ActionValidationError, + EventActionState, + execute_actions, + validate_action, + validate_action_list, +) +from .engine import ( + OrchestrationExecutionResult, + RuleExecutionResult, + execute_rule_tree, +) + +__all__ += [ + "ActionExecutionResult", + "ActionStepResult", + "ActionValidationError", + "EventActionState", + "OrchestrationExecutionResult", + "RuleExecutionResult", + "execute_actions", + "execute_rule_tree", + "validate_action", + "validate_action_list", +] +# END EVENT ORCHESTRATION WS3 EXPORTS + +# BEGIN EVENT ORCHESTRATION WS4 EXPORTS +from .runtime import ( + RuntimeOrchestrationError, + RuntimeResult, + RuntimeStep, + attach_runtime_executions, + run_event_orchestration, + run_service_orchestration, +) + +__all__ += [ + "RuntimeOrchestrationError", + "RuntimeResult", + "RuntimeStep", + "attach_runtime_executions", + "run_event_orchestration", + "run_service_orchestration", +] +# END EVENT ORCHESTRATION WS4 EXPORTS + +# BEGIN EVENT ORCHESTRATION WS6 EXPORTS +from .webhooks import ( + WebhookDeliveryError, + WebhookSecurityError, + WebhookValidationError, + create_webhook_action, + process_due_webhooks, + retry_failed_webhook, + serialize_webhook_action, + update_webhook_action, +) + +__all__ += [ + "WebhookDeliveryError", + "WebhookSecurityError", + "WebhookValidationError", + "create_webhook_action", + "process_due_webhooks", + "retry_failed_webhook", + "serialize_webhook_action", + "update_webhook_action", +] +# END EVENT ORCHESTRATION WS6 EXPORTS diff --git a/app/services/orchestration/actions.py b/app/services/orchestration/actions.py new file mode 100644 index 0000000..822cf24 --- /dev/null +++ b/app/services/orchestration/actions.py @@ -0,0 +1,809 @@ +"""Deterministic Event Orchestration action execution. + +The action layer mutates an isolated JSON-compatible event state. It never +loads database entities, performs network I/O or calls provider integrations. +Static entity references are validated by the control-plane repository before +publication and are resolved by a later ingestion-integration workstream. +""" + +from __future__ import annotations + +import copy +import json +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple + +from .errors import OrchestrationEvaluationError, ValidationIssue +from .fields import MISSING, resolve_field +from .templates import render_template, validate_template +from .variables import EXTRACTION_TYPES, extract_variables, validate_extractor + + +MAX_ACTIONS_PER_RULE = 128 +MAX_ACTION_VALUE_DEPTH = 16 +MAX_ACTION_COLLECTION_ITEMS = 512 +MAX_ACTION_VALUE_BYTES = 65_536 +MAX_LABELS = 256 +MAX_LABEL_NAME_LENGTH = 128 +MAX_CUSTOM_FIELD_NAME_LENGTH = 128 +MAX_NOTE_LENGTH = 8_192 +MAX_PAUSE_SECONDS = 604_800 +MAX_GROUP_WINDOW_SECONDS = 86_400 + +FAILURE_MODES = frozenset({"continue", "stop_rule", "stop_orchestration"}) +PROCESS_DISPOSITIONS = frozenset({"process", "suppress", "pause", "drop"}) +EVENT_ACTIONS = frozenset({"trigger", "resolve"}) + +_TEXT_FIELD_ACTIONS = { + "set_title": "title", + "set_message": "message", + "set_description": "description", + "set_severity": "severity", + "set_priority": "priority", + "set_dedup_key": "dedup_key", + "set_group_key": "group_key", +} +_ROUTING_ACTIONS = { + "set_route": ("route", "route_id"), + "set_team": ("team", "team_id"), + "set_service": ("service", "service_id"), +} +_POLICY_ACTIONS = { + "set_escalation_policy": "escalation_policy_id", + "set_notification_policy": "notification_policy_id", + "set_priority_policy": "priority_policy_id", +} +SUPPORTED_ACTION_TYPES = frozenset( + set(EXTRACTION_TYPES) + | set(_TEXT_FIELD_ACTIONS) + | set(_ROUTING_ACTIONS) + | set(_POLICY_ACTIONS) + | { + "set_event_action", + "set_label", + "remove_label", + "set_custom_field", + "remove_custom_field", + "set_grouping", + "add_note", + "suppress", + "drop", + "pause", + "enqueue_webhook", + } +) + +_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.:-]*$") + + +class ActionValidationError(OrchestrationEvaluationError): + code = "invalid_action" + + +@dataclass +class EventActionState: + """Mutable internal state; caller-owned mappings are always deep-copied.""" + + event: Dict[str, Any] + raw: Dict[str, Any] + variables: Dict[str, Any] + route: Dict[str, Any] + service: Dict[str, Any] + team: Dict[str, Any] + integration: Dict[str, Any] + time: Dict[str, Any] + result: Dict[str, Any] + + @classmethod + def from_context(cls, context: Mapping[str, Any]) -> "EventActionState": + event = _json_copy(context.get("event") or {}, path="event") + labels = context.get("labels") + if labels is None: + labels = event.get("labels") or {} + event["labels"] = _json_copy(labels, path="labels") + + result = _json_copy(context.get("result") or {}, path="result") + result.setdefault("disposition", "process") + result.setdefault("suppress_notifications", False) + result.setdefault("dropped", False) + result.setdefault("pause_seconds", None) + result.setdefault("pause_retrigger", "preserve") + result.setdefault("suppress_reason", None) + result.setdefault("pause_reason", None) + result.setdefault("drop_reason", None) + result.setdefault("routing", {}) + result.setdefault("policies", {}) + result.setdefault("grouping", {}) + result.setdefault("notes", []) + result.setdefault("webhooks", []) + + if result["disposition"] not in PROCESS_DISPOSITIONS: + raise ActionValidationError("result.disposition is invalid", path="result.disposition") + if not isinstance(result["routing"], dict): + raise ActionValidationError("result.routing must be an object", path="result.routing") + if not isinstance(result["policies"], dict): + raise ActionValidationError("result.policies must be an object", path="result.policies") + if not isinstance(result["grouping"], dict): + raise ActionValidationError("result.grouping must be an object", path="result.grouping") + if not isinstance(result["notes"], list): + raise ActionValidationError("result.notes must be a list", path="result.notes") + if not isinstance(result["webhooks"], list): + raise ActionValidationError("result.webhooks must be a list", path="result.webhooks") + + return cls( + event=event, + raw=_json_copy(context.get("raw") or {}, path="raw"), + variables=_json_copy(context.get("variables") or {}, path="variables"), + route=_json_copy(context.get("route") or {}, path="route"), + service=_json_copy(context.get("service") or {}, path="service"), + team=_json_copy(context.get("team") or {}, path="team"), + integration=_json_copy(context.get("integration") or {}, path="integration"), + time=_json_copy(context.get("time") or {}, path="time"), + result=result, + ) + + @property + def labels(self) -> Dict[str, Any]: + labels = self.event.setdefault("labels", {}) + if not isinstance(labels, dict): + raise ActionValidationError("event.labels must be an object", path="event.labels") + return labels + + def context(self) -> Dict[str, Any]: + return { + "event": self.event, + "labels": self.labels, + "raw": self.raw, + "variables": self.variables, + "route": self.route, + "service": self.service, + "team": self.team, + "integration": self.integration, + "time": self.time, + "result": self.result, + } + + def to_dict(self) -> Dict[str, Any]: + return _json_copy(self.context(), path="context") + + +@dataclass(frozen=True) +class ActionStepResult: + index: int + action_type: str + success: bool + code: str + reason: str + before: Any = None + after: Any = None + references: Tuple[str, ...] = field(default_factory=tuple) + failure_mode: str = "continue" + outcome: str = "continue" + + def to_dict(self) -> Dict[str, Any]: + return { + "index": self.index, + "type": self.action_type, + "success": self.success, + "code": self.code, + "reason": self.reason, + "before": _trace_value(self.before), + "after": _trace_value(self.after), + "references": list(self.references), + "failure_mode": self.failure_mode, + "outcome": self.outcome, + } + + +@dataclass(frozen=True) +class ActionExecutionResult: + state: EventActionState + steps: Tuple[ActionStepResult, ...] + outcome: str = "continue" + + @property + def context(self) -> Dict[str, Any]: + return self.state.to_dict() + + def to_dict(self) -> Dict[str, Any]: + return { + "context": self.context, + "steps": [step.to_dict() for step in self.steps], + "outcome": self.outcome, + } + + +def _trace_value(value: Any) -> Any: + if value is MISSING: + return None + if isinstance(value, str): + return value if len(value) <= 512 else value[:512] + "…" + if isinstance(value, list): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, tuple): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, dict): + return {str(key): _trace_value(item) for key, item in list(value.items())[:32]} + if isinstance(value, (int, float, bool)) or value is None: + return value + return "" + + +def _validate_json_value(value: Any, *, path: str, depth: int = 0) -> None: + if depth > MAX_ACTION_VALUE_DEPTH: + raise ActionValidationError("action value exceeds the nesting limit", path=path) + if value is None or isinstance(value, (str, int, bool)): + return + if isinstance(value, float): + if value != value or value in (float("inf"), float("-inf")): + raise ActionValidationError("action value cannot contain non-finite numbers", path=path) + return + if isinstance(value, list): + if len(value) > MAX_ACTION_COLLECTION_ITEMS: + raise ActionValidationError("action value list exceeds the item limit", path=path) + for index, child in enumerate(value): + _validate_json_value(child, path=f"{path}[{index}]", depth=depth + 1) + return + if isinstance(value, dict): + if len(value) > MAX_ACTION_COLLECTION_ITEMS: + raise ActionValidationError("action value object exceeds the item limit", path=path) + for key, child in value.items(): + if not isinstance(key, str): + raise ActionValidationError("action object keys must be strings", path=path) + _validate_json_value(child, path=f"{path}.{key}", depth=depth + 1) + return + raise ActionValidationError("action value must be JSON-compatible", path=path) + + +def _json_copy(value: Any, *, path: str) -> Any: + _validate_json_value(value, path=path) + try: + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + except (TypeError, ValueError) as exc: # defensive; validation above is explicit + raise ActionValidationError("action value must be JSON-compatible", path=path) from exc + if len(encoded.encode("utf-8")) > MAX_ACTION_VALUE_BYTES: + raise ActionValidationError("action value exceeds the size limit", path=path) + return copy.deepcopy(value) + + +def _action_value_sources(action: Mapping[str, Any]) -> List[str]: + return [key for key in ("value", "value_from", "template") if key in action] + + +def _resolve_value( + action: Mapping[str, Any], + state: EventActionState, + *, + path: str, + required: bool = True, +) -> Tuple[Any, Tuple[str, ...]]: + sources = _action_value_sources(action) + if not sources: + if required: + raise ActionValidationError("action requires value, value_from or template", path=path) + return None, () + if len(sources) != 1: + raise ActionValidationError("action value sources are mutually exclusive", path=path) + + source = sources[0] + if source == "value_from": + resolution = resolve_field(state.context(), action["value_from"]) + if not resolution.found: + raise ActionValidationError( + f"source field {resolution.normalized_reference!r} does not exist", + path=f"{path}.value_from", + ) + return _json_copy(resolution.value, path=f"{path}.value_from"), ( + resolution.normalized_reference, + ) + + value = action[source] + if source == "template" or ( + isinstance(value, str) and ("{{" in value or "}}" in value) + ): + if not isinstance(value, str): + raise ActionValidationError("template must be a string", path=f"{path}.{source}") + rendered = render_template(value, state.context()) + return rendered.value, rendered.references + return _json_copy(value, path=f"{path}.{source}"), () + + +def _scalar_text(value: Any, *, path: str, allow_empty: bool = True) -> str: + if isinstance(value, bool): + text = "true" if value else "false" + elif value is None: + text = "" + elif isinstance(value, (str, int, float)): + text = str(value) + else: + raise ActionValidationError("action value must be scalar", path=path) + if not allow_empty and not text.strip(): + raise ActionValidationError("action value cannot be empty", path=path) + return text + + +def _validate_name(value: Any, *, path: str, max_length: int) -> None: + if not isinstance(value, str) or not value: + raise ActionValidationError("name is required", path=path) + if len(value) > max_length: + raise ActionValidationError("name exceeds the size limit", path=path) + if not _NAME.fullmatch(value): + raise ActionValidationError( + "name must start with a letter or underscore and contain only letters, numbers, dot, colon, dash or underscore", + path=path, + ) + + +def _positive_id(value: Any, *, path: str) -> int: + if isinstance(value, bool): + raise ActionValidationError("reference id must be a positive integer", path=path) + try: + identifier = int(value) + except (TypeError, ValueError) as exc: + raise ActionValidationError("reference id must be a positive integer", path=path) from exc + if identifier <= 0: + raise ActionValidationError("reference id must be a positive integer", path=path) + return identifier + + +def _static_reference(action: Mapping[str, Any], keys: Sequence[str], *, path: str) -> int: + present = [key for key in keys if key in action and action[key] not in (None, "")] + params = action.get("params") + if isinstance(params, dict): + present.extend( + f"params.{key}" for key in keys if key in params and params[key] not in (None, "") + ) + if len(present) != 1: + raise ActionValidationError("action requires exactly one static reference id", path=path) + source = present[0] + if source.startswith("params."): + value = params[source.split(".", 1)[1]] + else: + value = action[source] + return _positive_id(value, path=f"{path}.{source}") + + +def _validate_value_source(action: Mapping[str, Any], *, path: str, required: bool = True) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + sources = _action_value_sources(action) + if required and not sources: + issues.append(ValidationIssue(path, "missing_action_value", "action requires value, value_from or template")) + if len(sources) > 1: + issues.append(ValidationIssue(path, "conflicting_action_value", "action value sources are mutually exclusive")) + if "value_from" in action: + try: + resolve_field({root: {} for root in ("event", "labels", "raw", "variables", "route", "service", "team", "integration", "time", "result")}, action["value_from"]) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.value_from", "invalid_field_reference", str(exc))) + for source in ("value", "template"): + value = action.get(source) + if isinstance(value, str) and (source == "template" or "{{" in value or "}}" in value): + issues.extend(validate_template(value, path=f"{path}.{source}")) + return issues + + +def validate_action(action: Any, *, path: str = "action") -> List[ValidationIssue]: + if not isinstance(action, dict): + return [ValidationIssue(path, "invalid_action", "action must be an object")] + action_type = action.get("type") + if action_type not in SUPPORTED_ACTION_TYPES: + return [ + ValidationIssue( + f"{path}.type", + "unsupported_action", + f"unsupported orchestration action {action_type!r}", + ) + ] + if action_type in EXTRACTION_TYPES: + return validate_extractor(action, path=path) + + issues: List[ValidationIssue] = [] + failure_mode = action.get("on_failure", "continue") + if failure_mode not in FAILURE_MODES: + issues.append( + ValidationIssue( + f"{path}.on_failure", + "invalid_failure_mode", + "on_failure must be continue, stop_rule or stop_orchestration", + ) + ) + + if action_type in _TEXT_FIELD_ACTIONS or action_type in {"set_event_action", "set_label", "set_custom_field", "add_note"}: + issues.extend(_validate_value_source(action, path=path)) + + if action_type == "set_event_action" and "value" in action: + if action["value"] not in EVENT_ACTIONS: + issues.append( + ValidationIssue(f"{path}.value", "invalid_event_action", "event action must be trigger or resolve") + ) + elif action_type in {"set_label", "remove_label"}: + try: + _validate_name(action.get("name", action.get("label")), path=f"{path}.name", max_length=MAX_LABEL_NAME_LENGTH) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or f"{path}.name", exc.code, str(exc))) + elif action_type in {"set_custom_field", "remove_custom_field"}: + try: + _validate_name(action.get("name", action.get("field")), path=f"{path}.name", max_length=MAX_CUSTOM_FIELD_NAME_LENGTH) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or f"{path}.name", exc.code, str(exc))) + elif action_type in _ROUTING_ACTIONS: + _, id_key = _ROUTING_ACTIONS[action_type] + try: + _static_reference(action, (id_key, "value"), path=path) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or path, exc.code, str(exc))) + elif action_type in _POLICY_ACTIONS: + id_key = _POLICY_ACTIONS[action_type] + try: + _static_reference(action, (id_key, "policy_id", "value"), path=path) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or path, exc.code, str(exc))) + elif action_type == "set_grouping": + keys = {"dedup_key", "group_key", "window_seconds", "strategy"} + if not any(key in action for key in keys): + issues.append(ValidationIssue(path, "empty_grouping_action", "set_grouping requires at least one grouping field")) + if "window_seconds" in action: + value = action["window_seconds"] + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= MAX_GROUP_WINDOW_SECONDS: + issues.append(ValidationIssue(f"{path}.window_seconds", "invalid_group_window", "grouping window must be between 0 and 86400 seconds")) + for key in ("dedup_key", "group_key", "strategy"): + value = action.get(key) + if isinstance(value, str) and ("{{" in value or "}}" in value): + issues.extend(validate_template(value, path=f"{path}.{key}")) + elif action_type == "pause": + seconds = action.get("seconds") + if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= MAX_PAUSE_SECONDS: + issues.append(ValidationIssue(f"{path}.seconds", "invalid_pause_duration", "pause duration must be between 1 and 604800 seconds")) + retrigger = action.get("retrigger", "preserve") + if retrigger not in {"preserve", "reset"}: + issues.append(ValidationIssue(f"{path}.retrigger", "invalid_pause_retrigger", "pause retrigger must be preserve or reset")) + + elif action_type == "enqueue_webhook": + action_id = action.get("action_id", action.get("webhook_action_id")) + if isinstance(action_id, bool) or not isinstance(action_id, int) or action_id <= 0: + issues.append(ValidationIssue( + f"{path}.action_id", + "invalid_webhook_action", + "enqueue_webhook requires a positive integer action_id", + )) + + if action_type in {"suppress", "pause", "drop"}: + reason = action.get("reason") + if reason is not None and not isinstance(reason, str): + issues.append(ValidationIssue(f"{path}.reason", "invalid_disposition_reason", "disposition reason must be a string")) + elif isinstance(reason, str) and ("{{" in reason or "}}" in reason): + issues.extend(validate_template(reason, path=f"{path}.reason")) + + return issues + + +def validate_action_list(actions: Any, *, path: str = "actions") -> List[ValidationIssue]: + if not isinstance(actions, list): + return [ValidationIssue(path, "invalid_actions", "actions must be a list")] + if len(actions) > MAX_ACTIONS_PER_RULE: + return [ValidationIssue(path, "action_limit", "rule has too many actions")] + issues: List[ValidationIssue] = [] + for index, action in enumerate(actions): + issues.extend(validate_action(action, path=f"{path}[{index}]")) + return issues + + +def _execute_grouping(action: Mapping[str, Any], state: EventActionState, *, path: str) -> Tuple[Any, Any, Tuple[str, ...]]: + before = copy.deepcopy(state.result.get("grouping") or {}) + grouping: MutableMapping[str, Any] = state.result.setdefault("grouping", {}) + references: List[str] = [] + + for key in ("dedup_key", "group_key", "strategy"): + if key not in action: + continue + value = action[key] + if isinstance(value, str) and ("{{" in value or "}}" in value): + rendered = render_template(value, state.context()) + value = rendered.value + references.extend(rendered.references) + value = _scalar_text(value, path=f"{path}.{key}", allow_empty=False) + grouping[key] = value + if key in {"dedup_key", "group_key"}: + state.event[key] = value + + if "window_seconds" in action: + seconds = action["window_seconds"] + if isinstance(seconds, bool) or not isinstance(seconds, int) or not 0 <= seconds <= MAX_GROUP_WINDOW_SECONDS: + raise ActionValidationError("grouping window must be between 0 and 86400 seconds", path=f"{path}.window_seconds") + grouping["window_seconds"] = seconds + + return before, copy.deepcopy(grouping), tuple(references) + + +def _disposition_reason( + action: Mapping[str, Any], + state: EventActionState, + *, + path: str, +) -> Tuple[Optional[str], Tuple[str, ...]]: + reason = action.get("reason") + if reason in (None, ""): + return None, () + references: Tuple[str, ...] = () + if "{{" in reason or "}}" in reason: + rendered = render_template(reason, state.context()) + reason = rendered.value + references = rendered.references + reason = _scalar_text(reason, path=f"{path}.reason", allow_empty=True) + if len(reason) > MAX_NOTE_LENGTH: + raise ActionValidationError( + "disposition reason exceeds the size limit", + path=f"{path}.reason", + ) + return reason or None, references + + +def _execute_action(action: Mapping[str, Any], state: EventActionState, *, path: str) -> Tuple[Any, Any, Tuple[str, ...], str]: + action_type = action["type"] + + if action_type in _TEXT_FIELD_ACTIONS: + target = _TEXT_FIELD_ACTIONS[action_type] + before = state.event.get(target) + value, references = _resolve_value(action, state, path=path) + state.event[target] = _scalar_text(value, path=f"{path}.value") + return before, state.event[target], references, "continue" + + if action_type == "set_event_action": + before = state.event.get("event_action") + value, references = _resolve_value(action, state, path=path) + normalized = _scalar_text(value, path=f"{path}.value", allow_empty=False).lower() + if normalized not in EVENT_ACTIONS: + raise ActionValidationError("event action must be trigger or resolve", path=f"{path}.value") + state.event["event_action"] = normalized + return before, normalized, references, "continue" + + if action_type == "set_label": + name = action.get("name", action.get("label")) + _validate_name(name, path=f"{path}.name", max_length=MAX_LABEL_NAME_LENGTH) + labels = state.labels + if name not in labels and len(labels) >= MAX_LABELS: + raise ActionValidationError("event label count exceeds the limit", path=f"{path}.name") + before = labels.get(name, MISSING) + value, references = _resolve_value(action, state, path=path) + labels[name] = _scalar_text(value, path=f"{path}.value") + return before, labels[name], references, "continue" + + if action_type == "remove_label": + name = action.get("name", action.get("label")) + _validate_name(name, path=f"{path}.name", max_length=MAX_LABEL_NAME_LENGTH) + before = state.labels.get(name, MISSING) + state.labels.pop(name, None) + return before, MISSING, (), "continue" + + if action_type == "set_custom_field": + name = action.get("name", action.get("field")) + _validate_name(name, path=f"{path}.name", max_length=MAX_CUSTOM_FIELD_NAME_LENGTH) + details = state.event.setdefault("custom_details", {}) + if not isinstance(details, dict): + raise ActionValidationError("event.custom_details must be an object", path="event.custom_details") + before = details.get(name, MISSING) + value, references = _resolve_value(action, state, path=path) + details[name] = _json_copy(value, path=f"{path}.value") + return before, details[name], references, "continue" + + if action_type == "remove_custom_field": + name = action.get("name", action.get("field")) + _validate_name(name, path=f"{path}.name", max_length=MAX_CUSTOM_FIELD_NAME_LENGTH) + details = state.event.setdefault("custom_details", {}) + if not isinstance(details, dict): + raise ActionValidationError("event.custom_details must be an object", path="event.custom_details") + before = details.get(name, MISSING) + details.pop(name, None) + return before, MISSING, (), "continue" + + if action_type in _ROUTING_ACTIONS: + root, id_key = _ROUTING_ACTIONS[action_type] + identifier = _static_reference(action, (id_key, "value"), path=path) + target = getattr(state, root) + before = copy.deepcopy(target) + target.clear() + target["id"] = identifier + state.result.setdefault("routing", {})[id_key] = identifier + return before, copy.deepcopy(target), (), "continue" + + if action_type in _POLICY_ACTIONS: + id_key = _POLICY_ACTIONS[action_type] + identifier = _static_reference(action, (id_key, "policy_id", "value"), path=path) + policies = state.result.setdefault("policies", {}) + before = policies.get(id_key, MISSING) + policies[id_key] = identifier + return before, identifier, (), "continue" + + if action_type == "set_grouping": + before, after, references = _execute_grouping(action, state, path=path) + return before, after, references, "continue" + + if action_type == "add_note": + before = list(state.result.setdefault("notes", [])) + value, references = _resolve_value(action, state, path=path) + note = _scalar_text(value, path=f"{path}.value", allow_empty=False) + if len(note) > MAX_NOTE_LENGTH: + raise ActionValidationError("note exceeds the size limit", path=f"{path}.value") + state.result["notes"].append(note) + return before, list(state.result["notes"]), references, "continue" + + if action_type == "enqueue_webhook": + action_id = action.get("action_id", action.get("webhook_action_id")) + if isinstance(action_id, bool) or not isinstance(action_id, int) or action_id <= 0: + raise ActionValidationError( + "enqueue_webhook requires a positive integer action_id", + path=f"{path}.action_id", + ) + webhooks = state.result.setdefault("webhooks", []) + before = list(webhooks) + request = {"action_id": action_id} + webhooks.append(request) + return before, request, (), "continue" + + if action_type == "suppress": + reason, references = _disposition_reason(action, state, path=path) + before = { + "disposition": state.result.get("disposition"), + "suppress_notifications": state.result.get("suppress_notifications"), + "suppress_reason": state.result.get("suppress_reason"), + } + state.result["disposition"] = "suppress" + state.result["suppress_notifications"] = True + state.result["suppress_reason"] = reason + return before, { + "disposition": "suppress", + "suppress_notifications": True, + "reason": reason, + }, references, "continue" + + if action_type == "pause": + seconds = action.get("seconds") + if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= MAX_PAUSE_SECONDS: + raise ActionValidationError("pause duration must be between 1 and 604800 seconds", path=f"{path}.seconds") + retrigger = action.get("retrigger", "preserve") + if retrigger not in {"preserve", "reset"}: + raise ActionValidationError("pause retrigger must be preserve or reset", path=f"{path}.retrigger") + reason, references = _disposition_reason(action, state, path=path) + before = { + "disposition": state.result.get("disposition"), + "pause_seconds": state.result.get("pause_seconds"), + "pause_retrigger": state.result.get("pause_retrigger"), + "pause_reason": state.result.get("pause_reason"), + } + state.result["disposition"] = "pause" + state.result["pause_seconds"] = seconds + state.result["pause_retrigger"] = retrigger + state.result["pause_reason"] = reason + return before, { + "disposition": "pause", + "pause_seconds": seconds, + "retrigger": retrigger, + "reason": reason, + }, references, "continue" + + if action_type == "drop": + reason, references = _disposition_reason(action, state, path=path) + before = { + "disposition": state.result.get("disposition"), + "dropped": state.result.get("dropped"), + "drop_reason": state.result.get("drop_reason"), + } + state.result["disposition"] = "drop" + state.result["dropped"] = True + state.result["suppress_notifications"] = True + state.result["drop_reason"] = reason + return before, { + "disposition": "drop", + "dropped": True, + "reason": reason, + }, references, "stop_orchestration" + + raise ActionValidationError(f"unsupported action {action_type!r}", path=f"{path}.type") + + +def execute_actions( + actions: Sequence[Mapping[str, Any]], + context: Optional[Mapping[str, Any]] = None, + *, + state: Optional[EventActionState] = None, +) -> ActionExecutionResult: + """Execute actions in order and return state plus an explainable trace.""" + + action_list = list(actions) + issues = validate_action_list(action_list) + # Static validation errors are programming/configuration errors. Runtime + # missing-field/template failures are recorded per action below. + if issues: + first = issues[0] + raise ActionValidationError(first.message, path=first.path) + if state is None: + state = EventActionState.from_context(context or {}) + + steps: List[ActionStepResult] = [] + outcome = "continue" + + for index, action in enumerate(action_list): + action_type = action["type"] + failure_mode = action.get("on_failure", "continue") + path = f"actions[{index}]" + + if action_type in EXTRACTION_TYPES: + extraction = extract_variables( + [action], + state.context(), + initial_variables=state.variables, + ) + state.variables.clear() + state.variables.update(extraction.variables) + extraction_step = extraction.steps[0] + steps.append( + ActionStepResult( + index=index, + action_type=action_type, + success=extraction_step.success, + code=extraction_step.code, + reason=extraction_step.reason, + before=None, + after=dict(extraction_step.variables), + references=(), + failure_mode=extraction_step.failure_mode, + outcome=extraction.outcome, + ) + ) + if extraction.outcome in {"stop_rule", "stop_orchestration"}: + outcome = extraction.outcome + break + continue + + try: + before, after, references, action_outcome = _execute_action(action, state, path=path) + steps.append( + ActionStepResult( + index=index, + action_type=action_type, + success=True, + code="action_applied", + reason="action applied", + before=before, + after=after, + references=references, + failure_mode=failure_mode, + outcome=action_outcome, + ) + ) + if action_outcome == "stop_orchestration": + outcome = action_outcome + break + except (OrchestrationEvaluationError, ValueError) as exc: + steps.append( + ActionStepResult( + index=index, + action_type=action_type, + success=False, + code=getattr(exc, "code", "action_failed"), + reason=str(exc), + before=None, + after=None, + references=(), + failure_mode=failure_mode, + outcome=failure_mode, + ) + ) + if failure_mode in {"stop_rule", "stop_orchestration"}: + outcome = failure_mode + break + + return ActionExecutionResult(state=state, steps=tuple(steps), outcome=outcome) + + +__all__ = [ + "ActionExecutionResult", + "ActionStepResult", + "ActionValidationError", + "EventActionState", + "SUPPORTED_ACTION_TYPES", + "execute_actions", + "validate_action", + "validate_action_list", +] diff --git a/app/services/orchestration/cache.py b/app/services/orchestration/cache.py new file mode 100644 index 0000000..4751062 --- /dev/null +++ b/app/services/orchestration/cache.py @@ -0,0 +1,32 @@ +"""Small process-local cache for immutable published definitions.""" + +from collections import OrderedDict +from copy import deepcopy +from threading import RLock + + +class PublishedDefinitionCache: + def __init__(self, max_entries=256): + self.max_entries = max(1, int(max_entries)) + self._items = OrderedDict() + self._lock = RLock() + + def get(self, version): + key = (int(version.id), str(version.definition_hash or "")) + with self._lock: + value = self._items.get(key) + if value is None: + value = deepcopy(version.definition_json or {}) + self._items[key] = value + while len(self._items) > self.max_entries: + self._items.popitem(last=False) + else: + self._items.move_to_end(key) + return deepcopy(value) + + def clear(self): + with self._lock: + self._items.clear() + + +published_definition_cache = PublishedDefinitionCache() diff --git a/app/services/orchestration/conditions.py b/app/services/orchestration/conditions.py new file mode 100644 index 0000000..48f4691 --- /dev/null +++ b/app/services/orchestration/conditions.py @@ -0,0 +1,440 @@ +"""Deterministic nested condition-tree evaluation.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field as dataclass_field +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .errors import ConditionValidationError, RegexSafetyError, ValidationIssue +from .fields import MISSING, resolve_field, validate_field_reference +from .limits import MAX_CONDITION_DEPTH, MAX_CONDITION_NODES, MAX_TRACE_VALUE_LENGTH +from .regex import bounded_regex_input, compile_safe_regex, validate_regex_pattern + + +LOGICAL_KEYS = frozenset({"all", "any", "none"}) +OPERATOR_ALIASES = { + "eq": "equals", + "ne": "not_equals", + "gt": "greater_than", + "lt": "less_than", + "gte": "greater_or_equal", + "lte": "less_or_equal", +} +SUPPORTED_OPERATORS = frozenset( + { + "equals", + "not_equals", + "contains", + "not_contains", + "starts_with", + "ends_with", + "regex", + "not_regex", + "in", + "not_in", + "exists", + "not_exists", + "greater_than", + "less_than", + "greater_or_equal", + "less_or_equal", + "is_true", + "is_false", + } +) + + +@dataclass(frozen=True) +class ConditionResult: + matched: bool + code: str + reason: str + path: str = "$" + node_type: str = "condition" + field: Optional[str] = None + operator: Optional[str] = None + expected: Any = MISSING + actual: Any = None + found: Optional[bool] = None + children: Tuple["ConditionResult", ...] = dataclass_field(default_factory=tuple) + + def to_dict(self) -> Dict[str, Any]: + result: Dict[str, Any] = { + "matched": self.matched, + "code": self.code, + "reason": self.reason, + "path": self.path, + "node_type": self.node_type, + } + if self.field is not None: + result["field"] = self.field + if self.operator is not None: + result["operator"] = self.operator + if self.found is not None: + result["found"] = self.found + if self.expected is not MISSING: + result["expected"] = _trace_value(self.expected) + if self.actual is not None or self.found is True: + result["actual"] = _trace_value(self.actual) + if self.children: + result["children"] = [child.to_dict() for child in self.children] + return result + + +def _trace_value(value: Any) -> Any: + if value is MISSING: + return None + if isinstance(value, str) and len(value) > MAX_TRACE_VALUE_LENGTH: + return value[:MAX_TRACE_VALUE_LENGTH] + "…" + if isinstance(value, list): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, tuple): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, dict): + items = list(value.items())[:32] + return {str(key): _trace_value(item) for key, item in items} + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return "" + + +def normalize_operator(operator: Any) -> str: + if not isinstance(operator, str): + raise ConditionValidationError("condition operator must be a string") + normalized = OPERATOR_ALIASES.get(operator.strip(), operator.strip()) + if normalized not in SUPPORTED_OPERATORS: + raise ConditionValidationError(f"unsupported condition operator {operator!r}") + return normalized + + +def _decimal(value: Any) -> Decimal: + if isinstance(value, bool) or value is None: + raise InvalidOperation + if isinstance(value, Decimal): + result = value + elif isinstance(value, int): + result = Decimal(value) + elif isinstance(value, float): + if not math.isfinite(value): + raise InvalidOperation + result = Decimal(str(value)) + elif isinstance(value, str): + text = value.strip() + if not text: + raise InvalidOperation + result = Decimal(text) + else: + raise InvalidOperation + if not result.is_finite(): + raise InvalidOperation + return result + + +def _coerce_boolean(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + raise ValueError("value is not deterministically boolean") + + +def _equal(left: Any, right: Any) -> bool: + if left is MISSING or right is MISSING: + return False + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if isinstance(left, (int, float, Decimal)) or isinstance( + right, (int, float, Decimal) + ): + try: + return _decimal(left) == _decimal(right) + except InvalidOperation: + return False + if left is None or right is None: + return left is right + return type(left) is type(right) and left == right + + +def _contains(container: Any, expected: Any) -> bool: + if isinstance(container, str): + return isinstance(expected, str) and expected in container + if isinstance(container, Mapping): + return expected in container + if isinstance(container, Sequence) and not isinstance( + container, (str, bytes, bytearray) + ): + return any(_equal(item, expected) for item in container) + return False + + +def _in(actual: Any, expected: Any) -> bool: + if isinstance(expected, Mapping): + return actual in expected + if isinstance(expected, Sequence) and not isinstance( + expected, (str, bytes, bytearray) + ): + return any(_equal(actual, item) for item in expected) + return False + + +def _evaluate_operator(operator: str, actual: Any, expected: Any, found: bool) -> Tuple[bool, str]: + if operator == "exists": + return found, "field_exists" if found else "field_missing" + if operator == "not_exists": + return not found, "field_missing" if not found else "field_exists" + if not found: + return False, "field_missing" + + if operator == "equals": + matched = _equal(actual, expected) + elif operator == "not_equals": + matched = not _equal(actual, expected) + elif operator == "contains": + matched = _contains(actual, expected) + elif operator == "not_contains": + matched = not _contains(actual, expected) + elif operator == "starts_with": + matched = isinstance(actual, str) and isinstance(expected, str) and actual.startswith(expected) + elif operator == "ends_with": + matched = isinstance(actual, str) and isinstance(expected, str) and actual.endswith(expected) + elif operator in {"regex", "not_regex"}: + regex = compile_safe_regex(expected) + matched = regex.search(bounded_regex_input(actual)) is not None + if operator == "not_regex": + matched = not matched + elif operator == "in": + matched = _in(actual, expected) + elif operator == "not_in": + matched = not _in(actual, expected) + elif operator in { + "greater_than", + "less_than", + "greater_or_equal", + "less_or_equal", + }: + try: + left = _decimal(actual) + right = _decimal(expected) + except InvalidOperation: + return False, "numeric_coercion_failed" + matched = { + "greater_than": left > right, + "less_than": left < right, + "greater_or_equal": left >= right, + "less_or_equal": left <= right, + }[operator] + elif operator in {"is_true", "is_false"}: + try: + boolean = _coerce_boolean(actual) + except ValueError: + return False, "boolean_coercion_failed" + matched = boolean if operator == "is_true" else not boolean + else: # pragma: no cover - protected by validation + raise ConditionValidationError(f"unsupported operator {operator}") + + return matched, "condition_matched" if matched else "condition_mismatched" + + +def validate_condition_tree(tree: Any, *, path: str = "condition_tree") -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + node_count = 0 + + def visit(node: Any, node_path: str, depth: int) -> None: + nonlocal node_count + node_count += 1 + if node_count > MAX_CONDITION_NODES: + issues.append( + ValidationIssue(node_path, "condition_node_limit", "condition tree has too many nodes") + ) + return + if depth > MAX_CONDITION_DEPTH: + issues.append( + ValidationIssue(node_path, "condition_depth_limit", "condition tree is nested too deeply") + ) + return + if not isinstance(node, dict): + issues.append( + ValidationIssue(node_path, "invalid_condition_node", "condition node must be an object") + ) + return + if not node: + return # Explicit catch-all rule. + + logical = [key for key in LOGICAL_KEYS if key in node] + if logical: + if len(logical) != 1 or len(node) != 1: + issues.append( + ValidationIssue( + node_path, + "ambiguous_condition_node", + "logical condition nodes must contain exactly one of all, any or none", + ) + ) + return + key = logical[0] + children = node[key] + if not isinstance(children, list): + issues.append( + ValidationIssue( + f"{node_path}.{key}", + "invalid_condition_children", + f"{key} must contain a list", + ) + ) + return + for index, child in enumerate(children): + visit(child, f"{node_path}.{key}[{index}]", depth + 1) + return + + allowed = {"field", "operator", "value"} + unknown = sorted(set(node) - allowed) + if unknown: + issues.append( + ValidationIssue( + node_path, + "unknown_condition_keys", + "unknown condition keys: " + ", ".join(unknown), + ) + ) + if "field" not in node: + issues.append(ValidationIssue(node_path, "missing_field", "condition field is required")) + else: + issues.extend(validate_field_reference(node["field"], path=f"{node_path}.field")) + if "operator" not in node: + issues.append( + ValidationIssue(node_path, "missing_operator", "condition operator is required") + ) + return + try: + operator = normalize_operator(node["operator"]) + except ConditionValidationError as exc: + issues.append(ValidationIssue(f"{node_path}.operator", exc.code, str(exc))) + return + + no_value = {"exists", "not_exists", "is_true", "is_false"} + if operator not in no_value and "value" not in node: + issues.append( + ValidationIssue(node_path, "missing_condition_value", f"operator {operator} requires value") + ) + if operator in {"regex", "not_regex"} and "value" in node: + try: + validate_regex_pattern(node["value"]) + except RegexSafetyError as exc: + issues.append(ValidationIssue(f"{node_path}.value", exc.code, str(exc))) + if operator in {"in", "not_in"} and "value" in node: + value = node["value"] + if not isinstance(value, (list, tuple, dict)): + issues.append( + ValidationIssue( + f"{node_path}.value", + "invalid_collection", + f"operator {operator} requires a list or object value", + ) + ) + if operator in { + "greater_than", + "less_than", + "greater_or_equal", + "less_or_equal", + } and "value" in node: + try: + _decimal(node["value"]) + except InvalidOperation: + issues.append( + ValidationIssue( + f"{node_path}.value", + "invalid_numeric_value", + f"operator {operator} requires a finite numeric value", + ) + ) + + visit(tree, path, 0) + # Avoid emitting the same global limit many times. + deduped: List[ValidationIssue] = [] + seen = set() + for issue in issues: + key = (issue.path, issue.code, issue.message) + if key not in seen: + seen.add(key) + deduped.append(issue) + return deduped + + +def evaluate_condition_tree( + tree: Mapping[str, Any], + context: Mapping[str, Any], + *, + validate: bool = True, + path: str = "$", +) -> ConditionResult: + """Evaluate every node so Explain traces contain matches and mismatches.""" + + if validate: + issues = validate_condition_tree(tree) + if issues: + first = issues[0] + raise ConditionValidationError(first.message, path=first.path) + + def visit(node: Mapping[str, Any], node_path: str) -> ConditionResult: + if not node: + return ConditionResult(True, "catch_all", "empty condition matches all events", path=node_path, node_type="all") + + logical = next((key for key in ("all", "any", "none") if key in node), None) + if logical is not None: + children = tuple( + visit(child, f"{node_path}.{logical}[{index}]") + for index, child in enumerate(node[logical]) + ) + child_matches = [child.matched for child in children] + if logical == "all": + matched = all(child_matches) + elif logical == "any": + matched = any(child_matches) + else: + matched = not any(child_matches) + return ConditionResult( + matched=matched, + code=f"{logical}_{'matched' if matched else 'mismatched'}", + reason=f"{logical} group {'matched' if matched else 'did not match'}", + path=node_path, + node_type=logical, + children=children, + ) + + field_reference = node["field"] + operator = normalize_operator(node["operator"]) + expected = node["value"] if "value" in node else MISSING + resolution = resolve_field(context, field_reference) + try: + matched, code = _evaluate_operator( + operator, + resolution.value, + expected, + resolution.found, + ) + reason = code.replace("_", " ") + except RegexSafetyError as exc: + matched = False + code = exc.code + reason = str(exc) + return ConditionResult( + matched=matched, + code=code, + reason=reason, + path=node_path, + field=resolution.normalized_reference, + operator=operator, + expected=expected, + actual=None if not resolution.found else resolution.value, + found=resolution.found, + ) + + return visit(tree, path) diff --git a/app/services/orchestration/engine.py b/app/services/orchestration/engine.py new file mode 100644 index 0000000..a958729 --- /dev/null +++ b/app/services/orchestration/engine.py @@ -0,0 +1,288 @@ +"""Deterministic ordered rule-tree execution for Event Orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from .actions import ActionStepResult, EventActionState, execute_actions, validate_action_list +from .conditions import ConditionResult, evaluate_condition_tree, validate_condition_tree +from .errors import ConditionValidationError + + +MAX_RULE_DEPTH = 20 +MAX_RULE_NODES = 512 +VALID_PROCESSING_MODES = frozenset( + {"continue", "stop", "evaluate_children", "children_then_continue"} +) + + +@dataclass(frozen=True) +class RuleExecutionResult: + path: str + name: str + enabled: bool + matched: bool + processing_mode: str + condition: Optional[ConditionResult] = None + actions: Tuple[ActionStepResult, ...] = field(default_factory=tuple) + children: Tuple["RuleExecutionResult", ...] = field(default_factory=tuple) + outcome: str = "continue" + code: str = "rule_evaluated" + reason: str = "rule evaluated" + + def to_dict(self) -> Dict[str, Any]: + result: Dict[str, Any] = { + "path": self.path, + "name": self.name, + "enabled": self.enabled, + "matched": self.matched, + "processing_mode": self.processing_mode, + "actions": [step.to_dict() for step in self.actions], + "children": [child.to_dict() for child in self.children], + "outcome": self.outcome, + "code": self.code, + "reason": self.reason, + } + if self.condition is not None: + result["condition"] = self.condition.to_dict() + return result + + +@dataclass(frozen=True) +class OrchestrationExecutionResult: + state: EventActionState + rules: Tuple[RuleExecutionResult, ...] + outcome: str + matched_rule_count: int + stopped_at: Optional[str] = None + + @property + def context(self) -> Dict[str, Any]: + return self.state.to_dict() + + def to_dict(self) -> Dict[str, Any]: + return { + "context": self.context, + "rules": [rule.to_dict() for rule in self.rules], + "outcome": self.outcome, + "matched_rule_count": self.matched_rule_count, + "stopped_at": self.stopped_at, + } + + +@dataclass +class _EngineCounters: + nodes: int = 0 + matched: int = 0 + stopped_at: Optional[str] = None + + +def _validate_rules(rules: Any, *, path: str = "rules", depth: int = 0, counters: Optional[_EngineCounters] = None) -> None: + if counters is None: + counters = _EngineCounters() + if depth > MAX_RULE_DEPTH: + raise ConditionValidationError("rule tree exceeds the depth limit", path=path) + if not isinstance(rules, list): + raise ConditionValidationError("rules must be a list", path=path) + for index, rule in enumerate(rules): + rule_path = f"{path}[{index}]" + counters.nodes += 1 + if counters.nodes > MAX_RULE_NODES: + raise ConditionValidationError("rule tree exceeds the node limit", path=rule_path) + if not isinstance(rule, dict): + raise ConditionValidationError("rule must be an object", path=rule_path) + mode = rule.get("processing_mode", "continue") + if mode not in VALID_PROCESSING_MODES: + raise ConditionValidationError("invalid rule processing_mode", path=f"{rule_path}.processing_mode") + condition_issues = validate_condition_tree( + rule.get("condition_tree") or {}, + path=f"{rule_path}.condition_tree", + ) + if condition_issues: + first = condition_issues[0] + raise ConditionValidationError(first.message, path=first.path) + actions = rule.get("actions", []) + if not isinstance(actions, list): + raise ConditionValidationError("rule actions must be a list", path=f"{rule_path}.actions") + action_issues = validate_action_list(actions, path=f"{rule_path}.actions") + if action_issues: + first = action_issues[0] + raise ConditionValidationError(first.message, path=first.path) + children = rule.get("children", []) + if not isinstance(children, list): + raise ConditionValidationError("rule children must be a list", path=f"{rule_path}.children") + _validate_rules(children, path=f"{rule_path}.children", depth=depth + 1, counters=counters) + + +def _execute_level( + rules: Sequence[Mapping[str, Any]], + state: EventActionState, + *, + path: str, + depth: int, + counters: _EngineCounters, +) -> Tuple[List[RuleExecutionResult], str]: + traces: List[RuleExecutionResult] = [] + + for index, rule in enumerate(rules): + rule_path = f"{path}[{index}]" + name = str(rule.get("name") or f"Rule {index + 1}") + enabled = bool(rule.get("enabled", True)) + mode = rule.get("processing_mode", "continue") + + if not enabled: + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=False, + matched=False, + processing_mode=mode, + code="rule_disabled", + reason="rule is disabled", + ) + ) + continue + + condition = evaluate_condition_tree(rule.get("condition_tree") or {}, state.context()) + if not condition.matched: + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=False, + processing_mode=mode, + condition=condition, + code="rule_not_matched", + reason="rule condition did not match", + ) + ) + continue + + counters.matched += 1 + action_result = execute_actions(rule.get("actions") or [], state=state) + child_traces: List[RuleExecutionResult] = [] + local_outcome = action_result.outcome + + if local_outcome == "stop_orchestration": + counters.stopped_at = rule_path + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=True, + processing_mode=mode, + condition=condition, + actions=action_result.steps, + outcome="stop_orchestration", + code="rule_stopped_orchestration", + reason="an action stopped orchestration processing", + ) + ) + return traces, "stop_orchestration" + + children = rule.get("children") or [] + if mode in {"evaluate_children", "children_then_continue"} and children: + child_traces, child_outcome = _execute_level( + children, + state, + path=f"{rule_path}.children", + depth=depth + 1, + counters=counters, + ) + if child_outcome == "stop_orchestration": + counters.stopped_at = counters.stopped_at or rule_path + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=True, + processing_mode=mode, + condition=condition, + actions=action_result.steps, + children=tuple(child_traces), + outcome="stop_orchestration", + code="child_stopped_orchestration", + reason="a child rule stopped orchestration processing", + ) + ) + return traces, "stop_orchestration" + + if mode == "stop": + counters.stopped_at = rule_path + rule_outcome = "stop_orchestration" + code = "processing_mode_stop" + reason = "rule processing_mode stopped orchestration processing" + elif mode == "evaluate_children": + counters.stopped_at = rule_path + rule_outcome = "stop_orchestration" + code = "children_evaluated_then_stop" + reason = "child rules were evaluated and sibling processing stopped" + else: + rule_outcome = local_outcome + code = "rule_matched" + reason = "rule matched and actions were evaluated" + + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=True, + processing_mode=mode, + condition=condition, + actions=action_result.steps, + children=tuple(child_traces), + outcome=rule_outcome, + code=code, + reason=reason, + ) + ) + + if rule_outcome == "stop_orchestration": + return traces, "stop_orchestration" + + return traces, "continue" + + +def execute_rule_tree( + rules: Sequence[Mapping[str, Any]], + context: Mapping[str, Any], +) -> OrchestrationExecutionResult: + """Evaluate a published rule definition against an isolated event copy.""" + + rule_list = list(rules) + _validate_rules(rule_list) + state = EventActionState.from_context(context) + counters = _EngineCounters() + traces, outcome = _execute_level( + rule_list, + state, + path="rules", + depth=0, + counters=counters, + ) + if state.result.get("dropped"): + outcome = "drop" + elif outcome == "stop_orchestration": + outcome = "stop" + else: + outcome = "continue" + return OrchestrationExecutionResult( + state=state, + rules=tuple(traces), + outcome=outcome, + matched_rule_count=counters.matched, + stopped_at=counters.stopped_at, + ) + + +__all__ = [ + "OrchestrationExecutionResult", + "RuleExecutionResult", + "execute_rule_tree", +] diff --git a/app/services/orchestration/errors.py b/app/services/orchestration/errors.py new file mode 100644 index 0000000..bd72805 --- /dev/null +++ b/app/services/orchestration/errors.py @@ -0,0 +1,61 @@ +"""Safe error types for the Event Orchestration evaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass(frozen=True) +class ValidationIssue: + """A structured validation problem suitable for API and UI responses.""" + + path: str + code: str + message: str + severity: str = "error" + + def to_dict(self) -> Dict[str, str]: + return { + "path": self.path, + "code": self.code, + "message": self.message, + "severity": self.severity, + } + + +class OrchestrationEvaluationError(ValueError): + """Base class for deterministic evaluator failures.""" + + code = "evaluation_error" + + def __init__(self, message: str, *, path: Optional[str] = None): + self.path = path + super().__init__(message) + + def to_dict(self) -> Dict[str, Any]: + return { + "code": self.code, + "message": str(self), + "path": self.path, + } + + +class FieldResolutionError(OrchestrationEvaluationError): + code = "invalid_field_reference" + + +class ConditionValidationError(OrchestrationEvaluationError): + code = "invalid_condition" + + +class RegexSafetyError(OrchestrationEvaluationError): + code = "unsafe_regex" + + +class TemplateValidationError(OrchestrationEvaluationError): + code = "invalid_template" + + +class ExtractionError(OrchestrationEvaluationError): + code = "variable_extraction_failed" diff --git a/app/services/orchestration/evaluator.py b/app/services/orchestration/evaluator.py new file mode 100644 index 0000000..e7ea848 --- /dev/null +++ b/app/services/orchestration/evaluator.py @@ -0,0 +1,81 @@ +"""High-level evaluator facade used by simulation and the future action engine.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Sequence, Tuple + +from .conditions import ConditionResult, evaluate_condition_tree +from .fields import build_context +from .templates import TemplateRenderResult, render_template +from .variables import ExtractionResult, extract_variables + + +@dataclass(frozen=True) +class RuleEvaluationResult: + matched: bool + condition: ConditionResult + variables: Mapping[str, Any] + extraction_steps: Tuple[Mapping[str, Any], ...] + extraction_outcome: str + + def to_dict(self) -> Dict[str, Any]: + return { + "matched": self.matched, + "condition": self.condition.to_dict(), + "variables": dict(self.variables), + "extraction_steps": list(self.extraction_steps), + "extraction_outcome": self.extraction_outcome, + } + + +def evaluate_rule( + *, + condition_tree: Mapping[str, Any], + context: Mapping[str, Any], + extractors: Sequence[Mapping[str, Any]] = (), +) -> RuleEvaluationResult: + condition = evaluate_condition_tree(condition_tree, context) + if not condition.matched or not extractors: + return RuleEvaluationResult( + condition.matched, + condition, + dict(context.get("variables") or {}), + (), + "continue", + ) + + extraction = extract_variables(extractors, context) + return RuleEvaluationResult( + condition.matched, + condition, + extraction.variables, + tuple(step.to_dict() for step in extraction.steps), + extraction.outcome, + ) + + +__all__ = [ + "ConditionResult", + "ExtractionResult", + "RuleEvaluationResult", + "TemplateRenderResult", + "build_context", + "evaluate_condition_tree", + "evaluate_rule", + "extract_variables", + "render_template", +] + +# BEGIN EVENT ORCHESTRATION WS3 EXPORTS +from .actions import ActionExecutionResult, EventActionState, execute_actions +from .engine import OrchestrationExecutionResult, execute_rule_tree + +__all__ += [ + "ActionExecutionResult", + "EventActionState", + "OrchestrationExecutionResult", + "execute_actions", + "execute_rule_tree", +] +# END EVENT ORCHESTRATION WS3 EXPORTS diff --git a/app/services/orchestration/fields.py b/app/services/orchestration/fields.py new file mode 100644 index 0000000..1150501 --- /dev/null +++ b/app/services/orchestration/fields.py @@ -0,0 +1,183 @@ +"""Restricted field resolution for orchestration conditions and templates. + +The resolver only traverses mappings and sequences. It never performs Python +attribute access, method calls or descriptor evaluation. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, Mapping, Sequence, Tuple + +from .errors import FieldResolutionError, ValidationIssue +from .limits import MAX_FIELD_REFERENCE_LENGTH + + +ALLOWED_ROOTS = frozenset( + { + "event", + "labels", + "raw", + "variables", + "route", + "service", + "team", + "integration", + "time", + "result", + } +) + +# Existing IncidentRelay rules commonly use bare normalized-event fields such +# as "severity". Keep that notation deterministic by resolving it under event. +_BARE_EVENT_FIELD = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") +_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") +_INDEX = re.compile(r"^(0|[1-9][0-9]*)$") + + +class _Missing: + def __repr__(self) -> str: + return "MISSING" + + +MISSING = _Missing() + + +@dataclass(frozen=True) +class FieldResolution: + reference: str + found: bool + value: Any = MISSING + normalized_reference: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "reference": self.reference, + "normalized_reference": self.normalized_reference, + "found": self.found, + "value": None if not self.found else self.value, + } + + +def normalize_field_reference(reference: str) -> str: + if not isinstance(reference, str): + raise FieldResolutionError("field reference must be a string") + reference = reference.strip() + if not reference: + raise FieldResolutionError("field reference cannot be empty") + if len(reference) > MAX_FIELD_REFERENCE_LENGTH: + raise FieldResolutionError("field reference exceeds the size limit") + if reference.startswith(".") or reference.endswith(".") or ".." in reference: + raise FieldResolutionError("field reference contains an empty segment") + + parts = reference.split(".") + if parts[0] not in ALLOWED_ROOTS: + if len(parts) == 1 and _BARE_EVENT_FIELD.fullmatch(parts[0]): + parts.insert(0, "event") + else: + raise FieldResolutionError( + "field reference must start with one of: " + + ", ".join(sorted(ALLOWED_ROOTS)) + ) + + for index, part in enumerate(parts): + if not part: + raise FieldResolutionError("field reference contains an empty segment") + if index == 0: + if part not in ALLOWED_ROOTS: + raise FieldResolutionError("unsupported field root") + continue + if part.startswith("__") and part.endswith("__"): + raise FieldResolutionError("dunder field path segments are not allowed") + if not (_SEGMENT.fullmatch(part) or _INDEX.fullmatch(part)): + raise FieldResolutionError( + f"invalid field path segment {part!r}; only names and list indexes are allowed" + ) + return ".".join(parts) + + +def validate_field_reference(reference: Any, *, path: str = "field") -> Iterable[ValidationIssue]: + try: + normalize_field_reference(reference) + except FieldResolutionError as exc: + yield ValidationIssue(path, exc.code, str(exc)) + + +def _mapping_get(value: Mapping[str, Any], segment: str) -> Tuple[bool, Any]: + if segment in value: + return True, value[segment] + return False, MISSING + + +def _sequence_get(value: Sequence[Any], segment: str) -> Tuple[bool, Any]: + if not _INDEX.fullmatch(segment): + return False, MISSING + index = int(segment) + if index >= len(value): + return False, MISSING + return True, value[index] + + +def resolve_field(context: Mapping[str, Any], reference: str) -> FieldResolution: + """Resolve a safe dotted reference against an orchestration context.""" + + normalized = normalize_field_reference(reference) + parts = normalized.split(".") + current: Any = context + + for segment in parts: + if isinstance(current, Mapping): + found, current = _mapping_get(current, segment) + elif isinstance(current, Sequence) and not isinstance( + current, (str, bytes, bytearray) + ): + found, current = _sequence_get(current, segment) + else: + found, current = False, MISSING + if not found: + return FieldResolution( + reference=reference, + normalized_reference=normalized, + found=False, + ) + + return FieldResolution( + reference=reference, + normalized_reference=normalized, + found=True, + value=current, + ) + + +def build_context( + *, + event: Mapping[str, Any] | None = None, + labels: Mapping[str, Any] | None = None, + raw: Mapping[str, Any] | None = None, + variables: Mapping[str, Any] | None = None, + route: Mapping[str, Any] | None = None, + service: Mapping[str, Any] | None = None, + team: Mapping[str, Any] | None = None, + integration: Mapping[str, Any] | None = None, + time: Mapping[str, Any] | None = None, + result: Mapping[str, Any] | None = None, +) -> Dict[str, Any]: + """Build a complete evaluator context without mutating caller mappings.""" + + event_copy = dict(event or {}) + label_source = labels if labels is not None else event_copy.get("labels") + labels_copy = dict(label_source or {}) + event_copy["labels"] = labels_copy + return { + "event": event_copy, + "labels": labels_copy, + "raw": dict(raw or {}), + "variables": dict(variables or {}), + "route": dict(route or {}), + "service": dict(service or {}), + "team": dict(team or {}), + "integration": dict(integration or {}), + "time": dict(time or {}), + "result": dict(result or {}), + } diff --git a/app/services/orchestration/limits.py b/app/services/orchestration/limits.py new file mode 100644 index 0000000..ea3095c --- /dev/null +++ b/app/services/orchestration/limits.py @@ -0,0 +1,18 @@ +"""Central limits for deterministic and bounded orchestration evaluation.""" + +MAX_CONDITION_DEPTH = 20 +MAX_CONDITION_NODES = 256 +MAX_FIELD_REFERENCE_LENGTH = 256 +MAX_REGEX_PATTERN_LENGTH = 512 +MAX_REGEX_INPUT_LENGTH = 16_384 +MAX_TEMPLATE_LENGTH = 8_192 +MAX_TEMPLATE_EXPRESSIONS = 64 +MAX_TEMPLATE_EXPRESSION_LENGTH = 512 +MAX_TEMPLATE_OUTPUT_LENGTH = 16_384 +MAX_TEMPLATE_FILTERS = 8 +MAX_VARIABLES = 128 +MAX_VARIABLE_NAME_LENGTH = 64 +MAX_VARIABLE_VALUE_LENGTH = 16_384 +MAX_JSON_PATH_LENGTH = 512 +MAX_SPLIT_PARTS = 256 +MAX_TRACE_VALUE_LENGTH = 512 diff --git a/app/services/orchestration/metrics.py b/app/services/orchestration/metrics.py new file mode 100644 index 0000000..85243ed --- /dev/null +++ b/app/services/orchestration/metrics.py @@ -0,0 +1,96 @@ +"""Operational disposition metrics for Event Orchestration.""" + +from __future__ import annotations + +from typing import Optional + +from peewee import fn + +from app.modules.db.models import ( + AutomationExecution, + OrchestrationExecution, + PendingOrchestratedEvent, +) +from app.settings import Config + + +DISPOSITIONS = ("process", "suppress", "pause", "drop") +WEBHOOK_STATUSES = ("pending", "running", "succeeded", "failed", "cancelled") +PENDING_STATUSES = ( + "pending", + "activating", + "failed", + "activated", + "resolved", + "cancelled", +) + + +def _count_by(query, model, field, known_values): + result = {value: 0 for value in known_values} + for row in query.select(field, fn.COUNT(model.id).alias("row_count")).group_by(field): + value = getattr(row, field.name, None) + if value is None: + value = "unknown" + result[value] = int(getattr(row, "row_count", 0) or 0) + return result + + +def get_orchestration_disposition_metrics( + *, + group_id: Optional[int] = None, + since=None, + until=None, +): + """Return portable DB-backed counts for disposition and pause states.""" + executions = OrchestrationExecution.select() + pending = PendingOrchestratedEvent.select() + webhooks = AutomationExecution.select() + + if group_id is not None: + executions = executions.where(OrchestrationExecution.group == group_id) + pending = pending.where(PendingOrchestratedEvent.group == group_id) + webhooks = webhooks.where(AutomationExecution.group == group_id) + if since is not None: + executions = executions.where(OrchestrationExecution.created_at >= since) + pending = pending.where(PendingOrchestratedEvent.created_at >= since) + webhooks = webhooks.where(AutomationExecution.created_at >= since) + if until is not None: + executions = executions.where(OrchestrationExecution.created_at < until) + pending = pending.where(PendingOrchestratedEvent.created_at < until) + webhooks = webhooks.where(AutomationExecution.created_at < until) + + dispositions = _count_by( + executions, + OrchestrationExecution, + OrchestrationExecution.disposition, + DISPOSITIONS, + ) + pending_statuses = _count_by( + pending, + PendingOrchestratedEvent, + PendingOrchestratedEvent.status, + PENDING_STATUSES, + ) + + webhook_statuses = _count_by( + webhooks, + AutomationExecution, + AutomationExecution.status, + WEBHOOK_STATUSES, + ) + + return { + "executions_total": sum(dispositions.values()), + "dispositions": dispositions, + "pending_events_total": sum(pending_statuses.values()), + "pending_statuses": pending_statuses, + "webhook_executions_total": sum(webhook_statuses.values()), + "webhook_statuses": webhook_statuses, + "dropped_trace_retention_days": int( + getattr(Config, "ORCHESTRATION_DROPPED_TRACE_RETENTION_DAYS", 7) + ), + } + + +__all__ = ["get_orchestration_disposition_metrics"] diff --git a/app/services/orchestration/pending.py b/app/services/orchestration/pending.py new file mode 100644 index 0000000..4c4eefd --- /dev/null +++ b/app/services/orchestration/pending.py @@ -0,0 +1,551 @@ +"""Persistence and activation workflow for paused orchestration events.""" + +from __future__ import annotations + +import copy +import hashlib +import logging +import uuid +from datetime import timedelta +from typing import Any, Dict, Mapping, Optional + +from peewee import IntegrityError + +from app.db import database_proxy as db +from app.modules.common import utc_now +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationVersion, + OrchestrationExecution, + PendingOrchestratedEvent, +) +from app.modules.redaction import redact_secrets +from app.services.alerts.explain import AlertExplainTrace +from app.services.alerts.result import AlertProcessingResult +from app.services.orchestration.runtime import ( + RuntimeResult, + attach_runtime_executions, + restore_runtime_result, +) +from app.services.validation import make_json_safe +from app.services.orchestration.webhooks import cleanup_webhook_executions +from app.settings import Config + +logger = logging.getLogger("oncall.orchestration.pending") + +TERMINAL_STATUSES = frozenset({"activated", "resolved", "cancelled"}) + + +def pending_active_key(group_id: int, source: str, dedup_key: str) -> str: + material = f"{int(group_id)}\x1f{source or ''}\x1f{dedup_key or ''}" + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def _event_for_storage(alert_data: Mapping[str, Any]) -> Dict[str, Any]: + value = copy.deepcopy(dict(alert_data or {})) + value.pop("raw", None) + for key in tuple(value): + if str(key).startswith("_orchestration_"): + value.pop(key, None) + return make_json_safe(value) + + +def _terminal_event_snapshot(value: Mapping[str, Any]) -> Dict[str, Any]: + event = dict(value or {}) + return { + key: make_json_safe(event.get(key)) + for key in ( + "source", + "external_id", + "dedup_key", + "group_key", + "title", + "severity", + "status", + "labels", + ) + if key in event + } + + +def _runtime_context(runtime: RuntimeResult, previous=None) -> Dict[str, Any]: + previous = dict(previous or {}) + updates = list(previous.get("updates") or [])[-49:] + updates.append( + { + "at": utc_now().isoformat(), + "execution_ids": list(runtime.execution_ids), + "disposition": runtime.disposition, + } + ) + return redact_secrets( + make_json_safe( + { + "runtime": runtime.to_dict(), + "updates": updates, + } + ) + ) + + +def _source_models(runtime: RuntimeResult): + orchestration_id = runtime.disposition_orchestration_id + version_id = runtime.disposition_version_id + if not orchestration_id or not version_id: + raise ValueError("pause disposition is missing orchestration provenance") + orchestration = EventOrchestration.get_by_id(orchestration_id) + version = EventOrchestrationVersion.get_by_id(version_id) + return orchestration, version + + +def store_paused_event( + alert_data: Mapping[str, Any], + runtime: RuntimeResult, + *, + route=None, + service=None, + trace=None, + now=None, +) -> PendingOrchestratedEvent: + """Create or refresh the one active paused row for an event fingerprint.""" + now = now or utc_now() + group_id = runtime.group_id + if group_id is None and route is not None: + group_id = getattr(getattr(route, "team", None), "group_id", None) + if group_id is None: + raise ValueError("paused event requires a group") + + source = str(alert_data.get("source") or "") + dedup_key = str(alert_data.get("dedup_key") or "") + if not source or not dedup_key: + raise ValueError("paused event requires source and dedup_key") + + seconds = int(runtime.pause_seconds or 0) + if seconds <= 0: + raise ValueError("paused event requires a positive pause duration") + + orchestration, version = _source_models(runtime) + key = pending_active_key(group_id, source, dedup_key) + requested_activation_at = now + timedelta(seconds=seconds) + stored_event = _event_for_storage(alert_data) + route_id = getattr(route or runtime.route, "id", None) + service_id = getattr(service or runtime.service, "id", None) + integration_name = alert_data.get("integration_name") or source + + row = None + for _attempt in range(4): + with db.atomic(): + current = PendingOrchestratedEvent.get_or_none( + PendingOrchestratedEvent.active_key == key + ) + + if current is None: + try: + # The nested atomic block is a savepoint on PostgreSQL, so a + # concurrent unique-key insert does not poison the outer transaction. + with db.atomic(): + row = PendingOrchestratedEvent.create( + group=group_id, + orchestration=orchestration.id, + version=version.id, + route=route_id, + service=service_id, + source=source, + integration_name=integration_name, + dedup_key=dedup_key, + active_key=key, + normalized_event_json=stored_event, + context_json=_runtime_context(runtime), + activation_at=requested_activation_at, + status="pending", + created_at=now, + updated_at=now, + ) + except IntegrityError: + row = None + if row is not None: + break + continue + + context_json = _runtime_context(runtime, current.context_json) + + if current.status == "activating": + # Never clear or replace a live claim. Refresh the payload for + # observability, while the claimed worker completes atomically. + updated = ( + PendingOrchestratedEvent.update( + orchestration=orchestration.id, + version=version.id, + route=route_id, + service=service_id, + integration_name=integration_name, + normalized_event_json=stored_event, + context_json=context_json, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == current.id) + & (PendingOrchestratedEvent.active_key == key) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == current.claim_token) + ) + .execute() + ) + else: + activation_at = requested_activation_at + if runtime.pause_retrigger != "reset": + activation_at = current.activation_at + updated = ( + PendingOrchestratedEvent.update( + orchestration=orchestration.id, + version=version.id, + route=route_id, + service=service_id, + integration_name=integration_name, + normalized_event_json=stored_event, + context_json=context_json, + activation_at=activation_at, + status="pending", + attempts=0, + last_error=None, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + resolved_at=None, + activated_at=None, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == current.id) + & (PendingOrchestratedEvent.active_key == key) + & PendingOrchestratedEvent.status.in_(("pending", "failed")) + ) + .execute() + ) + + if updated == 1: + row = PendingOrchestratedEvent.get_by_id(current.id) + break + + if row is None: + raise RuntimeError("paused event changed concurrently; retry the request") + + if trace is not None: + trace.step( + "orchestration", + "orchestration_event_paused", + "success", + "Event activation paused", + runtime.disposition_reason, + pending_event_id=row.id, + activation_at=row.activation_at.isoformat(), + pause_seconds=seconds, + retrigger=runtime.pause_retrigger, + activation_in_progress=row.status == "activating", + ) + return row + +def resolve_pending_event( + *, + group_id: int, + source: str, + dedup_key: str, + trace=None, + now=None, +) -> Optional[PendingOrchestratedEvent]: + """Resolve a paused trigger before its activation transaction commits.""" + now = now or utc_now() + key = pending_active_key(group_id, source, dedup_key) + + with db.atomic(): + row = PendingOrchestratedEvent.get_or_none( + PendingOrchestratedEvent.active_key == key + ) + if row is None: + return None + + updated = ( + PendingOrchestratedEvent.update( + status="resolved", + active_key=None, + resolved_at=now, + updated_at=now, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + normalized_event_json=_terminal_event_snapshot( + row.normalized_event_json or {} + ), + ) + .where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.active_key == key) + & PendingOrchestratedEvent.status.in_(("pending", "failed", "activating")) + ) + .execute() + ) + if updated != 1: + return None + row = PendingOrchestratedEvent.get_by_id(row.id) + + if trace is not None: + trace.step( + "orchestration", + "orchestration_pause_resolved_before_activation", + "success", + "Paused event resolved before activation", + "No alert or incident was created.", + pending_event_id=row.id, + ) + return row + +def _claim_one(row_id: int, *, now) -> Optional[PendingOrchestratedEvent]: + token = uuid.uuid4().hex + updated = ( + PendingOrchestratedEvent.update( + status="activating", + claim_token=token, + claimed_at=now, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == row_id) + & (PendingOrchestratedEvent.status == "pending") + ) + .execute() + ) + if updated != 1: + return None + return PendingOrchestratedEvent.get( + (PendingOrchestratedEvent.id == row_id) + & (PendingOrchestratedEvent.claim_token == token) + ) + + +def _requeue_stale_claims(now) -> int: + cutoff = now - timedelta( + seconds=int(getattr(Config, "ORCHESTRATION_PENDING_CLAIM_TTL_SECONDS", 300)) + ) + return ( + PendingOrchestratedEvent.update( + status="pending", + claim_token=None, + claimed_at=None, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claimed_at <= cutoff) + ) + .execute() + ) + + +def _activation_runtime(row: PendingOrchestratedEvent) -> RuntimeResult: + context = row.context_json or {} + runtime = restore_runtime_result(context.get("runtime") or {}) + runtime.disposition = "process" + runtime.disposition_reason = None + runtime.pause_seconds = None + runtime.pause_retrigger = "preserve" + return runtime + + +def _mark_activation_failed(row, exc, *, now): + attempts = int(row.attempts or 0) + 1 + max_attempts = int(getattr(Config, "ORCHESTRATION_PENDING_MAX_ATTEMPTS", 5)) + error = str(redact_secrets(exc))[:2048] + if attempts >= max_attempts: + status = "failed" + next_attempt_at = None + else: + status = "pending" + base = int(getattr(Config, "ORCHESTRATION_PENDING_RETRY_BASE_SECONDS", 30)) + next_attempt_at = now + timedelta(seconds=min(base * (2 ** (attempts - 1)), 3600)) + + PendingOrchestratedEvent.update( + status=status, + attempts=attempts, + last_error=error, + claim_token=None, + claimed_at=None, + next_attempt_at=next_attempt_at, + updated_at=now, + ).where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == row.claim_token) + ).execute() + + +def _activate_claimed( + row: PendingOrchestratedEvent, + *, + now, +) -> Optional[AlertProcessingResult]: + from app.services.alerts.lifecycle import _upsert_alert + + # This write acquires a row lock on PostgreSQL and the write lock on + # SQLite. Resolve requests either win before it or wait until the alert + # and pending-state transition commit together. + with db.atomic(): + locked = ( + PendingOrchestratedEvent.update( + claimed_at=now, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == row.claim_token) + ) + .execute() + ) + if locked != 1: + return None + + row = PendingOrchestratedEvent.get_by_id(row.id) + alert_data = copy.deepcopy(row.normalized_event_json or {}) + runtime = _activation_runtime(row) + trace = AlertExplainTrace.start(alert_data) + trace.step( + "orchestration", + "orchestration_pause_activated", + "success", + "Paused event activation started", + pending_event_id=row.id, + scheduled_activation_at=row.activation_at.isoformat(), + ) + + result = _upsert_alert(alert_data, trace, runtime=runtime) + if result.group is None or result.alert is None: + raise RuntimeError( + result.reason or f"paused event activation ended with {result.outcome}" + ) + + attach_runtime_executions(runtime, group=result.group, alert=result.alert) + updated = ( + PendingOrchestratedEvent.update( + status="activated", + active_key=None, + activated_at=now, + normalized_event_json=_terminal_event_snapshot(alert_data), + claim_token=None, + claimed_at=None, + next_attempt_at=None, + last_error=None, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == row.claim_token) + ) + .execute() + ) + if updated != 1: + raise RuntimeError("paused event activation claim was lost") + return result + +def process_due_pending_events(limit=100, *, now=None): + """Activate due paused events with atomic claims and bounded retries.""" + now = now or utc_now() + result = {"processed": 0, "activated": 0, "failed": 0, "requeued": 0} + result["requeued"] = _requeue_stale_claims(now) + + rows = list( + PendingOrchestratedEvent.select(PendingOrchestratedEvent.id) + .where( + (PendingOrchestratedEvent.status == "pending") + & (PendingOrchestratedEvent.activation_at <= now) + & ( + PendingOrchestratedEvent.next_attempt_at.is_null(True) + | (PendingOrchestratedEvent.next_attempt_at <= now) + ) + ) + .order_by( + PendingOrchestratedEvent.activation_at.asc(), + PendingOrchestratedEvent.id.asc(), + ) + .limit(int(limit)) + ) + + for candidate in rows: + claimed = _claim_one(candidate.id, now=now) + if claimed is None: + continue + result["processed"] += 1 + try: + activated = _activate_claimed(claimed, now=utc_now()) + if activated is not None: + result["activated"] += 1 + except Exception as exc: + logger.exception( + "paused orchestration event activation failed", + extra={"extra": {"pending_event_id": claimed.id}}, + ) + _mark_activation_failed(claimed, exc, now=utc_now()) + result["failed"] += 1 + return result + + +def retry_failed_pending_event(pending_event_id: int, *, now=None): + now = now or utc_now() + updated = ( + PendingOrchestratedEvent.update( + status="pending", + attempts=0, + last_error=None, + claim_token=None, + claimed_at=None, + next_attempt_at=now, + activation_at=now, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == pending_event_id) + & (PendingOrchestratedEvent.status == "failed") + ) + .execute() + ) + return updated == 1 + + +def cleanup_orchestration_retention(*, now=None): + now = now or utc_now() + executions_deleted = ( + OrchestrationExecution.delete() + .where( + OrchestrationExecution.expires_at.is_null(False) + & (OrchestrationExecution.expires_at <= now) + ) + .execute() + ) + retention_days = int( + getattr(Config, "ORCHESTRATION_PENDING_EVENT_RETENTION_DAYS", 30) + ) + cutoff = now - timedelta(days=retention_days) + pending_deleted = ( + PendingOrchestratedEvent.delete() + .where( + PendingOrchestratedEvent.status.in_(tuple(TERMINAL_STATUSES)) + & (PendingOrchestratedEvent.updated_at <= cutoff) + ) + .execute() + ) + webhook_executions_deleted = cleanup_webhook_executions(now=now) + return { + "executions_deleted": executions_deleted, + "pending_events_deleted": pending_deleted, + "webhook_executions_deleted": webhook_executions_deleted, + } + + +__all__ = [ + "cleanup_orchestration_retention", + "pending_active_key", + "process_due_pending_events", + "resolve_pending_event", + "retry_failed_pending_event", + "store_paused_event", +] diff --git a/app/services/orchestration/permissions.py b/app/services/orchestration/permissions.py new file mode 100644 index 0000000..ca975f7 --- /dev/null +++ b/app/services/orchestration/permissions.py @@ -0,0 +1,53 @@ +from typing import Dict, FrozenSet + +from app.modules.db.models import UserGroup + + +VIEW = "orchestration.view" +CREATE = "orchestration.create" +EDIT = "orchestration.edit" +SIMULATE = "orchestration.simulate" +PUBLISH = "orchestration.publish" +DELETE = "orchestration.delete" +MANAGE_TOKENS = "orchestration.manage_tokens" +VIEW_EXECUTIONS = "orchestration.view_executions" +REPLAY = "orchestration.replay" +MANAGE_ACTIONS = "orchestration.manage_actions" + +ALL_PERMISSIONS: FrozenSet[str] = frozenset( + { + VIEW, + CREATE, + EDIT, + SIMULATE, + PUBLISH, + DELETE, + MANAGE_TOKENS, + VIEW_EXECUTIONS, + REPLAY, + MANAGE_ACTIONS, + } +) + +GROUP_ROLE_PERMISSIONS: Dict[str, FrozenSet[str]] = { + "viewer": frozenset({VIEW, VIEW_EXECUTIONS}), + "editor": frozenset({VIEW, CREATE, EDIT, SIMULATE, VIEW_EXECUTIONS, REPLAY}), + # user_admin is intentionally not treated as an orchestration publisher. + "user_admin": frozenset({VIEW, VIEW_EXECUTIONS}), +} + + +def has_orchestration_permission(user, group_id: int, permission: str) -> bool: + if permission not in ALL_PERMISSIONS: + return False + if bool(getattr(user, "is_admin", False)): + return True + + membership = UserGroup.get_or_none( + (UserGroup.user == user.id) + & (UserGroup.group == group_id) + & (UserGroup.active == True) # noqa: E712 + ) + if membership is None: + return False + return permission in GROUP_ROLE_PERMISSIONS.get(membership.role, frozenset()) diff --git a/app/services/orchestration/regex.py b/app/services/orchestration/regex.py new file mode 100644 index 0000000..577e592 --- /dev/null +++ b/app/services/orchestration/regex.py @@ -0,0 +1,91 @@ +"""Bounded regular-expression helpers. + +Python's standard ``re`` engine has no portable timeout on the supported +IncidentRelay Python versions, so orchestration regexes use strict size and +complexity checks before compilation and input-size limits before matching. +""" + +from __future__ import annotations + +import re +from typing import Pattern + +from .errors import RegexSafetyError +from .limits import MAX_REGEX_INPUT_LENGTH, MAX_REGEX_PATTERN_LENGTH + + +# Reject constructs that are either unnecessary for orchestration matching or +# commonly associated with catastrophic backtracking / hidden complexity. +_FORBIDDEN_PATTERNS = ( + (re.compile(r"\\[1-9]"), "numeric backreferences are not allowed"), + (re.compile(r"\(\?P="), "named backreferences are not allowed"), + (re.compile(r"\(\?<[=!]"), "lookbehind is not allowed"), + (re.compile(r"\(\?\("), "conditional groups are not allowed"), + (re.compile(r"\(\?>"), "atomic groups are not supported"), +) + +# Approximate nested-quantifier detection. This intentionally rejects some +# valid but complex patterns in exchange for predictable ingestion latency. +_NESTED_QUANTIFIER = re.compile( + r"\((?:[^()\\]|\\.)*(?:\*|\+|\?|\{\d+(?:,\d*)?\})(?:[^()\\]|\\.)*\)" + r"\s*(?:\*|\+|\{\d+(?:,\d*)?\})" +) +_QUANTIFIED_ALTERNATION = re.compile( + r"\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)" + r"\s*(?:\*|\+|\{\d+(?:,\d*)?\})" +) +_AMBIGUOUS_DOT_REPEAT = re.compile(r"(?:\.\*|\.\+)\s*(?:\.\*|\.\+)") + + +def normalize_named_groups(pattern: str) -> str: + """Accept architecture-style ``(?...)`` named groups safely.""" + + return re.sub(r"\(\?<([A-Za-z_][A-Za-z0-9_]*)>", r"(?P<\1>", pattern) + + +def validate_regex_pattern(pattern: str) -> str: + if not isinstance(pattern, str): + raise RegexSafetyError("regex pattern must be a string") + if len(pattern) > MAX_REGEX_PATTERN_LENGTH: + raise RegexSafetyError("regex pattern exceeds the size limit") + if "\x00" in pattern: + raise RegexSafetyError("regex pattern cannot contain NUL bytes") + + normalized = normalize_named_groups(pattern) + for detector, message in _FORBIDDEN_PATTERNS: + if detector.search(normalized): + raise RegexSafetyError(message) + if _NESTED_QUANTIFIER.search(normalized): + raise RegexSafetyError("nested quantified groups are not allowed") + if _QUANTIFIED_ALTERNATION.search(normalized): + raise RegexSafetyError("quantified alternation groups are not allowed") + if _AMBIGUOUS_DOT_REPEAT.search(normalized): + raise RegexSafetyError("ambiguous repeated wildcard expressions are not allowed") + if sum(normalized.count(token) for token in ("*", "+", "?", "{")) > 64: + raise RegexSafetyError("regex pattern has too many quantifiers") + if normalized.count("|") > 32: + raise RegexSafetyError("regex pattern has too many alternatives") + + try: + re.compile(normalized) + except re.error as exc: + raise RegexSafetyError(f"invalid regex pattern: {exc.msg}") from exc + return normalized + + +def compile_safe_regex(pattern: str, *, flags: int = 0) -> Pattern[str]: + return re.compile(validate_regex_pattern(pattern), flags) + + +def bounded_regex_input(value: object) -> str: + if value is None: + text = "" + elif isinstance(value, bool): + text = "true" if value else "false" + elif isinstance(value, (str, int, float)): + text = str(value) + else: + raise RegexSafetyError("regex input must be a scalar value") + if len(text) > MAX_REGEX_INPUT_LENGTH: + raise RegexSafetyError("regex input exceeds the size limit") + return text diff --git a/app/services/orchestration/runtime.py b/app/services/orchestration/runtime.py new file mode 100644 index 0000000..3abd14f --- /dev/null +++ b/app/services/orchestration/runtime.py @@ -0,0 +1,1033 @@ +"""Runtime handoff from published orchestration definitions to alert lifecycle.""" + +from __future__ import annotations + +import copy +import logging +import time +from datetime import timedelta +from dataclasses import dataclass, field, replace +from typing import Any, Dict, List, Mapping, Optional + +from app.modules.common import utc_now +from app.settings import Config +from app.modules.db import orchestrations_repo +from app.modules.db.models import ( + AlertRoute, + EscalationPolicy, + EventOrchestration, + NotificationPolicy, + OrchestrationExecution, + PriorityPolicy, + Service, + Team, +) +from app.services.orchestration.cache import published_definition_cache +from app.services.orchestration.engine import execute_rule_tree +from app.services.orchestration.fields import build_context +from app.services.orchestration.safety import safe_trace_value + +logger = logging.getLogger("oncall.orchestration.runtime") + +_COMPATIBILITY_ORDER = {"legacy": 0, "hybrid": 1, "orchestration": 2} + + +class RuntimeOrchestrationError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RuntimeStep: + orchestration_id: int + version_id: int + scope: str + mode: str + compatibility_mode: str + applied: bool + execution_id: Optional[int] + duration_ms: int + matched_rule_count: int = 0 + outcome: str = "continue" + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "orchestration_id": self.orchestration_id, + "version_id": self.version_id, + "scope": self.scope, + "mode": self.mode, + "compatibility_mode": self.compatibility_mode, + "applied": self.applied, + "execution_id": self.execution_id, + "duration_ms": self.duration_ms, + "matched_rule_count": self.matched_rule_count, + "outcome": self.outcome, + "error": self.error, + } + + +@dataclass +class RuntimeResult: + group_id: Optional[int] = None + compatibility_mode: str = "legacy" + route: Any = None + team: Any = None + service: Any = None + escalation_policy: Any = None + priority_policy: Any = None + notification_policy: Any = None + group_key: Optional[str] = None + grouping_window_seconds: Optional[int] = None + route_selected_by_orchestration: bool = False + steps: List[RuntimeStep] = field(default_factory=list) + blocked: bool = False + reason: Optional[str] = None + disposition: str = "process" + disposition_reason: Optional[str] = None + pause_seconds: Optional[int] = None + pause_retrigger: str = "preserve" + disposition_orchestration_id: Optional[int] = None + disposition_version_id: Optional[int] = None + evaluated_service_ids: List[int] = field(default_factory=list, repr=False) + _context: Dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def execution_ids(self): + return [step.execution_id for step in self.steps if step.execution_id] + + def to_dict(self): + return { + "group_id": self.group_id, + "compatibility_mode": self.compatibility_mode, + "route_id": getattr(self.route, "id", None), + "team_id": getattr(self.team, "id", None), + "service_id": getattr(self.service, "id", None), + "escalation_policy_id": getattr(self.escalation_policy, "id", None), + "priority_policy_id": getattr(self.priority_policy, "id", None), + "notification_policy_id": getattr(self.notification_policy, "id", None), + "group_key": self.group_key, + "grouping_window_seconds": self.grouping_window_seconds, + "blocked": self.blocked, + "reason": self.reason, + "disposition": self.disposition, + "disposition_reason": self.disposition_reason, + "pause_seconds": self.pause_seconds, + "pause_retrigger": self.pause_retrigger, + "disposition_orchestration_id": self.disposition_orchestration_id, + "disposition_version_id": self.disposition_version_id, + "evaluated_service_ids": list(self.evaluated_service_ids), + "steps": [step.to_dict() for step in self.steps], + } + + + +def _entity_context(entity) -> Dict[str, Any]: + if entity is None: + return {} + result = {"id": getattr(entity, "id", None)} + for key in ("name", "slug", "source", "enabled", "active"): + value = getattr(entity, key, None) + if value is not None: + result[key] = value + return result + + +def _route_group_id(route) -> Optional[int]: + team = getattr(route, "team", None) + group_id = getattr(team, "group_id", None) + return int(group_id) if group_id is not None else None + + +def _group_id_from_alert(alert_data: Mapping[str, Any]) -> Optional[int]: + explicit = alert_data.get("orchestration_group_id") or alert_data.get("group_id") + if explicit not in (None, ""): + try: + return int(explicit) + except (TypeError, ValueError): + return None + + route_id = alert_data.get("forced_route_id") + if route_id: + route = AlertRoute.get_or_none(AlertRoute.id == route_id) + return _route_group_id(route) if route else None + + team_id = alert_data.get("forced_team_id") + if team_id: + team = Team.get_or_none(Team.id == team_id) + return int(team.group_id) if team and team.group_id is not None else None + + team_slug = alert_data.get("team_slug") + if team_slug: + team = Team.get_or_none(Team.slug == team_slug) + return int(team.group_id) if team and team.group_id is not None else None + + return None + + +def _load_route(route_id: Any, *, group_id: int, source: Optional[str]): + if route_id in (None, ""): + return None + route = AlertRoute.get_or_none(AlertRoute.id == int(route_id)) + if not route or not route.enabled or getattr(route, "deleted", False): + raise RuntimeOrchestrationError("selected route is missing or disabled") + if _route_group_id(route) != int(group_id): + raise RuntimeOrchestrationError("selected route belongs to another group") + if source and route.source != source: + raise RuntimeOrchestrationError("selected route source does not match event source") + if ( + not route.team + or not route.team.active + or not route.team.group + or not route.team.group.active + ): + raise RuntimeOrchestrationError("selected route team or group is inactive") + return route + + +def _load_team(team_id: Any, *, group_id: int): + if team_id in (None, ""): + return None + team = Team.get_or_none(Team.id == int(team_id)) + if not team or not team.active or getattr(team, "deleted", False): + raise RuntimeOrchestrationError("selected team is missing or inactive") + if int(team.group_id or 0) != int(group_id): + raise RuntimeOrchestrationError("selected team belongs to another group") + return team + + +def _load_service(service_id: Any, *, group_id: int): + if service_id in (None, ""): + return None + service = Service.get_or_none(Service.id == int(service_id)) + if not service or not service.enabled or getattr(service, "deleted", False): + raise RuntimeOrchestrationError("selected service is missing or disabled") + if int(service.group_id or 0) != int(group_id): + raise RuntimeOrchestrationError("selected service belongs to another group") + return service + + +def _load_policy(model, policy_id, *, group_id: int, team_id: Optional[int]): + if policy_id in (None, ""): + return None + policy = model.get_or_none(model.id == int(policy_id)) + if ( + not policy + or not getattr(policy, "enabled", False) + or getattr(policy, "deleted", False) + ): + raise RuntimeOrchestrationError("selected policy is missing or disabled") + policy_team = getattr(policy, "team", None) + if not policy_team or int(policy_team.group_id or 0) != int(group_id): + raise RuntimeOrchestrationError("selected policy belongs to another group") + if team_id is not None and int(policy.team_id) != int(team_id): + raise RuntimeOrchestrationError("selected policy belongs to another team") + return policy + + +def _initial_entities(alert_data, group_id): + route = _load_route( + alert_data.get("forced_route_id"), + group_id=group_id, + source=alert_data.get("source"), + ) if alert_data.get("forced_route_id") else None + team = ( + route.team + if route + else _load_team( + alert_data.get("forced_team_id"), + group_id=group_id, + ) + ) + service = ( + _load_service(alert_data.get("service_id"), group_id=group_id) + if alert_data.get("service_id") + else (route.service if route and getattr(route, "service_id", None) else None) + ) + return route, team, service + + +def _build_runtime_context(alert_data, *, route=None, team=None, service=None): + event = { + str(key): copy.deepcopy(value) + for key, value in alert_data.items() + if key not in {"payload", "raw"} and not str(key).startswith("_orchestration") + } + return build_context( + event=event, + labels=event.get("labels") or {}, + raw=copy.deepcopy(alert_data.get("raw") or alert_data.get("payload") or {}), + route=_entity_context(route), + team=_entity_context(team), + service=_entity_context(service), + integration={ + "name": alert_data.get("source"), + "source": alert_data.get("source"), + }, + time={"now": utc_now().isoformat()}, + ) + + +def _record_execution( + *, orchestration, version, alert_data, result=None, duration_ms=0, applied=False, + error=None, initial_context=None, +): + trace = { + "applied": bool(applied), + "mode": orchestration.mode, + "compatibility_mode": orchestration.compatibility_mode, + "initial_context": safe_trace_value(initial_context or {}), + "result": safe_trace_value(result.to_dict()) if result else None, + "error": safe_trace_value(error), + } + disposition = None + matched = 0 + if result is not None: + disposition = result.context.get("result", {}).get("disposition") + matched = result.matched_rule_count + expires_at = None + if disposition == "drop": + expires_at = utc_now() + timedelta( + days=int(getattr(Config, "ORCHESTRATION_DROPPED_TRACE_RETENTION_DAYS", 7)) + ) + + row = OrchestrationExecution.create( + group=orchestration.group_id, + orchestration=orchestration.id, + version=version.id, + source=alert_data.get("source"), + integration_name=alert_data.get("integration_name") or alert_data.get("source"), + event_fingerprint=( + alert_data.get("dedup_key") + or alert_data.get("external_id") + ), + disposition=disposition, + matched_rule_count=matched, + duration_ms=duration_ms, + trace_json=trace, + expires_at=expires_at, + ) + return row.id + + +def _safe_record_execution(**kwargs): + try: + return _record_execution(**kwargs) + except Exception: + logger.exception( + "failed to persist orchestration execution", + extra={"extra": { + "orchestration_id": getattr(kwargs.get("orchestration"), "id", None), + "version_id": getattr(kwargs.get("version"), "id", None), + }}, + ) + return None + + +def _run_one(orchestration: EventOrchestration, context, alert_data): + started = time.perf_counter() + initial_context = copy.deepcopy(context) + try: + version = orchestrations_repo.get_published_runtime_version(orchestration) + definition = published_definition_cache.get(version) + rules = definition.get("rules") or [] + result = execute_rule_tree(rules, context) + duration_ms = max(0, int((time.perf_counter() - started) * 1000)) + applied = ( + orchestration.mode == "active" + and orchestration.compatibility_mode != "legacy" + ) + execution_id = _safe_record_execution( + orchestration=orchestration, + version=version, + alert_data=alert_data, + result=result, + duration_ms=duration_ms, + applied=applied, + initial_context=initial_context, + ) + return result, RuntimeStep( + orchestration_id=orchestration.id, + version_id=version.id, + scope=orchestration.scope, + mode=orchestration.mode, + compatibility_mode=orchestration.compatibility_mode, + applied=applied, + execution_id=execution_id, + duration_ms=duration_ms, + matched_rule_count=result.matched_rule_count, + outcome=result.outcome, + ) + except Exception as exc: + duration_ms = max(0, int((time.perf_counter() - started) * 1000)) + safe_error = exc.__class__.__name__ + version = locals().get("version") + execution_id = None + if version is not None: + execution_id = _safe_record_execution( + orchestration=orchestration, + version=version, + alert_data=alert_data, + duration_ms=duration_ms, + applied=False, + error=safe_error, + initial_context=initial_context, + ) + return None, RuntimeStep( + orchestration_id=orchestration.id, + version_id=getattr( + version, + "id", + orchestration.active_version_id or 0, + ), + scope=orchestration.scope, + mode=orchestration.mode, + compatibility_mode=orchestration.compatibility_mode, + applied=False, + execution_id=execution_id, + duration_ms=duration_ms, + error=safe_error, + outcome="failed", + ) + + +def _mark_execution_rejected(step: RuntimeStep, reason: str) -> RuntimeStep: + """Correct the audit row when a candidate fails runtime entity validation.""" + if step.execution_id: + try: + execution = OrchestrationExecution.get_by_id(step.execution_id) + trace_json = copy.deepcopy(execution.trace_json or {}) + trace_json["applied"] = False + trace_json["rejected_reason"] = safe_trace_value(reason) + execution.trace_json = trace_json + execution.save(only=[OrchestrationExecution.trace_json]) + except Exception: + logger.exception( + "failed to mark orchestration execution as rejected", + extra={"extra": {"execution_id": step.execution_id}}, + ) + return replace( + step, + applied=False, + outcome="rejected", + error="RuntimeOrchestrationError", + ) + + +def _apply_candidate(candidate): + return copy.deepcopy(candidate.context) + + +def _selected_ids(context): + result = context.get("result") or {} + routing = result.get("routing") or {} + policies = result.get("policies") or {} + return routing, policies, result.get("grouping") or {} + + +def _resolve_selected_entities( + context, *, group_id, source, fallback_route=None, fallback_team=None, + fallback_service=None, fallback_escalation=None, fallback_priority=None, + fallback_notification=None, +): + routing, policies, grouping = _selected_ids(context) + explicit_route = routing.get("route_id") not in (None, "") + explicit_team = routing.get("team_id") not in (None, "") + explicit_service = routing.get("service_id") not in (None, "") + + if explicit_route: + route = _load_route(routing.get("route_id"), group_id=group_id, source=source) + elif explicit_team: + route = None + else: + route = fallback_route + + if explicit_team: + team = _load_team(routing.get("team_id"), group_id=group_id) + elif route: + team = route.team + else: + team = fallback_team + + if explicit_service: + service = _load_service(routing.get("service_id"), group_id=group_id) + if not explicit_team and not explicit_route: + team = service.team + if route and route.team_id != team.id: + route = None + elif fallback_service is not None: + service = fallback_service + elif route and getattr(route, "service_id", None): + service = route.service + else: + service = None + + if route and team and route.team_id != team.id: + raise RuntimeOrchestrationError("selected route and team do not match") + if service and team and service.team_id != team.id: + raise RuntimeOrchestrationError("selected service and team do not match") + + team_id = getattr(team, "id", None) + escalation = ( + _load_policy( + EscalationPolicy, + policies.get("escalation_policy_id"), + group_id=group_id, + team_id=team_id, + ) + if policies.get("escalation_policy_id") else fallback_escalation + ) + priority = ( + _load_policy( + PriorityPolicy, + policies.get("priority_policy_id"), + group_id=group_id, + team_id=team_id, + ) + if policies.get("priority_policy_id") else fallback_priority + ) + notification = ( + _load_policy( + NotificationPolicy, + policies.get("notification_policy_id"), + group_id=group_id, + team_id=team_id, + ) + if policies.get("notification_policy_id") else fallback_notification + ) + return route, team, service, escalation, priority, notification, grouping + + +def _refresh_entity_context(context, *, route, team, service): + context["route"] = _entity_context(route) + context["team"] = _entity_context(team) + context["service"] = _entity_context(service) + + +def _evaluate_orchestrations( + orchestrations, + *, + context, + alert_data, + runtime, + group_id, + route, + team, + service, +): + for orchestration in orchestrations: + # Legacy mode exists only as a safe rollout default. It must not execute + # or write shadow audit rows unless the operator explicitly switches it. + if orchestration.mode == "active" and orchestration.compatibility_mode == "legacy": + continue + + candidate, step = _run_one(orchestration, context, alert_data) + if step.error: + runtime.steps.append(step) + if ( + orchestration.mode == "active" + and orchestration.compatibility_mode == "orchestration" + ): + runtime.blocked = True + runtime.reason = "Active orchestration evaluation failed" + break + continue + + if not step.applied: + runtime.steps.append(step) + continue + + candidate_context = _apply_candidate(candidate) + try: + ( + route_candidate, + team_candidate, + service_candidate, + escalation, + priority, + notification, + grouping, + ) = _resolve_selected_entities( + candidate_context, + group_id=group_id, + source=alert_data.get("source"), + fallback_route=route, + fallback_team=team, + fallback_service=service, + fallback_escalation=runtime.escalation_policy, + fallback_priority=runtime.priority_policy, + fallback_notification=runtime.notification_policy, + ) + except RuntimeOrchestrationError as exc: + rejected_step = _mark_execution_rejected(step, str(exc)) + runtime.steps.append(rejected_step) + if orchestration.compatibility_mode == "orchestration": + runtime.blocked = True + runtime.reason = str(exc) + break + logger.warning( + "hybrid orchestration result rejected", + extra={"extra": { + "orchestration_id": orchestration.id, + "error": str(exc), + }}, + ) + continue + + runtime.steps.append(step) + runtime.compatibility_mode = max( + runtime.compatibility_mode, + orchestration.compatibility_mode, + key=lambda value: _COMPATIBILITY_ORDER[value], + ) + context = candidate_context + route, team, service = route_candidate, team_candidate, service_candidate + _refresh_entity_context(context, route=route, team=team, service=service) + runtime.route = route + runtime.team = team + runtime.service = service + runtime.escalation_policy = escalation + runtime.priority_policy = priority + runtime.notification_policy = notification + routing_result = (context.get("result") or {}).get("routing") or {} + + runtime.route_selected_by_orchestration = ( + runtime.route_selected_by_orchestration + or routing_result.get("route_id") not in (None, "") + ) + + event_group_key = (context.get("event") or {}).get("group_key") + if grouping.get("group_key") not in (None, ""): + runtime.group_key = grouping.get("group_key") + elif event_group_key not in (None, ""): + runtime.group_key = event_group_key + if "window_seconds" in grouping: + runtime.grouping_window_seconds = grouping.get("window_seconds") + + result_state = context.get("result") or {} + disposition = result_state.get("disposition") or "process" + if disposition in {"suppress", "pause", "drop"}: + runtime.disposition = disposition + runtime.disposition_orchestration_id = orchestration.id + runtime.disposition_version_id = step.version_id + if disposition == "suppress": + runtime.disposition_reason = result_state.get("suppress_reason") + elif disposition == "pause": + runtime.disposition_reason = result_state.get("pause_reason") + runtime.pause_seconds = result_state.get("pause_seconds") + runtime.pause_retrigger = result_state.get("pause_retrigger") or "preserve" + else: + runtime.disposition_reason = result_state.get("drop_reason") + + runtime._context = copy.deepcopy(context) + return context, route, team, service + + +_MUTABLE_EVENT_FIELDS = { + "title", + "message", + "description", + "severity", + "priority", + "dedup_key", + "group_key", + "labels", + "custom_details", + "event_action", +} + + +def _apply_runtime_to_alert_data( + runtime: RuntimeResult, + alert_data: Dict[str, Any], +): + if runtime.blocked or not any(step.applied for step in runtime.steps): + return + + event = (runtime._context or {}).get("event") or {} + for key in _MUTABLE_EVENT_FIELDS: + if key in event: + alert_data[key] = copy.deepcopy(event[key]) + + if event.get("event_action") == "resolve": + alert_data["status"] = "resolved" + elif event.get("event_action") == "trigger": + alert_data["status"] = "firing" + + if runtime.route is not None: + alert_data["forced_route_id"] = runtime.route.id + alert_data["forced_team_id"] = runtime.route.team_id + elif runtime.team is not None: + alert_data.pop("forced_route_id", None) + alert_data["forced_team_id"] = runtime.team.id + if runtime.service is not None: + alert_data["service_id"] = runtime.service.id + if runtime.group_key: + alert_data["orchestration_group_key"] = runtime.group_key + if runtime.priority_policy is not None: + alert_data["orchestration_priority_policy_id"] = runtime.priority_policy.id + if runtime.escalation_policy is not None: + alert_data["orchestration_escalation_policy_id"] = runtime.escalation_policy.id + if runtime.notification_policy is not None: + alert_data["orchestration_notification_policy_id"] = runtime.notification_policy.id + if event.get("priority") not in (None, ""): + alert_data["priority"] = event["priority"] + alert_data["priority_set_by_orchestration"] = True + + +def _runtime_explain_payload(result: RuntimeResult) -> Dict[str, Any]: + """Return runtime summary plus full redacted rule traces for Explain.""" + payload = result.to_dict() + execution_ids = result.execution_ids + if not execution_ids: + payload["executions"] = [] + return payload + + rows = { + row.id: row + for row in OrchestrationExecution.select().where( + OrchestrationExecution.id.in_(execution_ids) + ) + } + executions = [] + for step in result.steps: + row = rows.get(step.execution_id) + if row is None: + continue + executions.append( + { + "execution_id": row.id, + "orchestration_id": row.orchestration_id, + "version_id": row.version_id, + "duration_ms": row.duration_ms, + "matched_rule_count": row.matched_rule_count, + "disposition": row.disposition, + "trace": safe_trace_value(row.trace_json or {}), + } + ) + payload["executions"] = executions + return payload + + +def _trace_runtime(trace, result: RuntimeResult, *, phase="global"): + if trace is None: + return + status = ( + "error" + if result.blocked + else ("success" if result.steps else "skipped") + ) + trace.step( + "orchestration", + "orchestration_runtime_blocked" if result.blocked else "orchestration_runtime_evaluated", + status, + ( + "Event orchestration evaluated" + if not result.blocked + else "Event orchestration blocked processing" + ), + result.reason, + phase=phase, + orchestration=_runtime_explain_payload(result), + ) + + +def _evaluate_service_chain( + alert_data, + runtime, + *, + context, + route, + team, + service, +): + """Run each selected service orchestration once, including service handoffs.""" + current_service = service + iterations = 0 + while current_service is not None and not runtime.blocked: + service_id = int(current_service.id) + if service_id in runtime.evaluated_service_ids: + break + if iterations >= 8: + runtime.blocked = runtime.compatibility_mode == "orchestration" + runtime.reason = "Service orchestration handoff limit exceeded" + break + iterations += 1 + runtime.evaluated_service_ids.append(service_id) + orchestrations = orchestrations_repo.list_runtime_orchestrations( + group_id=runtime.group_id, + scope="service", + service_id=service_id, + ) + context, route, team, selected_service = _evaluate_orchestrations( + orchestrations, + context=context, + alert_data=alert_data, + runtime=runtime, + group_id=runtime.group_id, + route=route, + team=team, + service=current_service, + ) + current_service = selected_service + return context, route, team, current_service + + +def run_event_orchestration(alert_data: Dict[str, Any], *, trace=None) -> RuntimeResult: + """Evaluate published global orchestration before legacy lifecycle routing.""" + runtime = RuntimeResult() + group_id = _group_id_from_alert(alert_data) + runtime.group_id = group_id + if group_id is None: + _trace_runtime(trace, runtime) + return runtime + + global_orchestrations = orchestrations_repo.list_runtime_orchestrations( + group_id=group_id, + scope="global", + ) + try: + route, team, service = _initial_entities(alert_data, group_id) + except RuntimeOrchestrationError as exc: + requires_orchestration = any( + item.mode == "active" and item.compatibility_mode == "orchestration" + for item in global_orchestrations + ) + if requires_orchestration: + runtime.blocked = True + runtime.reason = str(exc) + _trace_runtime(trace, runtime) + return runtime + + runtime.route = route + runtime.team = team + runtime.service = service + if not global_orchestrations and service is None: + _trace_runtime(trace, runtime) + return runtime + + context = _build_runtime_context(alert_data, route=route, team=team, service=service) + context, route, team, service = _evaluate_orchestrations( + global_orchestrations, + context=context, + alert_data=alert_data, + runtime=runtime, + group_id=group_id, + route=route, + team=team, + service=service, + ) + + if not runtime.blocked and service is not None: + context, route, team, service = _evaluate_service_chain( + alert_data, + runtime, + context=context, + route=route, + team=team, + service=service, + ) + + runtime._context = copy.deepcopy(context) + runtime.route = route + runtime.team = team + runtime.service = service + + if ( + not runtime.blocked + and runtime.compatibility_mode == "orchestration" + and runtime.route is None + ): + runtime.blocked = True + runtime.reason = "Orchestration mode requires a selected route" + + _apply_runtime_to_alert_data(runtime, alert_data) + _trace_runtime(trace, runtime) + return runtime + + +def run_service_orchestration( + alert_data: Dict[str, Any], + runtime: Optional[RuntimeResult], + *, + route, + team, + service, + trace=None, +) -> RuntimeResult: + """Run service-scoped orchestration after the lifecycle selects a service.""" + runtime = runtime or RuntimeResult(group_id=_route_group_id(route)) + if runtime.blocked or service is None or runtime.group_id is None: + return runtime + + previous_step_count = len(runtime.steps) + context = ( + copy.deepcopy(runtime._context) + if runtime._context + else _build_runtime_context( + alert_data, + route=route, + team=team, + service=service, + ) + ) + _refresh_entity_context(context, route=route, team=team, service=service) + runtime.route = route + runtime.team = team + runtime.service = service + + context, route, team, service = _evaluate_service_chain( + alert_data, + runtime, + context=context, + route=route, + team=team, + service=service, + ) + runtime._context = copy.deepcopy(context) + runtime.route = route + runtime.team = team + runtime.service = service + + if ( + not runtime.blocked + and runtime.compatibility_mode == "orchestration" + and runtime.route is None + ): + runtime.blocked = True + runtime.reason = "Orchestration mode requires a selected route" + + _apply_runtime_to_alert_data(runtime, alert_data) + if runtime.blocked or len(runtime.steps) != previous_step_count: + _trace_runtime(trace, runtime, phase="service") + return runtime + + +def _actual_result_snapshot(*, group=None, alert=None) -> Dict[str, Any]: + """Capture the lifecycle result used to compare a shadow candidate.""" + route_id = getattr(alert, "route_id", None) or getattr(group, "route_id", None) + team_id = getattr(alert, "team_id", None) or getattr(group, "team_id", None) + service_id = getattr(alert, "service_id", None) or getattr(group, "service_id", None) + suppressed = bool( + getattr(alert, "orchestration_suppressed", False) + or getattr(group, "orchestration_suppressed", False) + ) + return { + "route_id": route_id, + "team_id": team_id, + "service_id": service_id, + "severity": getattr(alert, "severity", None), + "title": getattr(alert, "title", None), + "group_key": ( + getattr(alert, "group_key", None) + or getattr(group, "group_key", None) + ), + "status": getattr(alert, "status", None) or getattr(group, "status", None), + "disposition": "suppress" if suppressed else "process", + "alert_id": getattr(alert, "id", None), + "alert_group_id": getattr(group, "id", None), + } + + +def attach_runtime_executions( + runtime: Optional[RuntimeResult], + *, + group=None, + alert=None, +): + if runtime is None or not runtime.execution_ids: + return + execution_ids = list(runtime.execution_ids) + OrchestrationExecution.update( + alert_group_id=getattr(group, "id", None), + alert_id=getattr(alert, "id", None), + ).where(OrchestrationExecution.id.in_(execution_ids)).execute() + + actual_result = safe_trace_value( + _actual_result_snapshot(group=group, alert=alert) + ) + for execution in OrchestrationExecution.select().where( + OrchestrationExecution.id.in_(execution_ids) + ): + trace_json = copy.deepcopy(execution.trace_json or {}) + trace_json["actual_result"] = actual_result + execution.trace_json = trace_json + execution.save(only=[OrchestrationExecution.trace_json]) + + # Queue outbound automation only after the orchestration decision and the + # lifecycle outcome have been persisted. The queue function is idempotent. + from app.services.orchestration.webhooks import enqueue_execution_webhooks + + for execution_id in execution_ids: + try: + enqueue_execution_webhooks( + execution_id, + alert_group_id=getattr(group, "id", None), + ) + except Exception: + logger.exception( + "failed to enqueue orchestration webhook actions", + extra={"extra": {"execution_id": execution_id}}, + ) + + + + +def restore_runtime_result(data: Mapping[str, Any]) -> RuntimeResult: + """Restore trusted runtime metadata stored with a paused event.""" + payload = dict(data or {}) + steps = [] + for item in payload.get("steps") or []: + try: + steps.append(RuntimeStep(**item)) + except (TypeError, ValueError): + continue + + runtime = RuntimeResult( + group_id=payload.get("group_id"), + compatibility_mode=payload.get("compatibility_mode") or "legacy", + route=AlertRoute.get_or_none(AlertRoute.id == payload.get("route_id")) + if payload.get("route_id") else None, + team=Team.get_or_none(Team.id == payload.get("team_id")) + if payload.get("team_id") else None, + service=Service.get_or_none(Service.id == payload.get("service_id")) + if payload.get("service_id") else None, + escalation_policy=EscalationPolicy.get_or_none( + EscalationPolicy.id == payload.get("escalation_policy_id") + ) if payload.get("escalation_policy_id") else None, + priority_policy=PriorityPolicy.get_or_none( + PriorityPolicy.id == payload.get("priority_policy_id") + ) if payload.get("priority_policy_id") else None, + notification_policy=NotificationPolicy.get_or_none( + NotificationPolicy.id == payload.get("notification_policy_id") + ) if payload.get("notification_policy_id") else None, + group_key=payload.get("group_key"), + grouping_window_seconds=payload.get("grouping_window_seconds"), + route_selected_by_orchestration=bool( + payload.get("route_selected_by_orchestration") + ), + steps=steps, + blocked=bool(payload.get("blocked")), + reason=payload.get("reason"), + disposition=payload.get("disposition") or "process", + disposition_reason=payload.get("disposition_reason"), + pause_seconds=payload.get("pause_seconds"), + pause_retrigger=payload.get("pause_retrigger") or "preserve", + disposition_orchestration_id=payload.get("disposition_orchestration_id"), + disposition_version_id=payload.get("disposition_version_id"), + evaluated_service_ids=[ + int(value) for value in payload.get("evaluated_service_ids") or [] + ], + ) + if runtime.team is None and runtime.route is not None: + runtime.team = runtime.route.team + return runtime + +__all__ = [ + "RuntimeOrchestrationError", + "RuntimeResult", + "RuntimeStep", + "attach_runtime_executions", + "run_event_orchestration", + "run_service_orchestration", + "restore_runtime_result", +] diff --git a/app/services/orchestration/safety.py b/app/services/orchestration/safety.py new file mode 100644 index 0000000..8b76f66 --- /dev/null +++ b/app/services/orchestration/safety.py @@ -0,0 +1,176 @@ +"""Shared safety helpers for orchestration traces and JSON payloads.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any, Optional + +from app.modules.redaction import redact_secrets +from app.settings import Config + + +class OrchestrationJsonError(ValueError): + """Raised when orchestration input cannot be represented safely as JSON.""" + + +def _positive_int(value: Any, default: int, *, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(minimum, parsed) + + +def trace_limits() -> tuple[int, int, int]: + """Return the configured depth, string and collection trace limits.""" + return ( + _positive_int( + getattr(Config, "ORCHESTRATION_TRACE_MAX_DEPTH", 12), + 12, + minimum=0, + ), + _positive_int( + getattr(Config, "ORCHESTRATION_TRACE_MAX_STRING_CHARS", 2048), + 2048, + ), + _positive_int( + getattr(Config, "ORCHESTRATION_TRACE_MAX_ITEMS", 512), + 512, + ), + ) + + +def bounded_trace_value( + value: Any, + *, + depth: int = 0, + max_depth: Optional[int] = None, + max_string_chars: Optional[int] = None, + max_items: Optional[int] = None, +) -> Any: + """Return a bounded JSON-compatible representation of ``value``.""" + if max_depth is None or max_string_chars is None or max_items is None: + configured_depth, configured_string, configured_items = trace_limits() + max_depth = configured_depth if max_depth is None else max_depth + max_string_chars = ( + configured_string + if max_string_chars is None + else max_string_chars + ) + max_items = configured_items if max_items is None else max_items + + max_depth = max(0, int(max_depth)) + max_string_chars = max(1, int(max_string_chars)) + max_items = max(1, int(max_items)) + + if depth > max_depth: + return "" + + if isinstance(value, str): + if len(value) <= max_string_chars: + return value + return value[:max_string_chars] + "…" + + if value is None or isinstance(value, (int, float, bool)): + return value + + if isinstance(value, Mapping): + return { + str(key): bounded_trace_value( + item, + depth=depth + 1, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + for key, item in list(value.items())[:max_items] + } + + if isinstance(value, (list, tuple)): + return [ + bounded_trace_value( + item, + depth=depth + 1, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + for item in value[:max_items] + ] + + if isinstance(value, (set, frozenset)): + ordered = sorted(value, key=lambda item: repr(item)) + return [ + bounded_trace_value( + item, + depth=depth + 1, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + for item in ordered[:max_items] + ] + + if isinstance(value, BaseException): + return bounded_trace_value( + str(value), + depth=depth, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + + return bounded_trace_value( + str(value), + depth=depth, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + + +def safe_trace_value(value: Any) -> Any: + """Bound trace data and redact secrets through the global redactor.""" + return redact_secrets(bounded_trace_value(value)) + + +def json_size_bytes(value: Any) -> int: + """Return deterministic UTF-8 JSON size or raise ``OrchestrationJsonError``.""" + try: + encoded = json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + default=str, + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise OrchestrationJsonError("value must be JSON-compatible") from exc + return len(encoded) + + +def ensure_json_size( + value: Any, + *, + maximum_bytes: int, + label: str = "Value", +) -> int: + """Validate a JSON value against a byte limit and return its size.""" + maximum = max(1, int(maximum_bytes)) + size = json_size_bytes(value) + if size > maximum: + raise OrchestrationJsonError( + f"{label} exceeds the {maximum}-byte limit" + ) + return size + + +__all__ = [ + "OrchestrationJsonError", + "bounded_trace_value", + "ensure_json_size", + "json_size_bytes", + "safe_trace_value", + "trace_limits", +] diff --git a/app/services/orchestration/simulator.py b/app/services/orchestration/simulator.py new file mode 100644 index 0000000..48c945b --- /dev/null +++ b/app/services/orchestration/simulator.py @@ -0,0 +1,969 @@ +"""Safe Event Orchestration simulation, replay and shadow analytics. + +The simulator evaluates isolated event copies. It never calls the alert +lifecycle, persists orchestration executions, creates pending events, or +queues webhook actions. +""" + +from __future__ import annotations + +import copy +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from app.modules.common import parse_datetime, utc_now +from app.modules.db import orchestrations_repo +from app.modules.db.models import ( + Alert, + EventOrchestration, + EventOrchestrationVersion, + OrchestrationExecution, +) +from app.services.integrations.normalizers.registry import ( + SUPPORTED_NORMALIZER_SOURCES, + UnknownNormalizerSource, + normalize_for_source, +) +from app.services.orchestration.engine import execute_rule_tree +from app.services.orchestration.runtime import ( + RuntimeOrchestrationError, + _build_runtime_context, + _initial_entities, + _resolve_selected_entities, +) +from app.services.orchestration.safety import ( + OrchestrationJsonError, + ensure_json_size, + safe_trace_value, +) +from app.settings import Config + + +SUPPORTED_SIMULATION_SOURCES = SUPPORTED_NORMALIZER_SOURCES + + +class OrchestrationSimulationError(ValueError): + """Base error for invalid or inaccessible simulation inputs.""" + + +class OrchestrationSimulationNotFound(OrchestrationSimulationError): + pass + + +class OrchestrationSimulationConflict(OrchestrationSimulationError): + pass + + +@dataclass(frozen=True) +class ReplayInput: + kind: str + id: int + event: Dict[str, Any] + + + + +def _iso_datetime(value: Any) -> Optional[str]: + parsed = parse_datetime(value) + return parsed.isoformat() if parsed is not None else None + + +def _ensure_payload_size(value: Any, *, label: str) -> None: + maximum = max( + 1024, + int( + getattr( + Config, + "ORCHESTRATION_SIMULATION_MAX_PAYLOAD_BYTES", + 1048576, + ) + ), + ) + try: + ensure_json_size(value, maximum_bytes=maximum, label=label) + except OrchestrationJsonError as exc: + message = str(exc) + if " exceeds the " in message: + message = f"{label} exceeds the {maximum}-byte simulation limit" + raise OrchestrationSimulationError(message) from exc + + +def get_orchestration(orchestration_id: int) -> EventOrchestration: + row = EventOrchestration.get_or_none( + (EventOrchestration.id == orchestration_id) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + if row is None: + raise OrchestrationSimulationNotFound("Orchestration not found") + return row + + +def get_simulation_version( + orchestration: EventOrchestration, + version_id: Optional[int] = None, +) -> EventOrchestrationVersion: + if version_id is not None: + version = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == int(version_id) + ) + if version is None or version.orchestration_id != orchestration.id: + raise OrchestrationSimulationNotFound( + "Orchestration version not found" + ) + return version + + draft = orchestrations_repo.get_draft(orchestration.id) + if draft is not None: + return draft + + if orchestration.active_version_id is not None: + version = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == orchestration.active_version_id + ) + if version is not None and version.orchestration_id == orchestration.id: + return version + + raise OrchestrationSimulationConflict( + "Orchestration has no draft or published version to simulate" + ) + + +def normalize_simulation_payload( + *, + source: str, + payload: Any, + headers: Optional[Mapping[str, Any]] = None, + event_index: int = 0, +) -> Tuple[str, Dict[str, Any], int]: + source = str(source or "").strip().lower() + if source not in SUPPORTED_SIMULATION_SOURCES: + raise OrchestrationSimulationError( + "Unsupported simulation source: " + (source or "missing") + ) + if not isinstance(payload, Mapping): + raise OrchestrationSimulationError("Simulation payload must be an object") + _ensure_payload_size(payload, label="Simulation payload") + + try: + events = normalize_for_source( + source, + payload, + headers=headers, + route_config={}, + copy_payload=True, + ) + except UnknownNormalizerSource as exc: + raise OrchestrationSimulationError(str(exc)) from exc + except Exception as exc: + raise OrchestrationSimulationError( + f"{source} payload could not be normalized" + ) from exc + + if isinstance(events, Mapping): + events = [events] + if not isinstance(events, list) or not events: + raise OrchestrationSimulationError( + "The selected normalizer produced no events" + ) + if event_index < 0 or event_index >= len(events): + raise OrchestrationSimulationError( + f"event_index must be between 0 and {len(events) - 1}" + ) + + event = events[event_index] + if not isinstance(event, Mapping): + raise OrchestrationSimulationError( + "The selected normalizer produced an invalid event" + ) + normalized = copy.deepcopy(dict(event)) + normalized.setdefault("source", source) + _ensure_payload_size(normalized, label="Normalized event") + _validate_normalized_event(normalized) + return source, normalized, len(events) + + +def prepare_normalized_event(event: Mapping[str, Any]) -> Dict[str, Any]: + if not isinstance(event, Mapping): + raise OrchestrationSimulationError("normalized_event must be an object") + normalized = copy.deepcopy(dict(event)) + _ensure_payload_size(normalized, label="Normalized event") + _validate_normalized_event(normalized) + return normalized + + +def _validate_normalized_event(event: Mapping[str, Any]) -> None: + missing = [ + key + for key in ("source", "dedup_key", "title") + if event.get(key) in (None, "") + ] + if missing: + raise OrchestrationSimulationError( + "Normalized event is missing: " + ", ".join(missing) + ) + labels = event.get("labels") + if labels is not None and not isinstance(labels, Mapping): + raise OrchestrationSimulationError( + "normalized_event.labels must be an object" + ) + + +def _entity_summary(value: Any) -> Optional[Dict[str, Any]]: + if value is None: + return None + return { + "id": getattr(value, "id", None), + "name": getattr(value, "name", None), + "slug": getattr(value, "slug", None), + } + + +def _selected_summary( + context: Mapping[str, Any], + *, + orchestration: EventOrchestration, + event: Mapping[str, Any], + initial_route: Any, + initial_team: Any, + initial_service: Any, +) -> Tuple[Dict[str, Any], List[str]]: + errors: List[str] = [] + try: + ( + route, + team, + service, + escalation, + priority, + notification, + grouping, + ) = _resolve_selected_entities( + context, + group_id=orchestration.group_id, + source=event.get("source"), + fallback_route=initial_route, + fallback_team=initial_team, + fallback_service=initial_service, + ) + except RuntimeOrchestrationError as exc: + errors.append(str(exc)) + result = context.get("result") or {} + return { + "routing": copy.deepcopy(result.get("routing") or {}), + "policies": copy.deepcopy(result.get("policies") or {}), + "grouping": copy.deepcopy(result.get("grouping") or {}), + }, errors + + return { + "route": _entity_summary(route), + "team": _entity_summary(team), + "service": _entity_summary(service), + "escalation_policy": _entity_summary(escalation), + "priority_policy": _entity_summary(priority), + "notification_policy": _entity_summary(notification), + "grouping": copy.deepcopy(grouping or {}), + }, errors + + +def _disposition(context: Mapping[str, Any]) -> Dict[str, Any]: + result = context.get("result") or {} + disposition = result.get("disposition") or "process" + reason = None + if disposition == "drop": + reason = result.get("drop_reason") + elif disposition == "suppress": + reason = result.get("suppress_reason") + elif disposition == "pause": + reason = result.get("pause_reason") + return { + "type": disposition, + "reason": reason, + "pause_seconds": result.get("pause_seconds"), + "pause_retrigger": result.get("pause_retrigger"), + } + + +def _flatten( + value: Any, + *, + path: str = "", + output: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + if output is None: + output = {} + if isinstance(value, Mapping): + if not value: + output[path or "$"] = {} + for key in sorted(value, key=lambda item: str(item)): + child = f"{path}.{key}" if path else str(key) + _flatten(value[key], path=child, output=output) + return output + if isinstance(value, list): + if not value: + output[path or "$"] = [] + for index, item in enumerate(value): + child = f"{path}[{index}]" if path else f"[{index}]" + _flatten(item, path=child, output=output) + return output + output[path or "$"] = value + return output + + +def context_diff( + before: Mapping[str, Any], + after: Mapping[str, Any], + *, + limit: Optional[int] = None, +) -> Dict[str, Any]: + maximum = min( + max( + int( + limit + or getattr(Config, "ORCHESTRATION_SIMULATION_MAX_DIFFS", 512) + ), + 1, + ), + 5000, + ) + left = _flatten(before) + right = _flatten(after) + changes = [] + for path in sorted(set(left) | set(right)): + old = left.get(path) + new = right.get(path) + if old == new: + continue + changes.append( + { + "path": path, + "before": safe_trace_value(old), + "after": safe_trace_value(new), + } + ) + if len(changes) >= maximum: + break + total = sum( + 1 + for path in set(left) | set(right) + if left.get(path) != right.get(path) + ) + return { + "changed": bool(total), + "total_changes": total, + "truncated": total > len(changes), + "changes": changes, + } + + +def _simulate_version( + orchestration: EventOrchestration, + version: EventOrchestrationVersion, + event: Mapping[str, Any], + *, + evaluated_at: str, +) -> Dict[str, Any]: + validation = orchestrations_repo.validate_version(version.id) + response: Dict[str, Any] = { + "orchestration_id": orchestration.id, + "version_id": version.id, + "version_number": version.version_number, + "version_status": version.status, + "validation": validation, + } + if not validation.get("valid"): + response["executed"] = False + response["errors"] = list(validation.get("errors") or []) + return response + + normalized = prepare_normalized_event(event) + try: + route, team, service = _initial_entities( + normalized, + orchestration.group_id, + ) + except RuntimeOrchestrationError as exc: + response.update( + { + "executed": False, + "errors": [str(exc)], + "initial_normalized_event": safe_trace_value(normalized), + } + ) + return response + + if service is None and orchestration.scope == "service": + service = orchestration.service + if team is None and service is not None: + team = service.team + + context = _build_runtime_context( + normalized, + route=route, + team=team, + service=service, + ) + context.setdefault("time", {})["now"] = evaluated_at + definition = orchestrations_repo.export_version(version.id) + started = time.perf_counter() + try: + result = execute_rule_tree(definition.get("rules") or [], context) + except Exception as exc: + response.update( + { + "executed": False, + "errors": [exc.__class__.__name__], + "initial_normalized_event": safe_trace_value(normalized), + "initial_context": safe_trace_value(context), + } + ) + return response + + duration_ms = max(0, int((time.perf_counter() - started) * 1000)) + selected, selection_errors = _selected_summary( + result.context, + orchestration=orchestration, + event=normalized, + initial_route=route, + initial_team=team, + initial_service=service, + ) + response.update( + { + "executed": True, + "duration_ms": duration_ms, + "initial_normalized_event": safe_trace_value(normalized), + "initial_context": safe_trace_value(context), + "execution": safe_trace_value(result.to_dict()), + "final_context": safe_trace_value(result.context), + "selected": safe_trace_value(selected), + "disposition": safe_trace_value(_disposition(result.context)), + "errors": selection_errors, + } + ) + return response + + +def _simulate_resolved_version( + orchestration: EventOrchestration, + version: EventOrchestrationVersion, + event: Mapping[str, Any], + *, + active_version: Optional[EventOrchestrationVersion] = None, + compare_with_active: bool = False, + selected_normalizer: str = "normalized", + normalized_event_count: int = 1, + evaluated_at: Optional[str] = None, +) -> Dict[str, Any]: + evaluated_at = evaluated_at or utc_now().isoformat() + result = _simulate_version( + orchestration, + version, + event, + evaluated_at=evaluated_at, + ) + result["evaluated_at"] = evaluated_at + result["selected_normalizer"] = selected_normalizer + result["normalized_event_count"] = normalized_event_count + + if compare_with_active and active_version is not None: + active_result = _simulate_version( + orchestration, + active_version, + event, + evaluated_at=evaluated_at, + ) + active_result["evaluated_at"] = evaluated_at + result["active"] = active_result + if result.get("executed") and active_result.get("executed"): + result["active_draft_diff"] = context_diff( + active_result.get("final_context") or {}, + result.get("final_context") or {}, + ) + return safe_trace_value(result) + + +def _active_version( + orchestration: EventOrchestration, +) -> Optional[EventOrchestrationVersion]: + if orchestration.active_version_id is None: + return None + active = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == orchestration.active_version_id + ) + if active is None or active.orchestration_id != orchestration.id: + return None + return active + + +def simulate_event( + orchestration_id: int, + event: Mapping[str, Any], + *, + version_id: Optional[int] = None, + compare_with_active: bool = False, + selected_normalizer: str = "normalized", + normalized_event_count: int = 1, +) -> Dict[str, Any]: + orchestration = get_orchestration(orchestration_id) + version = get_simulation_version(orchestration, version_id) + active = _active_version(orchestration) if compare_with_active else None + return _simulate_resolved_version( + orchestration, + version, + event, + active_version=active, + compare_with_active=compare_with_active, + selected_normalizer=selected_normalizer, + normalized_event_count=normalized_event_count, + ) + + +def _entity_group_id( + value: Any, + *, + depth: int = 0, + seen: Optional[set[int]] = None, +) -> Optional[int]: + """Resolve a group through team/route/service relationships safely.""" + if value is None or depth > 3: + return None + seen = seen or set() + identity = id(value) + if identity in seen: + return None + seen.add(identity) + + group_id = getattr(value, "group_id", None) + if group_id is not None: + return int(group_id) + + for attribute in ("team", "route", "service", "group"): + try: + related = getattr(value, attribute, None) + except Exception: + continue + resolved = _entity_group_id( + related, + depth=depth + 1, + seen=seen, + ) + if resolved is not None: + return resolved + return None + + +def _alert_group_id(alert: Alert) -> Optional[int]: + # Alert.group_id points to AlertGroup, not to the tenant Group. Resolve + # ownership only through the alert's related routing/service entities. + for attribute in ("team", "route", "service", "group"): + try: + related = getattr(alert, attribute, None) + except Exception: + continue + resolved = _entity_group_id(related) + if resolved is not None: + return resolved + return None + + +def event_from_alert(alert: Alert) -> Dict[str, Any]: + event = { + "source": alert.source, + "external_id": alert.external_id, + "dedup_key": alert.dedup_key, + "group_key": alert.group_key, + "title": alert.title, + "message": alert.message, + "severity": alert.severity, + "labels": copy.deepcopy(alert.labels or {}), + "payload": copy.deepcopy(alert.payload or {}), + "status": alert.status, + } + if alert.route_id: + event["forced_route_id"] = alert.route_id + if alert.team_id: + event["forced_team_id"] = alert.team_id + if alert.service_id: + event["service_id"] = alert.service_id + return event + + +def event_from_execution(execution: OrchestrationExecution) -> Dict[str, Any]: + trace = execution.trace_json or {} + context = trace.get("initial_context") or {} + event = copy.deepcopy(context.get("event") or {}) + if context.get("raw") and not event.get("payload"): + event["payload"] = copy.deepcopy(context.get("raw")) + event.setdefault("source", execution.source or "webhook") + event.setdefault( + "dedup_key", + execution.event_fingerprint or f"execution:{execution.id}", + ) + event.setdefault("title", f"Replay execution {execution.id}") + event.setdefault("labels", {}) + return prepare_normalized_event(event) + + +def load_replay_inputs( + orchestration: EventOrchestration, + *, + alert_ids: Sequence[int] = (), + execution_ids: Sequence[int] = (), +) -> List[ReplayInput]: + maximum = min( + max( + int(getattr(Config, "ORCHESTRATION_REPLAY_MAX_EVENTS", 100)), + 1, + ), + 1000, + ) + identifiers = len(alert_ids) + len(execution_ids) + if identifiers < 1: + raise OrchestrationSimulationError( + "Replay requires at least one alert_id or execution_id" + ) + if identifiers > maximum: + raise OrchestrationSimulationError( + f"Replay is limited to {maximum} events" + ) + + inputs: List[ReplayInput] = [] + for alert_id in alert_ids: + alert = Alert.get_or_none(Alert.id == int(alert_id)) + if alert is None: + raise OrchestrationSimulationNotFound( + f"Alert {alert_id} not found" + ) + if _alert_group_id(alert) != orchestration.group_id: + raise OrchestrationSimulationError( + f"Alert {alert_id} belongs to another group" + ) + inputs.append(ReplayInput("alert", alert.id, event_from_alert(alert))) + + for execution_id in execution_ids: + execution = OrchestrationExecution.get_or_none( + OrchestrationExecution.id == int(execution_id) + ) + if execution is None: + raise OrchestrationSimulationNotFound( + f"Execution {execution_id} not found" + ) + if execution.group_id != orchestration.group_id: + raise OrchestrationSimulationError( + f"Execution {execution_id} belongs to another group" + ) + inputs.append( + ReplayInput( + "execution", + execution.id, + event_from_execution(execution), + ) + ) + return inputs + + +def replay_events( + orchestration_id: int, + *, + alert_ids: Sequence[int] = (), + execution_ids: Sequence[int] = (), + version_id: Optional[int] = None, + compare_with_active: bool = False, +) -> Dict[str, Any]: + orchestration = get_orchestration(orchestration_id) + inputs = load_replay_inputs( + orchestration, + alert_ids=alert_ids, + execution_ids=execution_ids, + ) + version = get_simulation_version(orchestration, version_id) + active = _active_version(orchestration) if compare_with_active else None + evaluated_at = utc_now().isoformat() + results = [] + for item in inputs: + try: + simulation = _simulate_resolved_version( + orchestration, + version, + item.event, + active_version=active, + compare_with_active=compare_with_active, + selected_normalizer="stored_normalized_event", + evaluated_at=evaluated_at, + ) + results.append( + { + "input": {"kind": item.kind, "id": item.id}, + "ok": True, + "simulation": simulation, + } + ) + except Exception as exc: + results.append( + { + "input": {"kind": item.kind, "id": item.id}, + "ok": False, + "error": exc.__class__.__name__, + } + ) + successful = [item for item in results if item["ok"]] + dispositions: Dict[str, int] = {} + changed_from_active = 0 + for item in successful: + simulation = item.get("simulation") or {} + disposition = (simulation.get("disposition") or {}).get("type") or "process" + dispositions[disposition] = dispositions.get(disposition, 0) + 1 + if (simulation.get("active_draft_diff") or {}).get("changed"): + changed_from_active += 1 + + drop_count = dispositions.get("drop", 0) + drop_percentage = ( + round((drop_count / len(successful)) * 100, 2) + if successful + else 0.0 + ) + warning_threshold = min( + max( + int( + getattr( + Config, + "ORCHESTRATION_REPLAY_DROP_WARNING_PERCENT", + 20, + ) + ), + 0, + ), + 100, + ) + warnings = [] + if successful and drop_percentage >= warning_threshold and drop_count: + warnings.append( + { + "code": "high_drop_rate", + "message": ( + f"Draft would drop {drop_percentage}% of replayed events" + ), + "drop_percentage": drop_percentage, + "threshold_percentage": warning_threshold, + } + ) + + return { + "orchestration_id": orchestration.id, + "version_id": version.id, + "active_version_id": active.id if active is not None else None, + "evaluated_at": evaluated_at, + "count": len(results), + "successful": len(successful), + "failed": sum(1 for item in results if not item["ok"]), + "production_state_modified": False, + "summary": { + "dispositions": dispositions, + "drop_percentage": drop_percentage, + "changed_from_active": changed_from_active, + }, + "warnings": warnings, + "results": results, + } + + +def serialize_execution( + execution: OrchestrationExecution, + *, + include_trace: bool = False, +) -> Dict[str, Any]: + created_at = execution.created_at + expires_at = execution.expires_at + trace = execution.trace_json or {} + result = { + "id": execution.id, + "uid": str(execution.uid), + "group_id": execution.group_id, + "orchestration_id": execution.orchestration_id, + "version_id": execution.version_id, + "source": execution.source, + "integration_name": execution.integration_name, + "event_fingerprint": execution.event_fingerprint, + "mode": trace.get("mode"), + "compatibility_mode": trace.get("compatibility_mode"), + "applied": bool(trace.get("applied")), + "error": safe_trace_value(trace.get("error")), + "rejected_reason": safe_trace_value(trace.get("rejected_reason")), + "disposition": execution.disposition, + "matched_rule_count": execution.matched_rule_count, + "duration_ms": execution.duration_ms, + "alert_id": execution.alert_id, + "alert_group_id": execution.alert_group_id, + "created_at": _iso_datetime(created_at), + "expires_at": _iso_datetime(expires_at), + } + if include_trace: + result["trace"] = safe_trace_value(execution.trace_json or {}) + return result + + +def list_executions( + orchestration_id: int, + *, + limit: int = 50, + include_trace: bool = False, +) -> List[Dict[str, Any]]: + orchestration = get_orchestration(orchestration_id) + maximum = min(max(int(limit), 1), 200) + rows = ( + OrchestrationExecution.select() + .where(OrchestrationExecution.orchestration == orchestration.id) + .order_by(OrchestrationExecution.id.desc()) + .limit(maximum) + ) + return [ + serialize_execution(row, include_trace=include_trace) + for row in rows + ] + + +def _candidate_actual_values(trace: Mapping[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + result = (trace.get("result") or {}).get("context") or {} + candidate_result = result.get("result") or {} + candidate_routing = candidate_result.get("routing") or {} + candidate_event = result.get("event") or {} + candidate = { + "route_id": ( + candidate_routing.get("route_id") + or (result.get("route") or {}).get("id") + ), + "team_id": ( + candidate_routing.get("team_id") + or (result.get("team") or {}).get("id") + ), + "service_id": ( + candidate_routing.get("service_id") + or (result.get("service") or {}).get("id") + ), + "severity": candidate_event.get("severity"), + "title": candidate_event.get("title"), + "group_key": ( + (candidate_result.get("grouping") or {}).get("group_key") + or candidate_event.get("group_key") + ), + "disposition": candidate_result.get("disposition") or "process", + } + actual = copy.deepcopy(trace.get("actual_result") or {}) + return candidate, actual + + +def shadow_metrics( + orchestration_id: int, + *, + limit: Optional[int] = None, +) -> Dict[str, Any]: + orchestration = get_orchestration(orchestration_id) + maximum = min( + max( + int( + limit + or getattr( + Config, + "ORCHESTRATION_SHADOW_METRICS_MAX_EXECUTIONS", + 5000, + ) + ), + 1, + ), + 10000, + ) + rows = ( + OrchestrationExecution.select() + .where(OrchestrationExecution.orchestration == orchestration.id) + .order_by(OrchestrationExecution.id.desc()) + .limit(maximum) + ) + metrics = { + "executions": 0, + "matched": 0, + "errors": 0, + "rejected": 0, + "comparable_executions": 0, + "not_comparable": 0, + "routing_changes": 0, + "team_changes": 0, + "service_changes": 0, + "severity_changes": 0, + "title_changes": 0, + "grouping_changes": 0, + "potential_drops": 0, + "potential_suppressions": 0, + "potential_pauses": 0, + } + first_at = None + last_at = None + for row in rows: + trace = row.trace_json or {} + if trace.get("mode") != "shadow": + continue + metrics["executions"] += 1 + if row.matched_rule_count: + metrics["matched"] += 1 + execution_error = trace.get("error") + rejected_reason = trace.get("rejected_reason") + if execution_error: + metrics["errors"] += 1 + if rejected_reason: + metrics["rejected"] += 1 + + candidate, actual = _candidate_actual_values(trace) + comparable = bool(trace.get("result")) and not execution_error and not rejected_reason + if actual and comparable: + metrics["comparable_executions"] += 1 + if candidate.get("route_id") != actual.get("route_id"): + metrics["routing_changes"] += 1 + if candidate.get("team_id") != actual.get("team_id"): + metrics["team_changes"] += 1 + if candidate.get("service_id") != actual.get("service_id"): + metrics["service_changes"] += 1 + if candidate.get("severity") != actual.get("severity"): + metrics["severity_changes"] += 1 + if candidate.get("title") != actual.get("title"): + metrics["title_changes"] += 1 + if candidate.get("group_key") != actual.get("group_key"): + metrics["grouping_changes"] += 1 + else: + metrics["not_comparable"] += 1 + disposition = candidate.get("disposition") + if disposition == "drop": + metrics["potential_drops"] += 1 + elif disposition == "suppress": + metrics["potential_suppressions"] += 1 + elif disposition == "pause": + metrics["potential_pauses"] += 1 + created_at = parse_datetime(row.created_at) + if created_at: + first_at = min(first_at, created_at) if first_at else created_at + last_at = max(last_at, created_at) if last_at else created_at + + return { + "orchestration_id": orchestration.id, + "mode": orchestration.mode, + "limit": maximum, + "first_execution_at": first_at.isoformat() if first_at else None, + "last_execution_at": last_at.isoformat() if last_at else None, + "metrics": metrics, + } + + +__all__ = [ + "SUPPORTED_SIMULATION_SOURCES", + "OrchestrationSimulationConflict", + "OrchestrationSimulationError", + "OrchestrationSimulationNotFound", + "context_diff", + "get_orchestration", + "list_executions", + "normalize_simulation_payload", + "prepare_normalized_event", + "replay_events", + "shadow_metrics", + "simulate_event", +] diff --git a/app/services/orchestration/templates.py b/app/services/orchestration/templates.py new file mode 100644 index 0000000..41c6d29 --- /dev/null +++ b/app/services/orchestration/templates.py @@ -0,0 +1,259 @@ +"""A restricted interpolation engine for Event Orchestration. + +This is deliberately not Jinja. Expressions are limited to safe field +references followed by whitelisted string filters with literal arguments. +""" + +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Sequence, Tuple + +from .errors import TemplateValidationError, ValidationIssue +from .fields import MISSING, normalize_field_reference, resolve_field +from .limits import ( + MAX_TEMPLATE_EXPRESSION_LENGTH, + MAX_TEMPLATE_EXPRESSIONS, + MAX_TEMPLATE_FILTERS, + MAX_TEMPLATE_LENGTH, + MAX_TEMPLATE_OUTPUT_LENGTH, +) + + +_EXPRESSION = re.compile(r"{{(.*?)}}", re.DOTALL) +_FILTER = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$", re.DOTALL) +_ALLOWED_FILTERS = frozenset({"lower", "upper", "trim", "default", "replace", "truncate"}) + + +@dataclass(frozen=True) +class ParsedExpression: + field: str + filters: Tuple[Tuple[str, Tuple[Any, ...]], ...] + + +@dataclass(frozen=True) +class TemplateRenderResult: + value: str + references: Tuple[str, ...] + + def to_dict(self) -> Dict[str, Any]: + return {"value": self.value, "references": list(self.references)} + + +def _split_pipeline(expression: str) -> List[str]: + parts: List[str] = [] + start = 0 + quote: str | None = None + escaped = False + depth = 0 + for index, char in enumerate(expression): + if escaped: + escaped = False + continue + if char == "\\" and quote is not None: + escaped = True + continue + if quote is not None: + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth < 0: + raise TemplateValidationError("unbalanced filter parentheses") + elif char == "|" and depth == 0: + parts.append(expression[start:index].strip()) + start = index + 1 + if quote is not None or depth != 0: + raise TemplateValidationError("unbalanced template expression") + parts.append(expression[start:].strip()) + return parts + + +def _literal_arguments(raw: str | None) -> Tuple[Any, ...]: + if raw is None or not raw.strip(): + return () + try: + value = ast.literal_eval(f"({raw},)") + except (SyntaxError, ValueError) as exc: + raise TemplateValidationError("filter arguments must be string, number, boolean or null literals") from exc + if not isinstance(value, tuple): # pragma: no cover + raise TemplateValidationError("invalid filter arguments") + for item in value: + if not isinstance(item, (str, int, float, bool, type(None))): + raise TemplateValidationError("filter arguments cannot contain collections or objects") + return value + + +def _validate_filter(name: str, args: Tuple[Any, ...]) -> None: + if name not in _ALLOWED_FILTERS: + raise TemplateValidationError(f"unsupported template filter {name!r}") + required = { + "lower": (0, 0), + "upper": (0, 0), + "trim": (0, 0), + "default": (1, 1), + "replace": (2, 2), + "truncate": (1, 1), + }[name] + if not required[0] <= len(args) <= required[1]: + raise TemplateValidationError( + f"filter {name} expects {required[0]} argument(s)" + ) + if name == "replace" and not all(isinstance(arg, str) for arg in args): + raise TemplateValidationError("replace arguments must be strings") + if name == "truncate": + length = args[0] + if isinstance(length, bool) or not isinstance(length, int) or length < 0: + raise TemplateValidationError("truncate length must be a non-negative integer") + if length > MAX_TEMPLATE_OUTPUT_LENGTH: + raise TemplateValidationError("truncate length exceeds the output limit") + + +def parse_expression(expression: str) -> ParsedExpression: + expression = expression.strip() + if not expression: + raise TemplateValidationError("template expression cannot be empty") + if len(expression) > MAX_TEMPLATE_EXPRESSION_LENGTH: + raise TemplateValidationError("template expression exceeds the size limit") + parts = _split_pipeline(expression) + field = normalize_field_reference(parts[0]) + if len(parts) - 1 > MAX_TEMPLATE_FILTERS: + raise TemplateValidationError("template expression has too many filters") + + filters: List[Tuple[str, Tuple[Any, ...]]] = [] + for raw_filter in parts[1:]: + match = _FILTER.fullmatch(raw_filter) + if not match: + raise TemplateValidationError(f"invalid template filter syntax {raw_filter!r}") + name = match.group(1) + args = _literal_arguments(match.group(2)) + _validate_filter(name, args) + filters.append((name, args)) + return ParsedExpression(field=field, filters=tuple(filters)) + + +def validate_template(template: Any, *, path: str = "template") -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + if not isinstance(template, str): + return [ValidationIssue(path, "invalid_template_type", "template must be a string")] + if len(template) > MAX_TEMPLATE_LENGTH: + return [ValidationIssue(path, "template_size_limit", "template exceeds the size limit")] + + matches = list(_EXPRESSION.finditer(template)) + if len(matches) > MAX_TEMPLATE_EXPRESSIONS: + issues.append( + ValidationIssue(path, "template_expression_limit", "template has too many expressions") + ) + # Any leftover delimiter indicates malformed syntax. + stripped = _EXPRESSION.sub("", template) + if "{{" in stripped or "}}" in stripped: + issues.append( + ValidationIssue(path, "unbalanced_template_delimiter", "template contains unbalanced delimiters") + ) + + for index, match in enumerate(matches): + try: + parse_expression(match.group(1)) + except (TemplateValidationError, ValueError) as exc: + code = getattr(exc, "code", "invalid_template") + issues.append( + ValidationIssue(f"{path}.expression[{index}]", code, str(exc)) + ) + return issues + + +def _stringify(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + raise TemplateValidationError("templates can only render scalar values") + + +def _apply_filter(value: Any, name: str, args: Sequence[Any], *, missing: bool) -> Tuple[Any, bool]: + if name == "default": + if missing or value is None or value == "": + return args[0], False + return value, missing + if missing: + return value, missing + text = _stringify(value) + if name == "lower": + return text.lower(), False + if name == "upper": + return text.upper(), False + if name == "trim": + return text.strip(), False + if name == "replace": + return text.replace(args[0], args[1]), False + if name == "truncate": + return text[: args[0]], False + raise TemplateValidationError(f"unsupported template filter {name!r}") + + +def render_template( + template: str, + context: Mapping[str, Any], + *, + max_output_length: int = MAX_TEMPLATE_OUTPUT_LENGTH, +) -> TemplateRenderResult: + issues = validate_template(template) + if issues: + first = issues[0] + raise TemplateValidationError(first.message, path=first.path) + if max_output_length < 0 or max_output_length > MAX_TEMPLATE_OUTPUT_LENGTH: + raise TemplateValidationError("invalid template output limit") + + references: List[str] = [] + output: List[str] = [] + cursor = 0 + length = 0 + + def append(value: str) -> None: + nonlocal length + length += len(value) + if length > max_output_length: + raise TemplateValidationError("rendered template exceeds the output limit") + output.append(value) + + for match in _EXPRESSION.finditer(template): + append(template[cursor : match.start()]) + parsed = parse_expression(match.group(1)) + references.append(parsed.field) + resolution = resolve_field(context, parsed.field) + value = resolution.value + missing = not resolution.found + for name, args in parsed.filters: + value, missing = _apply_filter(value, name, args, missing=missing) + if missing: + raise TemplateValidationError( + f"template field {parsed.field!r} does not exist", + path=parsed.field, + ) + append(_stringify(value)) + cursor = match.end() + append(template[cursor:]) + return TemplateRenderResult("".join(output), tuple(references)) + + +def find_templates(value: Any, *, path: str = "value") -> Iterable[Tuple[str, str]]: + """Yield ``(path, template)`` for strings containing template syntax.""" + + if isinstance(value, str): + if "{{" in value or "}}" in value: + yield path, value + elif isinstance(value, dict): + for key, child in value.items(): + yield from find_templates(child, path=f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + yield from find_templates(child, path=f"{path}[{index}]") diff --git a/app/services/orchestration/validation.py b/app/services/orchestration/validation.py new file mode 100644 index 0000000..c3332fc --- /dev/null +++ b/app/services/orchestration/validation.py @@ -0,0 +1,59 @@ +"""Publication-time validation for orchestration conditions and templates.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +# EVENT ORCHESTRATION WS3 ACTION VALIDATION +from .actions import validate_action_list +from .conditions import validate_condition_tree +from .errors import ValidationIssue +from .templates import find_templates, validate_template +from .variables import EXTRACTION_TYPES, validate_extractor + + +def validate_rule_definition( + condition_tree: Any, + actions: Any, + *, + path: str = "rule", +) -> Dict[str, List[ValidationIssue]]: + errors: List[ValidationIssue] = [] + warnings: List[ValidationIssue] = [] + + errors.extend(validate_condition_tree(condition_tree, path=f"{path}.condition_tree")) + if not isinstance(actions, list): + errors.append( + ValidationIssue(f"{path}.actions", "invalid_actions", "actions must be a list") + ) + return {"errors": errors, "warnings": warnings} + + errors.extend(validate_action_list(actions, path=f"{path}.actions")) + + for index, action in enumerate(actions): + action_path = f"{path}.actions[{index}]" + if not isinstance(action, dict): + errors.append( + ValidationIssue(action_path, "invalid_action", "action must be an object") + ) + continue + if action.get("type") in EXTRACTION_TYPES: + errors.extend(validate_extractor(action, path=action_path)) + for template_path, template in find_templates(action, path=action_path): + errors.extend(validate_template(template, path=template_path)) + + def dedupe(items: List[ValidationIssue]) -> List[ValidationIssue]: + result: List[ValidationIssue] = [] + seen = set() + for item in items: + key = (item.path, item.code, item.message, item.severity) + if key not in seen: + seen.add(key) + result.append(item) + return result + + return {"errors": dedupe(errors), "warnings": dedupe(warnings)} + + +def issues_to_messages(issues: List[ValidationIssue]) -> List[str]: + return [f"{issue.path}: {issue.message}" for issue in issues] diff --git a/app/services/orchestration/variables.py b/app/services/orchestration/variables.py new file mode 100644 index 0000000..0e9e6b3 --- /dev/null +++ b/app/services/orchestration/variables.py @@ -0,0 +1,435 @@ +"""Restricted variable extraction for Event Orchestration.""" + +from __future__ import annotations + +import copy +import json +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple + +from .errors import ExtractionError, RegexSafetyError, ValidationIssue +from .fields import MISSING, normalize_field_reference, resolve_field +from .limits import ( + MAX_JSON_PATH_LENGTH, + MAX_SPLIT_PARTS, + MAX_VARIABLE_NAME_LENGTH, + MAX_VARIABLE_VALUE_LENGTH, + MAX_VARIABLES, +) +from .regex import bounded_regex_input, compile_safe_regex, validate_regex_pattern +from .templates import render_template, validate_template + + +VARIABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +EXTRACTION_TYPES = frozenset( + { + "extract_regex", + "copy_field", + "copy_to_variable", + "json_path", + "split", + "set_variable", + "static", + "lowercase", + "uppercase", + "trim", + } +) +FAILURE_MODES = frozenset({"continue", "stop_rule", "stop_orchestration"}) + + +@dataclass(frozen=True) +class ExtractionStepResult: + index: int + extractor_type: str + success: bool + code: str + reason: str + variables: Mapping[str, Any] + failure_mode: str = "continue" + + def to_dict(self) -> Dict[str, Any]: + return { + "index": self.index, + "type": self.extractor_type, + "success": self.success, + "code": self.code, + "reason": self.reason, + "variables": dict(self.variables), + "failure_mode": self.failure_mode, + } + + +@dataclass(frozen=True) +class ExtractionResult: + variables: Mapping[str, Any] + steps: Tuple[ExtractionStepResult, ...] + outcome: str = "continue" + + def to_dict(self) -> Dict[str, Any]: + return { + "variables": dict(self.variables), + "steps": [step.to_dict() for step in self.steps], + "outcome": self.outcome, + } + + +def _validate_variable_name(name: Any) -> Optional[str]: + if not isinstance(name, str) or not name: + return "variable name is required" + if len(name) > MAX_VARIABLE_NAME_LENGTH: + return "variable name exceeds the size limit" + if not VARIABLE_NAME.fullmatch(name): + return "variable name must start with a letter or underscore and contain only letters, numbers and underscores" + return None + + +def _extractor_target(extractor: Mapping[str, Any]) -> Any: + return extractor.get("name", extractor.get("target")) + + +def _validate_json_path(path: Any) -> Optional[str]: + if not isinstance(path, str) or not path: + return "JSON path is required" + if len(path) > MAX_JSON_PATH_LENGTH: + return "JSON path exceeds the size limit" + try: + _parse_json_path(path) + except ExtractionError as exc: + return str(exc) + return None + + +def validate_extractor(extractor: Any, *, path: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + if not isinstance(extractor, dict): + return [ValidationIssue(path, "invalid_extractor", "extractor must be an object")] + extractor_type = extractor.get("type") + if extractor_type not in EXTRACTION_TYPES: + return [ + ValidationIssue( + f"{path}.type", + "unsupported_extractor", + f"unsupported variable extractor {extractor_type!r}", + ) + ] + failure_mode = extractor.get("on_failure", "continue") + if failure_mode not in FAILURE_MODES: + issues.append( + ValidationIssue( + f"{path}.on_failure", + "invalid_failure_mode", + "on_failure must be continue, stop_rule or stop_orchestration", + ) + ) + + if extractor_type == "extract_regex": + source = extractor.get("source") + try: + normalize_field_reference(source) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + compiled = None + try: + normalized_pattern = validate_regex_pattern(extractor.get("pattern")) + compiled = re.compile(normalized_pattern) + except RegexSafetyError as exc: + issues.append(ValidationIssue(f"{path}.pattern", exc.code, str(exc))) + target = _extractor_target(extractor) + if target is not None: + message = _validate_variable_name(target) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + group = extractor.get("group") + if group is not None and compiled is not None: + if isinstance(group, bool) or not isinstance(group, (int, str)): + issues.append(ValidationIssue(f"{path}.group", "invalid_regex_group", "regex group must be an integer index or named group")) + elif isinstance(group, int) and (group < 0 or group > compiled.groups): + issues.append(ValidationIssue(f"{path}.group", "invalid_regex_group", "regex group index does not exist")) + elif isinstance(group, str) and group not in compiled.groupindex: + issues.append(ValidationIssue(f"{path}.group", "invalid_regex_group", "named regex group does not exist")) + elif compiled is not None and not compiled.groupindex: + issues.append(ValidationIssue(path, "missing_regex_targets", "regex extraction without a target requires named groups")) + elif extractor_type in {"copy_field", "copy_to_variable"}: + try: + normalize_field_reference(extractor.get("source")) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + elif extractor_type == "json_path": + source = extractor.get("source", "raw") + try: + normalize_field_reference(source) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + message = _validate_json_path(extractor.get("path")) + if message: + issues.append(ValidationIssue(f"{path}.path", "invalid_json_path", message)) + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + elif extractor_type == "split": + try: + normalize_field_reference(extractor.get("source")) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + delimiter = extractor.get("delimiter") + if not isinstance(delimiter, str) or not delimiter: + issues.append(ValidationIssue(f"{path}.delimiter", "invalid_delimiter", "split delimiter must be a non-empty string")) + targets = extractor.get("targets") + target = _extractor_target(extractor) + if targets is not None: + if not isinstance(targets, list) or not targets: + issues.append(ValidationIssue(f"{path}.targets", "invalid_targets", "split targets must be a non-empty list")) + else: + if len(targets) > MAX_VARIABLES: + issues.append(ValidationIssue(f"{path}.targets", "variable_limit", "split has too many targets")) + seen_targets = set() + for index, name in enumerate(targets): + message = _validate_variable_name(name) + if message: + issues.append(ValidationIssue(f"{path}.targets[{index}]", "invalid_variable_name", message)) + elif name in seen_targets: + issues.append(ValidationIssue(f"{path}.targets[{index}]", "duplicate_variable_name", "split targets must be unique")) + else: + seen_targets.add(name) + elif target is not None: + message = _validate_variable_name(target) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + index = extractor.get("index") + if isinstance(index, bool) or not isinstance(index, int) or index < 0: + issues.append(ValidationIssue(f"{path}.index", "invalid_split_index", "split index must be a non-negative integer")) + else: + issues.append(ValidationIssue(path, "missing_split_target", "split requires name and index, or targets")) + elif extractor_type in {"set_variable", "static"}: + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + value = extractor.get("value") + if isinstance(value, str) and ("{{" in value or "}}" in value): + issues.extend(validate_template(value, path=f"{path}.value")) + elif extractor_type in {"lowercase", "uppercase", "trim"}: + source = extractor.get("source") + try: + normalize_field_reference(source) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + return issues + + +def validate_extractors(extractors: Any, *, path: str = "extractors") -> List[ValidationIssue]: + if not isinstance(extractors, list): + return [ValidationIssue(path, "invalid_extractors", "extractors must be a list")] + if len(extractors) > MAX_VARIABLES: + return [ValidationIssue(path, "extractor_limit", "too many variable extractors")] + issues: List[ValidationIssue] = [] + for index, extractor in enumerate(extractors): + issues.extend(validate_extractor(extractor, path=f"{path}[{index}]")) + return issues + + +def _parse_json_path(path: str) -> List[Any]: + """Parse a small, non-executable JSONPath subset. + + Supported forms: ``$``, ``$.a.b``, ``$['a']``, ``$[0]`` and combinations. + Wildcards, filters, scripts and recursive descent are intentionally absent. + """ + + if not path.startswith("$"): + raise ExtractionError("JSON path must start with $") + tokens: List[Any] = [] + index = 1 + while index < len(path): + if path[index] == ".": + index += 1 + match = re.match(r"[A-Za-z_][A-Za-z0-9_-]*", path[index:]) + if not match: + raise ExtractionError("invalid dotted JSON path segment") + tokens.append(match.group(0)) + index += len(match.group(0)) + elif path[index] == "[": + end = path.find("]", index + 1) + if end == -1: + raise ExtractionError("unclosed JSON path bracket") + raw = path[index + 1 : end].strip() + if re.fullmatch(r"0|[1-9][0-9]*", raw): + tokens.append(int(raw)) + elif len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}: + try: + value = bytes(raw[1:-1], "utf-8").decode("unicode_escape") + except UnicodeDecodeError as exc: + raise ExtractionError("invalid escaped JSON path key") from exc + if not value: + raise ExtractionError("JSON path key cannot be empty") + tokens.append(value) + else: + raise ExtractionError("JSON path brackets only support integer indexes or quoted keys") + index = end + 1 + else: + raise ExtractionError("invalid JSON path syntax") + return tokens + + +def _json_path_get(value: Any, path: str) -> Any: + current = value + for token in _parse_json_path(path): + if isinstance(token, int): + if not isinstance(current, Sequence) or isinstance(current, (str, bytes, bytearray)): + raise ExtractionError("JSON path expected a list") + if token >= len(current): + raise ExtractionError("JSON path index does not exist") + current = current[token] + else: + if not isinstance(current, Mapping) or token not in current: + raise ExtractionError("JSON path key does not exist") + current = current[token] + return current + + +def _bounded_value(value: Any) -> Any: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + except (TypeError, ValueError) as exc: + raise ExtractionError("extracted variable must be JSON-compatible") from exc + if len(encoded) > MAX_VARIABLE_VALUE_LENGTH: + raise ExtractionError("extracted variable exceeds the value size limit") + return copy.deepcopy(value) + + +def _set_variable(variables: MutableMapping[str, Any], name: str, value: Any) -> None: + if name not in variables and len(variables) >= MAX_VARIABLES: + raise ExtractionError("variable count exceeds the limit") + variables[name] = _bounded_value(value) + + +def _context_with_variables(context: Mapping[str, Any], variables: Mapping[str, Any]) -> Dict[str, Any]: + result = dict(context) + result["variables"] = dict(variables) + return result + + +def extract_variables( + extractors: Sequence[Mapping[str, Any]], + context: Mapping[str, Any], + *, + initial_variables: Optional[Mapping[str, Any]] = None, +) -> ExtractionResult: + issues = validate_extractors(list(extractors)) + if issues: + first = issues[0] + raise ExtractionError(first.message, path=first.path) + + variables: Dict[str, Any] = {} + initial = dict(initial_variables or context.get("variables") or {}) + if len(initial) > MAX_VARIABLES: + raise ExtractionError("initial variable count exceeds the limit") + for name, value in initial.items(): + message = _validate_variable_name(name) + if message: + raise ExtractionError(message, path=f"variables.{name}") + _set_variable(variables, name, value) + steps: List[ExtractionStepResult] = [] + outcome = "continue" + + for index, extractor in enumerate(extractors): + extractor_type = extractor["type"] + failure_mode = extractor.get("on_failure", "continue") + produced: Dict[str, Any] = {} + try: + current_context = _context_with_variables(context, variables) + if extractor_type == "extract_regex": + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("regex source field does not exist") + match = compile_safe_regex(extractor["pattern"]).search( + bounded_regex_input(resolution.value) + ) + if match is None: + raise ExtractionError("regex did not match") + target = _extractor_target(extractor) + if target is not None: + group = extractor.get("group", 1 if match.lastindex else 0) + try: + produced[target] = match.group(group) + except (IndexError, KeyError) as exc: + raise ExtractionError("requested regex group does not exist") from exc + else: + produced.update(match.groupdict()) + if not produced: + raise ExtractionError("regex extraction requires a target or named groups") + elif extractor_type in {"copy_field", "copy_to_variable"}: + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("copy source field does not exist") + produced[_extractor_target(extractor)] = resolution.value + elif extractor_type == "json_path": + resolution = resolve_field(current_context, extractor.get("source", "raw")) + if not resolution.found: + raise ExtractionError("JSON path source field does not exist") + produced[_extractor_target(extractor)] = _json_path_get( + resolution.value, extractor["path"] + ) + elif extractor_type == "split": + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("split source field does not exist") + if not isinstance(resolution.value, str): + raise ExtractionError("split source must be a string") + parts = resolution.value.split(extractor["delimiter"], MAX_SPLIT_PARTS) + if len(parts) > MAX_SPLIT_PARTS: + raise ExtractionError("split produced too many parts") + targets = extractor.get("targets") + if targets is not None: + if len(parts) < len(targets): + raise ExtractionError("split produced fewer parts than targets") + produced.update(zip(targets, parts)) + else: + split_index = extractor["index"] + if split_index >= len(parts): + raise ExtractionError("split index does not exist") + produced[_extractor_target(extractor)] = parts[split_index] + elif extractor_type in {"set_variable", "static"}: + value = extractor.get("value") + if isinstance(value, str) and ("{{" in value or "}}" in value): + value = render_template(value, current_context).value + produced[_extractor_target(extractor)] = value + elif extractor_type in {"lowercase", "uppercase", "trim"}: + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("transform source field does not exist") + if not isinstance(resolution.value, str): + raise ExtractionError("transform source must be a string") + if extractor_type == "lowercase": + value = resolution.value.lower() + elif extractor_type == "uppercase": + value = resolution.value.upper() + else: + value = resolution.value.strip() + produced[_extractor_target(extractor)] = value + + for name, value in produced.items(): + _set_variable(variables, name, value) + steps.append( + ExtractionStepResult(index, extractor_type, True, "extraction_succeeded", "variables extracted", dict(produced), failure_mode) + ) + except (ExtractionError, RegexSafetyError, ValueError) as exc: + steps.append( + ExtractionStepResult(index, extractor_type, False, getattr(exc, "code", "variable_extraction_failed"), str(exc), {}, failure_mode) + ) + if failure_mode in {"stop_rule", "stop_orchestration"}: + outcome = failure_mode + break + + return ExtractionResult(dict(variables), tuple(steps), outcome) diff --git a/app/services/orchestration/webhooks.py b/app/services/orchestration/webhooks.py new file mode 100644 index 0000000..b856b27 --- /dev/null +++ b/app/services/orchestration/webhooks.py @@ -0,0 +1,798 @@ +"""Asynchronous, audited and SSRF-resistant Event Orchestration webhooks.""" + +from __future__ import annotations + +import copy +import hashlib +import ipaddress +import json +import logging +import socket +import ssl +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple +from urllib.parse import urljoin, urlsplit, urlunsplit + +import urllib3 +from peewee import fn + +from app.db import database_proxy as db +from app.modules.common import utc_now +from app.modules.crypto import decrypt_json, decrypt_secret, encrypt_json, encrypt_secret +from app.modules.db.models import ( + AutomationExecution, + OrchestrationExecution, + OrchestrationWebhookAction, +) +from app.modules.redaction import redact_secrets +from app.services.orchestration.templates import render_template, validate_template +from app.settings import Config + +logger = logging.getLogger("oncall.orchestration.webhooks") + +_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +_RETRYABLE_STATUSES = frozenset({408, 409, 425, 429}) +_HOP_BY_HOP_HEADERS = frozenset( + { + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + } +) +_TERMINAL_STATUSES = frozenset({"succeeded", "failed", "cancelled"}) + + +class WebhookValidationError(ValueError): + pass + + +class WebhookSecurityError(RuntimeError): + pass + + +class WebhookDeliveryError(RuntimeError): + def __init__(self, message: str, *, retryable: bool = True, status: int | None = None): + self.retryable = bool(retryable) + self.status = status + super().__init__(message) + + +@dataclass(frozen=True) +class WebhookResponse: + status: int + body: bytes + final_url: str + redirects: int + + +def _safe_error(value: Any, *, limit: int = 2048) -> str: + text = str(redact_secrets(value)) + return text if len(text) <= limit else text[:limit] + "…" + + +def _validate_url_syntax(url: str) -> str: + value = str(url or "").strip() + parsed = urlsplit(value) + if parsed.scheme.lower() not in {"http", "https"}: + raise WebhookValidationError("webhook URL must use http or https") + if parsed.username or parsed.password: + raise WebhookValidationError("webhook URL must not contain credentials") + if not parsed.hostname: + raise WebhookValidationError("webhook URL requires a hostname") + if parsed.fragment: + raise WebhookValidationError("webhook URL must not contain a fragment") + if redact_secrets(value) != value: + raise WebhookValidationError( + "webhook URL must not contain secret query parameters; use encrypted headers" + ) + if parsed.scheme.lower() == "http" and not bool( + getattr(Config, "ORCHESTRATION_WEBHOOK_ALLOW_HTTP", False) + ): + raise WebhookValidationError("webhook URL must use HTTPS") + return value + + +def _normalize_headers(headers: Optional[Mapping[str, Any]]) -> Dict[str, str]: + result: Dict[str, str] = {} + for raw_name, raw_value in dict(headers or {}).items(): + name = str(raw_name or "").strip() + if not name or any(ch in name for ch in "\r\n:"): + raise WebhookValidationError("invalid webhook header name") + if name.lower() in _HOP_BY_HOP_HEADERS: + raise WebhookValidationError(f"webhook header {name!r} is not allowed") + value = str(raw_value if raw_value is not None else "") + if "\r" in value or "\n" in value: + raise WebhookValidationError("webhook header values must not contain newlines") + if "{{" in value or "}}" in value: + issues = validate_template(value, path=f"headers.{name}") + if issues: + raise WebhookValidationError(issues[0].message) + result[name] = value + return result + + +def create_webhook_action( + *, + group_id: int, + name: str, + url: str, + method: str = "POST", + headers: Optional[Mapping[str, Any]] = None, + body_template: Optional[str] = None, + description: Optional[str] = None, + timeout_seconds: int = 10, + retry_count: int = 2, + private_network_policy: str = "deny", + enabled: bool = True, + actor_id: Optional[int] = None, +) -> OrchestrationWebhookAction: + """Create one encrypted group-owned webhook action.""" + name = str(name or "").strip() + if not name: + raise WebhookValidationError("webhook action name is required") + method = str(method or "POST").upper() + if method not in _ALLOWED_METHODS: + raise WebhookValidationError("unsupported webhook method") + url = _validate_url_syntax(url) + headers = _normalize_headers(headers) + if body_template is not None: + body_template = str(body_template) + issues = validate_template(body_template, path="body_template") + if issues: + raise WebhookValidationError(issues[0].message) + return OrchestrationWebhookAction.create( + group=group_id, + name=name, + description=description, + url=url, + method=method, + headers_encrypted=encrypt_json(headers) if headers else None, + body_template=body_template, + timeout_seconds=int(timeout_seconds), + retry_count=int(retry_count), + private_network_policy=private_network_policy, + enabled=bool(enabled), + created_by=actor_id, + ) + + +def update_webhook_action(action_id: int, **changes) -> OrchestrationWebhookAction: + """Update a webhook action while preserving encrypted headers when omitted.""" + action = OrchestrationWebhookAction.get_by_id(action_id) + if "name" in changes: + action.name = str(changes["name"] or "").strip() + if not action.name: + raise WebhookValidationError("webhook action name is required") + if "url" in changes: + action.url = _validate_url_syntax(changes["url"]) + if "method" in changes: + method = str(changes["method"] or "POST").upper() + if method not in _ALLOWED_METHODS: + raise WebhookValidationError("unsupported webhook method") + action.method = method + if "headers" in changes: + normalized_headers = _normalize_headers(changes["headers"]) + action.headers_encrypted = ( + encrypt_json(normalized_headers) if normalized_headers else None + ) + if "body_template" in changes: + template = changes["body_template"] + if template is not None: + template = str(template) + issues = validate_template(template, path="body_template") + if issues: + raise WebhookValidationError(issues[0].message) + action.body_template = template + for field_name in ( + "description", + "timeout_seconds", + "retry_count", + "private_network_policy", + "enabled", + ): + if field_name in changes: + setattr(action, field_name, changes[field_name]) + action.save() + return action + + +def serialize_webhook_action(action: OrchestrationWebhookAction) -> Dict[str, Any]: + """Return safe metadata without decrypting secret headers.""" + return { + "id": action.id, + "uid": str(action.uid), + "group_id": action.group_id, + "name": action.name, + "description": action.description, + "url": redact_secrets(action.url), + "method": action.method, + "has_headers": bool(action.headers_encrypted), + "body_template": action.body_template, + "timeout_seconds": action.timeout_seconds, + "retry_count": action.retry_count, + "private_network_policy": action.private_network_policy, + "enabled": bool(action.enabled), + "created_at": action.created_at, + "updated_at": action.updated_at, + } + + +def _iter_webhook_requests(rules: Iterable[Mapping[str, Any]]): + for rule in rules or (): + path = rule.get("path") + for step in rule.get("actions") or (): + if not step.get("success") or step.get("type") != "enqueue_webhook": + continue + after = step.get("after") or {} + action_id = after.get("action_id") if isinstance(after, Mapping) else None + if action_id: + yield int(action_id), str(path or "") + yield from _iter_webhook_requests(rule.get("children") or ()) + + +def _render_headers(headers: Mapping[str, str], context: Mapping[str, Any]) -> Dict[str, str]: + rendered: Dict[str, str] = {} + for name, value in headers.items(): + if "{{" in value or "}}" in value: + value = render_template(value, context).value + if "\r" in value or "\n" in value: + raise WebhookValidationError("rendered webhook header contains a newline") + rendered[name] = value + return rendered + + +def _render_body(action: OrchestrationWebhookAction, context: Mapping[str, Any]) -> str: + if action.body_template is not None: + return render_template(action.body_template, context).value + return json.dumps( + { + "event": copy.deepcopy(context.get("event") or {}), + "result": copy.deepcopy(context.get("result") or {}), + }, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + + +def enqueue_execution_webhooks( + orchestration_execution_id: int, + *, + alert_group_id: Optional[int] = None, +) -> int: + """Materialize requested webhooks from an applied execution exactly once.""" + execution = OrchestrationExecution.get_by_id(orchestration_execution_id) + trace = execution.trace_json or {} + if not bool(trace.get("applied")): + return 0 + result = trace.get("result") or {} + context = result.get("context") or {} + queued = 0 + for ordinal, (action_id, rule_path) in enumerate( + _iter_webhook_requests(result.get("rules") or ()) + ): + action = OrchestrationWebhookAction.get_or_none( + (OrchestrationWebhookAction.id == action_id) + & (OrchestrationWebhookAction.group == execution.group_id) + & (OrchestrationWebhookAction.enabled == True) # noqa: E712 + & (OrchestrationWebhookAction.deleted == False) # noqa: E712 + & OrchestrationWebhookAction.deleted_at.is_null(True) + ) + if action is None: + logger.warning( + "orchestration webhook action skipped", + extra={"extra": { + "execution_id": execution.id, + "action_id": action_id, + "reason": "missing_or_disabled", + }}, + ) + continue + headers = _render_headers( + decrypt_json(action.headers_encrypted, default={}) or {}, + context, + ) + body = _render_body(action, context) + key_material = f"{execution.uid}:{action.uid}:{rule_path}:{ordinal}" + idempotency_key = hashlib.sha256(key_material.encode("utf-8")).hexdigest() + headers.setdefault("Idempotency-Key", idempotency_key) + headers.setdefault("User-Agent", "IncidentRelay-Orchestration/1") + if body and not any(name.lower() == "content-type" for name in headers): + headers["Content-Type"] = "application/json" + metadata = { + "url": action.url, + "method": action.method, + "timeout_seconds": int(action.timeout_seconds), + "retry_count": int(action.retry_count), + "private_network_policy": action.private_network_policy, + "header_names": sorted(headers), + "body_bytes": len(body.encode("utf-8")), + } + row, created = AutomationExecution.get_or_create( + idempotency_key=idempotency_key, + defaults={ + "action": action.id, + "orchestration_execution": execution.id, + "group": execution.group_id, + "alert_group_id": alert_group_id, + "rule_path": rule_path, + "status": "pending", + "request_metadata_json": metadata, + "request_headers_encrypted": encrypt_json(headers), + "request_body_encrypted": encrypt_secret(body), + "next_attempt_at": utc_now(), + }, + ) + if not created and alert_group_id and row.alert_group_id is None: + row.alert_group_id = alert_group_id + row.save(only=[AutomationExecution.alert_group_id]) + queued += int(created) + return queued + + +def _allowlist_networks() -> Sequence[Any]: + raw = str( + getattr(Config, "ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_ALLOWLIST", "") or "" + ) + result = [] + for item in raw.replace(";", ",").split(","): + item = item.strip() + if not item: + continue + try: + result.append(ipaddress.ip_network(item, strict=False)) + except ValueError as exc: + raise WebhookSecurityError("invalid private network allowlist") from exc + return tuple(result) + + +def _ip_allowed(address: ipaddress._BaseAddress, policy: str) -> bool: + unsafe = ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + ) + if not unsafe: + return True + if policy != "allowlist": + return False + return any(address in network for network in _allowlist_networks()) + + +def resolve_webhook_target(url: str, *, private_network_policy: str = "deny"): + """Resolve and validate every address, then return one pinned target IP.""" + url = _validate_url_syntax(url) + parsed = urlsplit(url) + port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80) + try: + literal = ipaddress.ip_address(parsed.hostname) + addresses = [literal] + except ValueError: + try: + infos = socket.getaddrinfo( + parsed.hostname, + port, + type=socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + raise WebhookDeliveryError("webhook DNS resolution failed") from exc + addresses = [] + for info in infos: + try: + address = ipaddress.ip_address(info[4][0]) + except ValueError: + continue + if address not in addresses: + addresses.append(address) + if not addresses: + raise WebhookDeliveryError("webhook hostname resolved to no addresses") + if not all(_ip_allowed(address, private_network_policy) for address in addresses): + raise WebhookSecurityError("webhook target resolves to a blocked network") + return parsed, str(addresses[0]), port + + +def _host_header(parsed) -> str: + host = parsed.hostname or "" + try: + if ipaddress.ip_address(host).version == 6: + host = f"[{host}]" + except ValueError: + pass + default_port = 443 if parsed.scheme.lower() == "https" else 80 + return f"{host}:{parsed.port}" if parsed.port and parsed.port != default_port else host + + +def _request_once( + url: str, + *, + method: str, + headers: Mapping[str, str], + body: bytes, + timeout_seconds: int, + private_network_policy: str, +) -> Tuple[int, Mapping[str, str], bytes]: + parsed, target_ip, port = resolve_webhook_target( + url, + private_network_policy=private_network_policy, + ) + request_headers = dict(headers) + request_headers["Host"] = _host_header(parsed) + path = urlunsplit(("", "", parsed.path or "/", parsed.query, "")) + timeout = urllib3.Timeout(connect=timeout_seconds, read=timeout_seconds) + if parsed.scheme.lower() == "https": + pool = urllib3.HTTPSConnectionPool( + target_ip, + port=port, + timeout=timeout, + maxsize=1, + block=True, + retries=False, + cert_reqs=ssl.CERT_REQUIRED, + assert_hostname=parsed.hostname, + server_hostname=parsed.hostname, + ) + else: + pool = urllib3.HTTPConnectionPool( + target_ip, + port=port, + timeout=timeout, + maxsize=1, + block=True, + retries=False, + ) + response = None + try: + response = pool.urlopen( + method, + path, + body=body or None, + headers=request_headers, + redirect=False, + retries=False, + preload_content=False, + ) + limit = int(getattr(Config, "ORCHESTRATION_WEBHOOK_MAX_RESPONSE_BYTES", 65536)) + payload = response.read(amt=limit + 1, decode_content=True) + if len(payload) > limit: + raise WebhookDeliveryError( + "webhook response exceeded the configured size limit", + retryable=False, + status=int(response.status), + ) + return int(response.status), dict(response.headers), payload + except WebhookDeliveryError: + raise + except Exception as exc: + raise WebhookDeliveryError(f"webhook request failed: {exc.__class__.__name__}") from exc + finally: + if response is not None: + response.release_conn() + pool.close() + + +def deliver_webhook( + *, + url: str, + method: str, + headers: Mapping[str, str], + body: bytes, + timeout_seconds: int, + private_network_policy: str, +) -> WebhookResponse: + """Deliver one request with pinned DNS and bounded redirects.""" + current_url = url + current_method = method + current_body = body + max_redirects = int(getattr(Config, "ORCHESTRATION_WEBHOOK_MAX_REDIRECTS", 3)) + for redirects in range(max_redirects + 1): + status, response_headers, payload = _request_once( + current_url, + method=current_method, + headers=headers, + body=current_body, + timeout_seconds=timeout_seconds, + private_network_policy=private_network_policy, + ) + if status not in _REDIRECT_STATUSES: + return WebhookResponse(status, payload, current_url, redirects) + location = response_headers.get("Location") or response_headers.get("location") + if not location: + return WebhookResponse(status, payload, current_url, redirects) + if redirects >= max_redirects: + raise WebhookDeliveryError("webhook redirect limit exceeded", retryable=False) + current_url = urljoin(current_url, location) + _validate_url_syntax(current_url) + if status == 303: + current_method = "GET" + current_body = b"" + raise WebhookDeliveryError("webhook redirect limit exceeded", retryable=False) + + +def _requeue_stale_claims(now) -> int: + cutoff = now - timedelta( + seconds=int(getattr(Config, "ORCHESTRATION_WEBHOOK_CLAIM_TTL_SECONDS", 300)) + ) + return ( + AutomationExecution.update( + status="pending", + claim_token=None, + claimed_at=None, + next_attempt_at=now, + ) + .where( + (AutomationExecution.status == "running") + & (AutomationExecution.claimed_at <= cutoff) + ) + .execute() + ) + + +def _group_capacity(group_id: int, *, now) -> bool: + concurrency = int( + getattr(Config, "ORCHESTRATION_WEBHOOK_PER_GROUP_CONCURRENCY", 2) + ) + running = ( + AutomationExecution.select(fn.COUNT(AutomationExecution.id)) + .where( + (AutomationExecution.group == group_id) + & (AutomationExecution.status == "running") + ) + .scalar() + or 0 + ) + if concurrency > 0 and int(running) >= concurrency: + return False + rate = int( + getattr(Config, "ORCHESTRATION_WEBHOOK_PER_GROUP_RATE_PER_MINUTE", 60) + ) + if rate <= 0: + return True + since = now - timedelta(minutes=1) + recent = ( + AutomationExecution.select(fn.COUNT(AutomationExecution.id)) + .where( + (AutomationExecution.group == group_id) + & AutomationExecution.started_at.is_null(False) + & (AutomationExecution.started_at >= since) + ) + .scalar() + or 0 + ) + return int(recent) < rate + + +def _claim_one(execution_id: int, *, now) -> Optional[AutomationExecution]: + token = uuid.uuid4().hex + row = AutomationExecution.get_or_none(AutomationExecution.id == execution_id) + if row is None or not _group_capacity(row.group_id, now=now): + return None + updated = ( + AutomationExecution.update( + status="running", + claim_token=token, + claimed_at=now, + started_at=now, + attempts=AutomationExecution.attempts + 1, + error_safe=None, + ) + .where( + (AutomationExecution.id == execution_id) + & (AutomationExecution.status == "pending") + & ( + AutomationExecution.next_attempt_at.is_null(True) + | (AutomationExecution.next_attempt_at <= now) + ) + ) + .execute() + ) + if updated != 1: + return None + return AutomationExecution.get( + (AutomationExecution.id == execution_id) + & (AutomationExecution.claim_token == token) + ) + + +def _mark_succeeded(row: AutomationExecution, response: WebhookResponse, *, now): + excerpt = response.body.decode("utf-8", errors="replace") + excerpt = _safe_error(excerpt, limit=4096) + AutomationExecution.update( + status="succeeded", + response_status=response.status, + response_excerpt_safe=excerpt, + error_safe=None, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + finished_at=now, + ).where( + (AutomationExecution.id == row.id) + & (AutomationExecution.status == "running") + & (AutomationExecution.claim_token == row.claim_token) + ).execute() + + +def _mark_failed(row: AutomationExecution, exc: Exception, *, now): + metadata = row.request_metadata_json or {} + attempts = int(row.attempts or 0) + max_attempts = 1 + int(metadata.get("retry_count", row.action.retry_count) or 0) + retryable = bool(getattr(exc, "retryable", True)) + status_code = getattr(exc, "status", None) + if retryable and attempts < max_attempts: + status = "pending" + base = int(getattr(Config, "ORCHESTRATION_WEBHOOK_RETRY_BASE_SECONDS", 30)) + next_attempt_at = now + timedelta( + seconds=min(base * (2 ** max(attempts - 1, 0)), 3600) + ) + finished_at = None + else: + status = "failed" + next_attempt_at = None + finished_at = now + AutomationExecution.update( + status=status, + response_status=status_code, + error_safe=_safe_error(exc), + claim_token=None, + claimed_at=None, + next_attempt_at=next_attempt_at, + finished_at=finished_at, + ).where( + (AutomationExecution.id == row.id) + & (AutomationExecution.status == "running") + & (AutomationExecution.claim_token == row.claim_token) + ).execute() + + +def _deliver_claimed(row: AutomationExecution, *, now): + action = row.action + if not action.enabled or action.deleted or action.deleted_at is not None: + AutomationExecution.update( + status="cancelled", + error_safe="webhook action is disabled", + claim_token=None, + claimed_at=None, + finished_at=now, + ).where( + (AutomationExecution.id == row.id) + & (AutomationExecution.claim_token == row.claim_token) + ).execute() + return "cancelled" + headers = decrypt_json(row.request_headers_encrypted, default={}) or {} + body = (decrypt_secret(row.request_body_encrypted) or "").encode("utf-8") + metadata = row.request_metadata_json or {} + response = deliver_webhook( + url=metadata.get("url") or action.url, + method=metadata.get("method") or action.method, + headers=headers, + body=body, + timeout_seconds=int( + metadata.get("timeout_seconds", action.timeout_seconds) + ), + private_network_policy=( + metadata.get("private_network_policy") + or action.private_network_policy + ), + ) + if 200 <= response.status < 300: + _mark_succeeded(row, response, now=now) + return "succeeded" + retryable = response.status in _RETRYABLE_STATUSES or response.status >= 500 + raise WebhookDeliveryError( + f"webhook returned HTTP {response.status}", + retryable=retryable, + status=response.status, + ) + + +def process_due_webhooks(limit=50, *, now=None) -> Dict[str, int]: + """Claim and deliver queued webhook actions with bounded retries.""" + now = now or utc_now() + result = { + "processed": 0, + "succeeded": 0, + "failed": 0, + "cancelled": 0, + "requeued": _requeue_stale_claims(now), + } + rows = list( + AutomationExecution.select(AutomationExecution.id) + .where( + (AutomationExecution.status == "pending") + & ( + AutomationExecution.next_attempt_at.is_null(True) + | (AutomationExecution.next_attempt_at <= now) + ) + ) + .order_by(AutomationExecution.created_at.asc(), AutomationExecution.id.asc()) + .limit(int(limit)) + ) + for candidate in rows: + claimed = _claim_one(candidate.id, now=now) + if claimed is None: + continue + result["processed"] += 1 + try: + outcome = _deliver_claimed(claimed, now=utc_now()) + result[outcome] += 1 + except Exception as exc: + logger.warning( + "orchestration webhook execution failed", + extra={"extra": { + "automation_execution_id": claimed.id, + "action_id": claimed.action_id, + "error": _safe_error(exc), + }}, + ) + _mark_failed(claimed, exc, now=utc_now()) + result["failed"] += 1 + return result + + +def retry_failed_webhook(execution_id: int, *, now=None) -> bool: + now = now or utc_now() + updated = ( + AutomationExecution.update( + status="pending", + attempts=0, + response_status=None, + response_excerpt_safe=None, + error_safe=None, + next_attempt_at=now, + claim_token=None, + claimed_at=None, + started_at=None, + finished_at=None, + ) + .where( + (AutomationExecution.id == execution_id) + & (AutomationExecution.status == "failed") + ) + .execute() + ) + return updated == 1 + + +def cleanup_webhook_executions(*, now=None) -> int: + now = now or utc_now() + cutoff = now - timedelta( + days=int(getattr(Config, "ORCHESTRATION_WEBHOOK_EXECUTION_RETENTION_DAYS", 30)) + ) + return ( + AutomationExecution.delete() + .where( + AutomationExecution.status.in_(tuple(_TERMINAL_STATUSES)) + & (AutomationExecution.finished_at <= cutoff) + ) + .execute() + ) + + +__all__ = [ + "WebhookDeliveryError", + "WebhookResponse", + "WebhookSecurityError", + "WebhookValidationError", + "cleanup_webhook_executions", + "create_webhook_action", + "deliver_webhook", + "enqueue_execution_webhooks", + "process_due_webhooks", + "resolve_webhook_target", + "retry_failed_webhook", + "serialize_webhook_action", + "update_webhook_action", +] diff --git a/app/services/payloads.py b/app/services/payloads.py new file mode 100644 index 0000000..b807bfd --- /dev/null +++ b/app/services/payloads.py @@ -0,0 +1,11 @@ +"""Small helpers for service-layer request payloads.""" + +from typing import Any + + +def payload_to_dict(payload: Any) -> dict[str, Any]: + """Return only explicitly supplied fields from a schema or mapping payload.""" + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_unset=True) + + return dict(payload or {}) diff --git a/app/services/rbac.py b/app/services/rbac.py index 67fcc88..314ce5b 100644 --- a/app/services/rbac.py +++ b/app/services/rbac.py @@ -9,6 +9,7 @@ TEAM_VIEWER_ROLE, ) from app.modules.db import groups_repo, teams_repo +from app.modules.db.models import Group, UserGroup GROUP_READ_ROLES = {GROUP_VIEWER_ROLE, GROUP_EDITOR_ROLE, GROUP_USER_ADMIN_ROLE} GROUP_WRITE_ROLES = { @@ -375,6 +376,55 @@ def require_team_or_group_resource_access(team_id, write_required=False): }), 403 +def get_audit_log_group_ids(user=None): + """Return group ids whose audit records may be read by a group editor. + + ``None`` represents unrestricted global-admin scope. A group user-admin or + viewer does not gain audit-log access unless the same user is an editor in + another group. + """ + + user = user or current_user() + if not user: + return [] + + if user.is_admin: + return None + + memberships = ( + UserGroup + .select(UserGroup.group) + .join(Group) + .where( + (UserGroup.user == user.id) + & (UserGroup.role == GROUP_EDITOR_ROLE) + & (UserGroup.active == True) + & (Group.active == True) + & (Group.deleted == False) + ) + ) + return sorted({membership.group_id for membership in memberships}) + + +def can_read_audit_logs(user=None): + """Return True for global admins and users editing at least one group.""" + + scope = get_audit_log_group_ids(user=user) + return scope is None or bool(scope) + + +def require_audit_log_access(): + """Return an error response when current user cannot read audit logs.""" + + if can_read_audit_logs(): + return None + + return jsonify({ + "error": "audit_log_access_denied", + "message": "Global admin or group editor role is required", + }), 403 + + def require_admin_user(): """Return an error response when current user is not an admin.""" user = current_user() diff --git a/app/services/rotation_schedule.py b/app/services/rotation_schedule.py new file mode 100644 index 0000000..adcf413 --- /dev/null +++ b/app/services/rotation_schedule.py @@ -0,0 +1,146 @@ +from datetime import timedelta + +from app.modules.common import ( + as_utc_aware, + as_utc_naive, + local_datetime_to_utc_naive, + timezone_or_utc, +) + + +def effective_layer_value(layer, field_name, default=None): + """Return layer schedule value with fallback to the parent rotation.""" + value = getattr(layer, field_name, None) + + if value not in (None, ""): + return value + + rotation = getattr(layer, "rotation", None) + if rotation is not None: + value = getattr(rotation, field_name, None) + if value not in (None, ""): + return value + + return default + + +def layer_timezone_name(layer): + return effective_layer_value(layer, "timezone", "UTC") or "UTC" + + +def layer_timezone(layer): + return timezone_or_utc(layer_timezone_name(layer)) + + +def layer_start_local_naive(layer): + """Return schedule anchor as local wall-clock time.""" + value = effective_layer_value(layer, "start_at") + if value is None: + return None + + zone = layer_timezone(layer) + if value.tzinfo is None: + return value + + return value.astimezone(zone).replace(tzinfo=None) + + +def layer_start_utc_naive(layer): + """Return schedule anchor as naive UTC.""" + value = effective_layer_value(layer, "start_at") + if value is None: + return None + + return local_datetime_to_utc_naive(value, layer_timezone_name(layer)) + + +def layer_duration_seconds(layer): + value = effective_layer_value(layer, "duration_seconds", 86400) + try: + value = int(value) + except (TypeError, ValueError): + value = 86400 + return value if value > 0 else 86400 + + +def _calendar_interval(layer): + """Return local-wall-clock interval for day/week based schedules. + + Daily/weekly rotations and custom day/week intervals must preserve the + local handoff clock across DST changes. Minute/hour custom intervals stay + fixed-duration UTC intervals. + """ + rotation_type = str(effective_layer_value(layer, "rotation_type", "daily") or "daily") + + if rotation_type == "daily": + # Keep compatibility with legacy/internal rows that use a non-daily + # duration while still carrying rotation_type="daily". + if layer_duration_seconds(layer) == 86400: + return timedelta(days=1) + return None + if rotation_type == "weekly": + if layer_duration_seconds(layer) == 604800: + return timedelta(weeks=1) + return None + + if rotation_type != "custom": + return None + + unit = str(effective_layer_value(layer, "interval_unit", "days") or "days") + try: + value = max(1, int(effective_layer_value(layer, "interval_value", 1) or 1)) + except (TypeError, ValueError): + value = 1 + + if unit == "days": + return timedelta(days=value) + if unit == "weeks": + return timedelta(weeks=value) + + return None + + +def layer_slot_index(layer, at): + """Return zero-based schedule slot for an absolute UTC instant. + + ``None`` means the layer schedule has not started yet. Calendar-day/week + cadences are measured in local wall-clock time so DST does not move the + handoff clock. + """ + at = as_utc_naive(at) + start_utc = layer_start_utc_naive(layer) + + if start_utc is None: + return 0 + if at < start_utc: + return None + + calendar_interval = _calendar_interval(layer) + if calendar_interval is not None: + start_local = layer_start_local_naive(layer) + now_local = as_utc_aware(at).astimezone(layer_timezone(layer)).replace(tzinfo=None) + elapsed = now_local - start_local + return int(elapsed.total_seconds()) // int(calendar_interval.total_seconds()) + + elapsed_seconds = int((at - start_utc).total_seconds()) + return elapsed_seconds // layer_duration_seconds(layer) + + +def next_layer_boundary_utc(layer, at): + """Return next slot boundary as naive UTC.""" + at = as_utc_naive(at) + start_utc = layer_start_utc_naive(layer) + + if start_utc is None: + return None + if at < start_utc: + return start_utc + + slot = layer_slot_index(layer, at) + calendar_interval = _calendar_interval(layer) + + if calendar_interval is not None: + boundary_local = layer_start_local_naive(layer) + calendar_interval * (slot + 1) + return local_datetime_to_utc_naive(boundary_local, layer_timezone_name(layer)) + + return start_utc + timedelta(seconds=layer_duration_seconds(layer) * (slot + 1)) diff --git a/app/services/routing/matcher/matchers.py b/app/services/routing/matcher/matchers.py index a47e591..c5c59c3 100644 --- a/app/services/routing/matcher/matchers.py +++ b/app/services/routing/matcher/matchers.py @@ -84,6 +84,38 @@ def match_value(actual_value, expected_value): return actual_value in expected_value if isinstance(expected_value, dict): + operator = expected_value.get("op") + + if operator is not None: + operator = { + "eq": "equals", + "ne": "not_equals", + "neq": "not_equals", + }.get(str(operator).strip().lower(), str(operator).strip().lower()) + operator_value = expected_value.get("value") + actual_text = str(actual_value) + expected_text = str(operator_value) + + if operator == "regex": + return re.search( + str(operator_value or ""), + str(actual_value or ""), + ) is not None + + if operator == "equals": + return actual_text == expected_text + + if operator == "not_equals": + return actual_text != expected_text + + if operator == "contains": + return str(operator_value or "") in str(actual_value or "") + + if operator == "not_contains": + return str(operator_value or "") not in str(actual_value or "") + + return False + if "regex" in expected_value: return re.search( expected_value["regex"], diff --git a/app/services/routing/matcher/service.py b/app/services/routing/matcher/service.py index bbccff7..7ae82a7 100644 --- a/app/services/routing/matcher/service.py +++ b/app/services/routing/matcher/service.py @@ -1,7 +1,8 @@ from peewee import DoesNotExist from app.modules.db import matcher_presets_repo, teams_repo -from app.services.serializers.common import attach_team_permissions +from app.services.serializers.common import attach_team_permissions, serialize_utc_datetime +from app.services.payloads import payload_to_dict class MatcherPresetError(ValueError): @@ -20,13 +21,6 @@ class MatcherPresetInUseError(MatcherPresetConflictError): """Matcher preset is still used by policy rules.""" -def _payload_dict(payload): - if hasattr(payload, "model_dump"): - return payload.model_dump(exclude_unset=True) - - return dict(payload or {}) - - def _clean_name(value): name = str(value or "").strip() @@ -63,7 +57,7 @@ def get_preset(preset_id): def create_preset(payload): """Create or restore a matcher preset.""" - data = _payload_dict(payload) + data = payload_to_dict(payload) team = _require_team(data.get("team_id")) name = _clean_name(data.get("name")) @@ -99,7 +93,7 @@ def create_preset(payload): def update_preset(preset_id, payload): """Update a matcher preset and increment its version.""" preset = get_preset(preset_id) - data = _payload_dict(payload) + data = payload_to_dict(payload) if "name" in data: name = _clean_name(data["name"]) @@ -246,8 +240,8 @@ def serialize_preset(preset, current_user=None, *, include_usages=False): "id": silence.id, "name": silence.name, "team_id": silence.team_id, - "starts_at": silence.starts_at.isoformat() if silence.starts_at else None, - "ends_at": silence.ends_at.isoformat() if silence.ends_at else None, + "starts_at": serialize_utc_datetime(silence.starts_at), + "ends_at": serialize_utc_datetime(silence.ends_at), "enabled": silence.enabled, } for silence in usages["silences"] diff --git a/app/services/scheduler.py b/app/services/scheduler.py index fb995e5..535b2b6 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -7,7 +7,8 @@ from app.db import database_proxy as db from app.settings import Config from app.services.alerts.notification_queue import process_due_alert_group_notifications -from app.services.alerts.lifecycle import logger as send_unacked_reminders +from app.services.alerts.maintenance_state import process_maintenance_lifecycle +from app.services.alerts.reminders import send_unacked_reminders from app.services.db_lock import acquire_db_lock, release_db_lock from app.services.notifications.shift_notifications import ( send_due_oncall_shift_email_notifications, @@ -18,6 +19,13 @@ from app.services.alerts.explain_cleanup import cleanup_alert_explain_traces from app.services.service_catalog.impact_snapshots import capture_scheduled_service_impact_snapshot from app.services.heartbeats.service import process_overdue_heartbeats +from app.services.silences import process_silence_lifecycle +from app.modules.common import utc_now +from app.services.orchestration.pending import ( + cleanup_orchestration_retention, + process_due_pending_events, +) +from app.services.orchestration.webhooks import process_due_webhooks logger = logging.getLogger("oncall.scheduler") _scheduler = None @@ -247,6 +255,33 @@ def alert_group_notification_job(): db.close() +def maintenance_lifecycle_job(): + """Re-evaluate maintenance starts, ends and retained effects.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("maintenance_lifecycle_job") + if not owner: + return {"windows": 0, "applied": 0, "released": 0, "retained": 0} + result = process_maintenance_lifecycle( + limit=int(getattr(Config, "MAINTENANCE_LIFECYCLE_BATCH_SIZE", 500)) + ) + logger.info( + "maintenance lifecycle job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("maintenance lifecycle job failed") + return {"windows": 0, "applied": 0, "released": 0, "retained": 0, "failed": 1} + finally: + if owner: + release_db_lock("maintenance_lifecycle_job", owner) + if not db.is_closed(): + db.close() + + def incident_responder_expire_job(): """Expire pending incident responder requests under a database lock.""" if db.is_closed(): @@ -374,6 +409,47 @@ def alert_explain_trace_cleanup_job(): db.close() +def silence_lifecycle_job(): + """Apply scheduled Silences and reactivate alerts after Silence expiry.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + + owner = None + + try: + owner = acquire_db_lock("silence_lifecycle_job") + if not owner: + logger.debug("silence lifecycle job skipped because lock is busy") + return { + "silences_started": 0, + "silences_released": 0, + "alerts_silenced": 0, + "alerts_reactivated": 0, + "legacy_alerts_backfilled": 0, + } + + result = process_silence_lifecycle() + logger.info( + "silence lifecycle job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("silence lifecycle job failed") + return { + "silences_started": 0, + "silences_released": 0, + "alerts_silenced": 0, + "alerts_reactivated": 0, + "legacy_alerts_backfilled": 0, + "failed": 1, + } + finally: + if owner: + release_db_lock("silence_lifecycle_job", owner) + if not db.is_closed(): + db.close() + def heartbeat_overdue_job(): """Open incidents for overdue heartbeat/dead-man checks under a database lock.""" if db.is_closed(): @@ -473,6 +549,99 @@ def service_impact_snapshot_job(): db.close() + +def orchestration_pending_event_job(): + """Activate due paused orchestration events under a database lock.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("orchestration_pending_event_job") + if not owner: + logger.debug("orchestration pending event job skipped because lock is busy") + return {"processed": 0, "activated": 0, "failed": 0, "requeued": 0} + result = process_due_pending_events( + limit=int(getattr(Config, "ORCHESTRATION_PENDING_BATCH_SIZE", 100)) + ) + logger.info( + "orchestration pending event job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("orchestration pending event job failed") + return {"processed": 0, "activated": 0, "failed": 1, "requeued": 0} + finally: + if owner: + release_db_lock("orchestration_pending_event_job", owner) + if not db.is_closed(): + db.close() + + +def orchestration_webhook_job(): + """Deliver queued orchestration webhook actions under a database lock.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("orchestration_webhook_job") + if not owner: + logger.debug("orchestration webhook job skipped because lock is busy") + return { + "processed": 0, + "succeeded": 0, + "failed": 0, + "cancelled": 0, + "requeued": 0, + } + result = process_due_webhooks( + limit=int(getattr(Config, "ORCHESTRATION_WEBHOOK_BATCH_SIZE", 50)) + ) + logger.info( + "orchestration webhook job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("orchestration webhook job failed") + return { + "processed": 0, + "succeeded": 0, + "failed": 1, + "cancelled": 0, + "requeued": 0, + } + finally: + if owner: + release_db_lock("orchestration_webhook_job", owner) + if not db.is_closed(): + db.close() + + +def orchestration_retention_cleanup_job(): + """Prune expired dropped traces and terminal pending rows.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("orchestration_retention_cleanup_job") + if not owner: + return {"executions_deleted": 0, "pending_events_deleted": 0} + result = cleanup_orchestration_retention() + logger.info( + "orchestration retention cleanup job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("orchestration retention cleanup job failed") + return {"executions_deleted": 0, "pending_events_deleted": 0} + finally: + if owner: + release_db_lock("orchestration_retention_cleanup_job", owner) + if not db.is_closed(): + db.close() + def start_scheduler(): """ Start the background scheduler. @@ -493,7 +662,7 @@ def start_scheduler(): seconds=Config.REMINDER_INTERVAL_SECONDS, max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="reminder_job", replace_existing=True, ) @@ -510,7 +679,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="oncall_shift_email_job", replace_existing=True, ) @@ -528,7 +697,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="oncall_shift_mattermost_job", replace_existing=True, ) @@ -545,7 +714,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="user_notification_rules_job", replace_existing=True, ) @@ -556,11 +725,39 @@ def start_scheduler(): seconds=int(getattr(Config, "ALERT_GROUP_NOTIFICATION_CHECK_INTERVAL_SECONDS", 10)), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="alert_group_notification_job", replace_existing=True, ) + _scheduler.add_job( + silence_lifecycle_job, + "interval", + seconds=int( + getattr( + Config, + "SILENCE_LIFECYCLE_CHECK_INTERVAL_SECONDS", + 30, + ) + ), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="silence_lifecycle_job", + replace_existing=True, + ) + + _scheduler.add_job( + maintenance_lifecycle_job, + "interval", + seconds=int(getattr(Config, "MAINTENANCE_LIFECYCLE_CHECK_INTERVAL_SECONDS", 30)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="maintenance_lifecycle_job", + replace_existing=True, + ) + _scheduler.add_job( incident_responder_expire_job, "interval", @@ -573,7 +770,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="incident_responder_expire_job", replace_existing=True, ) @@ -590,7 +787,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="alert_explain_trace_cleanup_job", replace_existing=True, ) @@ -602,7 +799,7 @@ def start_scheduler(): seconds=int(getattr(Config, "HEARTBEAT_CHECK_INTERVAL_SECONDS", 30)), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="heartbeat_overdue_job", replace_existing=True, ) @@ -614,11 +811,44 @@ def start_scheduler(): seconds=int(getattr(Config, "SERVICE_IMPACT_SNAPSHOT_INTERVAL_SECONDS", 300)), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="service_impact_snapshot_job", replace_existing=True, ) + _scheduler.add_job( + orchestration_pending_event_job, + "interval", + seconds=int(getattr(Config, "ORCHESTRATION_PENDING_CHECK_INTERVAL_SECONDS", 10)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="orchestration_pending_event_job", + replace_existing=True, + ) + + _scheduler.add_job( + orchestration_webhook_job, + "interval", + seconds=int(getattr(Config, "ORCHESTRATION_WEBHOOK_CHECK_INTERVAL_SECONDS", 5)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="orchestration_webhook_job", + replace_existing=True, + ) + + _scheduler.add_job( + orchestration_retention_cleanup_job, + "interval", + seconds=int(getattr(Config, "ORCHESTRATION_RETENTION_CLEANUP_INTERVAL_SECONDS", 86400)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="orchestration_retention_cleanup_job", + replace_existing=True, + ) + try: _scheduler.start() except SchedulerAlreadyRunningError: diff --git a/app/services/serializers/alerts.py b/app/services/serializers/alerts.py index 81e6b17..8674eb1 100644 --- a/app/services/serializers/alerts.py +++ b/app/services/serializers/alerts.py @@ -42,7 +42,7 @@ def serialize_alert_event(event): "event_type": event.event_type, "message": event.message, "user": serialize_user_short(event.user), - "created_at": event.created_at.isoformat(), + "created_at": serialize_utc_datetime(event.created_at), } @@ -185,8 +185,8 @@ def serialize_alert_notification(notification): "provider_payload": notification.provider_payload or {}, "last_event_type": notification.last_event_type, "last_error": notification.last_error, - "created_at": notification.created_at.isoformat(), - "updated_at": notification.updated_at.isoformat(), + "created_at": serialize_utc_datetime(notification.created_at), + "updated_at": serialize_utc_datetime(notification.updated_at), } @@ -586,6 +586,12 @@ def serialize_alert_group( "team_escalation_enabled": group.team.escalation_enabled if group.team else None, "maintenance_window_id": group.maintenance_window_id, "maintenance_suppressed": group.maintenance_suppressed, + "orchestration_suppressed": bool( + getattr(group, "orchestration_suppressed", False) + ), + "orchestration_suppress_reason": getattr( + group, "orchestration_suppress_reason", None + ), "correlation_summary": serialize_alert_group_correlation_summary(group), "business_impact_summary": serialize_alert_group_business_impact_summary(group), } @@ -629,8 +635,8 @@ def serialize_alert_group( def serialize_alert_comment(comment): user = comment.user if getattr(comment, "user_id", None) else None - created_at = comment.created_at.isoformat() if comment.created_at else None - updated_at = comment.updated_at.isoformat() if comment.updated_at else None + created_at = serialize_utc_datetime(comment.created_at) + updated_at = serialize_utc_datetime(comment.updated_at) return { "id": comment.id, @@ -696,7 +702,7 @@ def serialize_attached_maintenance_ref(obj): def serialize_alert_explain_step(row): - created_at = getattr(row, "created_at", None) + created_at = serialize_utc_datetime(getattr(row, "created_at", None)) return { "id": row.id, @@ -707,13 +713,13 @@ def serialize_alert_explain_step(row): "title": row.title, "message": row.message, "data": row.data or {}, - "created_at": created_at.isoformat() if created_at else None, + "created_at": created_at, } def serialize_alert_explain_trace(row, steps=None): - started_at = getattr(row, "started_at", None) - finished_at = getattr(row, "finished_at", None) + started_at = serialize_utc_datetime(getattr(row, "started_at", None)) + finished_at = serialize_utc_datetime(getattr(row, "finished_at", None)) return { "id": row.id, @@ -728,8 +734,8 @@ def serialize_alert_explain_trace(row, steps=None): "reason": row.reason, "input_summary": row.input_summary or {}, "result": row.result or {}, - "started_at": started_at.isoformat() if started_at else None, - "finished_at": finished_at.isoformat() if finished_at else None, + "started_at": started_at, + "finished_at": finished_at, "steps": [ serialize_alert_explain_step(step) for step in (steps or []) diff --git a/app/services/serializers/heartbeats.py b/app/services/serializers/heartbeats.py index ad16196..0c0ebda 100644 --- a/app/services/serializers/heartbeats.py +++ b/app/services/serializers/heartbeats.py @@ -1,12 +1,4 @@ -from datetime import datetime - - -def isoformat(value): - if not value: - return None - if isinstance(value, datetime): - return value.isoformat() + "Z" - return str(value) +from app.services.serializers.common import serialize_utc_datetime def serialize_heartbeat(item, *, include_token=False, raw_token=None, base_url=None, pings=None, instances=None): @@ -56,18 +48,18 @@ def serialize_heartbeat(item, *, include_token=False, raw_token=None, base_url=N "severity": item.severity, "priority_slug": item.priority_slug, "token_prefix": item.token_prefix, - "last_seen_at": isoformat(item.last_seen_at), - "next_expected_at": isoformat(item.next_expected_at), - "deadline_at": isoformat(deadline_at), - "overdue_since": isoformat(item.overdue_since), - "last_overdue_at": isoformat(item.last_overdue_at), - "last_recovered_at": isoformat(item.last_recovered_at), + "last_seen_at": serialize_utc_datetime(item.last_seen_at), + "next_expected_at": serialize_utc_datetime(item.next_expected_at), + "deadline_at": serialize_utc_datetime(deadline_at), + "overdue_since": serialize_utc_datetime(item.overdue_since), + "last_overdue_at": serialize_utc_datetime(item.last_overdue_at), + "last_recovered_at": serialize_utc_datetime(item.last_recovered_at), "current_alert_group_id": item.current_alert_group_id, "labels": item.labels or {}, "metadata": item.metadata or {}, "created_by_id": item.created_by_id, - "created_at": isoformat(item.created_at), - "updated_at": isoformat(item.updated_at), + "created_at": serialize_utc_datetime(item.created_at), + "updated_at": serialize_utc_datetime(item.updated_at), } if include_token and raw_token: @@ -111,19 +103,19 @@ def serialize_heartbeat_instance(item): "status": item.status, "enabled": item.enabled, "auto_discovered": item.auto_discovered, - "first_seen_at": isoformat(item.first_seen_at), - "last_seen_at": isoformat(item.last_seen_at), - "next_expected_at": isoformat(item.next_expected_at), - "deadline_at": isoformat(deadline_at), - "overdue_since": isoformat(item.overdue_since), - "last_overdue_at": isoformat(item.last_overdue_at), - "last_recovered_at": isoformat(item.last_recovered_at), + "first_seen_at": serialize_utc_datetime(item.first_seen_at), + "last_seen_at": serialize_utc_datetime(item.last_seen_at), + "next_expected_at": serialize_utc_datetime(item.next_expected_at), + "deadline_at": serialize_utc_datetime(deadline_at), + "overdue_since": serialize_utc_datetime(item.overdue_since), + "last_overdue_at": serialize_utc_datetime(item.last_overdue_at), + "last_recovered_at": serialize_utc_datetime(item.last_recovered_at), "current_alert_group_id": item.current_alert_group_id, "last_payload": item.last_payload or {}, "last_remote_addr": item.last_remote_addr, "metadata": item.metadata or {}, - "created_at": isoformat(item.created_at), - "updated_at": isoformat(item.updated_at), + "created_at": serialize_utc_datetime(item.created_at), + "updated_at": serialize_utc_datetime(item.updated_at), } @@ -131,7 +123,7 @@ def serialize_heartbeat_ping(item): return { "id": item.id, "heartbeat_id": item.heartbeat_id, - "received_at": isoformat(item.received_at), + "received_at": serialize_utc_datetime(item.received_at), "event_type": item.event_type, "instance_key": item.instance_key, "status_before": item.status_before, diff --git a/app/services/serializers/incidents.py b/app/services/serializers/incidents.py index ec679bd..431ff35 100644 --- a/app/services/serializers/incidents.py +++ b/app/services/serializers/incidents.py @@ -1,6 +1,7 @@ import json from app.services.serializers.alerts import serialize_alert_group, serialize_attached_maintenance_ref +from app.services.serializers.common import serialize_utc_datetime def serialize_incident_stakeholder(stakeholder): @@ -27,8 +28,8 @@ def serialize_incident_stakeholder(stakeholder): "notify_on_comment": bool(getattr(stakeholder, "notify_on_comment", True)), "active": stakeholder.active, "created_by_id": stakeholder.created_by_id, - "created_at": stakeholder.created_at.isoformat() if stakeholder.created_at else None, - "updated_at": stakeholder.updated_at.isoformat() if stakeholder.updated_at else None, + "created_at": serialize_utc_datetime(stakeholder.created_at), + "updated_at": serialize_utc_datetime(stakeholder.updated_at), } @@ -47,7 +48,7 @@ def serialize_incident(group, *, current_user=None, include_details=False): "order": group.priority_order, "set_manually": group.priority_set_manually, "set_by_id": group.priority_set_by_id, - "set_at": group.priority_set_at.isoformat() if group.priority_set_at else None, + "set_at": serialize_utc_datetime(group.priority_set_at), } data["maintenance"] = { @@ -76,10 +77,6 @@ def _as_dict(value): return {} -def _isoformat(value): - return value.isoformat() if value else None - - def _extract_alert_annotations(payload): annotations = payload.get("annotations") if isinstance(annotations, dict): @@ -114,6 +111,8 @@ def _add_optional_incident_alert_fields(data, alert): "maintenance_window_id", "maintenance_behavior", "maintenance_suppressed", + "orchestration_suppressed", + "orchestration_suppress_reason", ) for field_name in optional_fields: @@ -129,6 +128,11 @@ def _add_optional_incident_alert_fields(data, alert): if "maintenance_suppressed" in data: data["maintenance_suppressed"] = bool(data["maintenance_suppressed"]) + if "orchestration_suppressed" in data: + data["orchestration_suppressed"] = bool( + data["orchestration_suppressed"] + ) + def serialize_incident_alert(alert): payload = _as_dict(getattr(alert, "payload", None)) @@ -163,19 +167,19 @@ def serialize_incident_alert(alert): "silenced": alert.silenced, "acknowledged_by_id": alert.acknowledged_by_id, - "acknowledged_at": _isoformat(alert.acknowledged_at), + "acknowledged_at": serialize_utc_datetime(alert.acknowledged_at), - "next_escalation_at": _isoformat(alert.next_escalation_at), - "last_escalated_at": _isoformat(alert.last_escalated_at), + "next_escalation_at": serialize_utc_datetime(alert.next_escalation_at), + "last_escalated_at": serialize_utc_datetime(alert.last_escalated_at), "escalation_repeat_count": alert.escalation_repeat_count, "escalation_level": alert.escalation_level, - "last_notification_at": _isoformat(alert.last_notification_at), + "last_notification_at": serialize_utc_datetime(alert.last_notification_at), "reminder_count": alert.reminder_count, - "first_seen_at": _isoformat(alert.first_seen_at), - "last_seen_at": _isoformat(alert.last_seen_at), - "resolved_at": _isoformat(alert.resolved_at), + "first_seen_at": serialize_utc_datetime(alert.first_seen_at), + "last_seen_at": serialize_utc_datetime(alert.last_seen_at), + "resolved_at": serialize_utc_datetime(alert.resolved_at), } _add_optional_incident_alert_fields(data, alert) diff --git a/app/services/serializers/services.py b/app/services/serializers/services.py index dee900f..b317a31 100644 --- a/app/services/serializers/services.py +++ b/app/services/serializers/services.py @@ -369,9 +369,7 @@ def serialize_service_owner(owner, current_user=None): "notify_on_status_change": bool(getattr(owner, "notify_on_status_change", True)), "notify_on_resolved": bool(getattr(owner, "notify_on_resolved", True)), "notify_on_comment": bool(getattr(owner, "notify_on_comment", True)), - "created_at": owner.created_at.isoformat() - if owner.created_at - else None, + "created_at": serialize_utc_datetime(owner.created_at), } @@ -570,7 +568,7 @@ def serialize_maintenance_window_scope(scope): "team_id": scope.team_id, "service_id": scope.service_id, "route_id": scope.route_id, - "created_at": scope.created_at.isoformat() if scope.created_at else None, + "created_at": serialize_utc_datetime(scope.created_at), } @@ -592,12 +590,15 @@ def serialize_maintenance_window(window, include_scopes=True): "ends_at": serialize_local_datetime(window.ends_at), "occurrence": serialize_maintenance_window_occurrence(window), "enabled": window.enabled, + "apply_to_existing": bool(window.apply_to_existing), + "reactivate_on_end": bool(window.reactivate_on_end), + "reconciled_at": serialize_utc_datetime(window.reconciled_at), "deleted": window.deleted, "cancelled_by_id": window.cancelled_by_id, - "cancelled_at": window.cancelled_at.isoformat() if window.cancelled_at else None, + "cancelled_at": serialize_utc_datetime(window.cancelled_at), "cancel_reason": window.cancel_reason, - "created_at": window.created_at.isoformat() if getattr(window, "created_at", None) else None, - "updated_at": window.updated_at.isoformat() if getattr(window, "updated_at", None) else None, + "created_at": serialize_utc_datetime(getattr(window, "created_at", None)), + "updated_at": serialize_utc_datetime(getattr(window, "updated_at", None)), } if include_scopes: diff --git a/app/services/serializers/sso.py b/app/services/serializers/sso.py index 32ff035..673c716 100644 --- a/app/services/serializers/sso.py +++ b/app/services/serializers/sso.py @@ -1,4 +1,5 @@ from app.modules.sso.saml_security import get_saml_security +from app.services.serializers.common import serialize_utc_datetime def serialize_sso_provider(provider): @@ -53,8 +54,8 @@ def serialize_sso_provider(provider): "extra_config": provider.extra_config or {}, "saml_security": get_saml_security(provider.extra_config), - "created_at": provider.created_at.isoformat() if provider.created_at else None, - "updated_at": provider.updated_at.isoformat() if provider.updated_at else None, + "created_at": serialize_utc_datetime(provider.created_at), + "updated_at": serialize_utc_datetime(provider.updated_at), } @@ -77,6 +78,6 @@ def serialize_sso_group_mapping(mapping): "team_role": mapping.team_role if team else None, "active": mapping.active, "priority": mapping.priority, - "created_at": mapping.created_at.isoformat() if mapping.created_at else None, - "updated_at": mapping.updated_at.isoformat() if mapping.updated_at else None, + "created_at": serialize_utc_datetime(mapping.created_at), + "updated_at": serialize_utc_datetime(mapping.updated_at), } diff --git a/app/services/serializers/tokens.py b/app/services/serializers/tokens.py index 5117c8a..d1fe010 100644 --- a/app/services/serializers/tokens.py +++ b/app/services/serializers/tokens.py @@ -1,4 +1,5 @@ -from datetime import datetime +from app.modules.common import utc_now +from app.services.serializers.common import serialize_utc_datetime def serialize_api_token(token): @@ -8,7 +9,7 @@ def serialize_api_token(token): Never expose token_hash or full raw token. """ expires_at = token.expires_at - expired = bool(expires_at and expires_at <= datetime.utcnow()) + expired = bool(expires_at and expires_at <= utc_now()) return { "id": token.id, @@ -22,7 +23,7 @@ def serialize_api_token(token): "team_slug": token.team.slug if token.team else None, "active": token.active, "expired": expired, - "created_at": token.created_at.isoformat() if token.created_at else None, - "expires_at": expires_at.isoformat() if expires_at else None, - "last_used_at": token.last_used_at.isoformat() if token.last_used_at else None, + "created_at": serialize_utc_datetime(token.created_at), + "expires_at": serialize_utc_datetime(expires_at), + "last_used_at": serialize_utc_datetime(token.last_used_at), } diff --git a/app/services/serializers/users.py b/app/services/serializers/users.py index 2651280..91a046e 100644 --- a/app/services/serializers/users.py +++ b/app/services/serializers/users.py @@ -25,6 +25,8 @@ def serialize_user(user, groups=None): "email": user.email, "phone": user.phone, "timezone": user.timezone, + "locale": getattr(user, "locale", None), + "theme": getattr(user, "theme", "system") or "system", "telegram_user_id": user.telegram_user_id, "slack_user_id": user.slack_user_id, "mattermost_user_id": user.mattermost_user_id, diff --git a/app/services/service_catalog/analytics.py b/app/services/service_catalog/analytics.py index 6d5236d..8995a47 100644 --- a/app/services/service_catalog/analytics.py +++ b/app/services/service_catalog/analytics.py @@ -7,6 +7,7 @@ from app.modules.db.models import Alert, AlertGroup, Service from app.services.serializers.services import serialize_utc_datetime from app.services.service_catalog.impact import build_single_service_impact_v2 +from app.modules.common import utc_now OPEN_ALERT_GROUP_STATUSES = {"firing", "acknowledged"} @@ -48,7 +49,7 @@ def build_service_analytics_v2(query, *, team_ids=None): requested_team_id = getattr(query, "team_id", None) requested_service_id = getattr(query, "service_id", None) - until = datetime.utcnow() + until = utc_now() since = until - timedelta(days=days) services = _load_services( diff --git a/app/services/service_catalog/impact_snapshots.py b/app/services/service_catalog/impact_snapshots.py index d31a11a..bd23e5a 100644 --- a/app/services/service_catalog/impact_snapshots.py +++ b/app/services/service_catalog/impact_snapshots.py @@ -10,6 +10,7 @@ ) from app.services.serializers.services import serialize_utc_datetime from app.services.service_catalog.impact import build_service_impact_v2 +from app.modules.common import utc_now IMPACTFUL_STATUSES = {"degraded", "partial_outage", "major_outage", "maintenance", "unknown"} STATUS_RANK = { @@ -45,7 +46,7 @@ def capture_service_impact_snapshot(query=None, *, team_ids=None, source="manual query = _normalize_snapshot_query(query) payload = build_service_impact_v2(query, team_ids=team_ids) items = list(payload.get("items") or []) - captured_at = datetime.utcnow() + captured_at = utc_now() summary = _build_snapshot_summary(items, payload.get("summary") or {}) scope = _snapshot_scope(query, team_ids=team_ids) @@ -114,7 +115,7 @@ def list_service_impact_snapshots(query, *, team_ids=None): """Return recent snapshots visible in the requested scope.""" days = int(getattr(query, "days", 7) or 7) limit = int(getattr(query, "limit", 50) or 50) - since = datetime.utcnow() - timedelta(days=days) + since = utc_now() - timedelta(days=days) snapshots = _snapshot_query( since=since, @@ -129,7 +130,7 @@ def list_service_impact_snapshots(query, *, team_ids=None): "window": { "days": days, "since": serialize_utc_datetime(since), - "until": serialize_utc_datetime(datetime.utcnow()), + "until": serialize_utc_datetime(utc_now()), }, "filters": { "team_id": getattr(query, "team_id", None), @@ -144,8 +145,8 @@ def build_service_impact_history(query, *, team_ids=None): days = int(getattr(query, "days", 30) or 30) limit = int(getattr(query, "limit", 25) or 25) bucket = getattr(query, "bucket", "day") or "day" - since = datetime.utcnow() - timedelta(days=days) - until = datetime.utcnow() + since = utc_now() - timedelta(days=days) + until = utc_now() snapshots = list(_snapshot_query( since=since, @@ -191,7 +192,7 @@ def build_service_impact_history(query, *, team_ids=None): def cleanup_service_impact_snapshots(*, retention_days): - cutoff = datetime.utcnow() - timedelta(days=int(retention_days)) + cutoff = utc_now() - timedelta(days=int(retention_days)) old_ids = [ snapshot.id for snapshot in ServiceImpactSnapshot diff --git a/app/services/service_catalog/presets.py b/app/services/service_catalog/presets.py index 8882594..7b9e9db 100644 --- a/app/services/service_catalog/presets.py +++ b/app/services/service_catalog/presets.py @@ -3,6 +3,7 @@ from peewee import IntegrityError from app.modules.db.models import ServiceStandard, ServiceStandardCheck +from app.modules.common import utc_now BASIC_OPERATIONAL_STANDARD_SLUG = "basic-operational-readiness" @@ -133,8 +134,8 @@ def _get_or_create_standard(group, *, actor_user=None): applies_to=BASIC_OPERATIONAL_STANDARD["applies_to"], enabled=BASIC_OPERATIONAL_STANDARD["enabled"], created_by=actor_user.id if actor_user else None, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ), True except IntegrityError: standard = ServiceStandard.get( @@ -169,8 +170,8 @@ def _get_or_create_check(standard, check_data): required=check_data["required"], enabled=check_data["enabled"], position=check_data["position"], - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ), True except IntegrityError: check = ServiceStandardCheck.get( diff --git a/app/services/service_catalog/readiness.py b/app/services/service_catalog/readiness.py index 3cbbbfe..19410ca 100644 --- a/app/services/service_catalog/readiness.py +++ b/app/services/service_catalog/readiness.py @@ -28,6 +28,7 @@ ) from app.services.service_catalog.standards import list_applicable_standards from app.services.service_catalog.timeline import publish_service_event +from app.modules.common import utc_now logger = logging.getLogger("oncall.services.readiness") @@ -42,7 +43,7 @@ class CheckOutcome: def evaluate_service_readiness(service, *, trigger="system", actor_user=None): batch_uid = uuid.uuid4() - evaluated_at = datetime.utcnow() + evaluated_at = utc_now() standards = list_applicable_standards(service) database = Service._meta.database diff --git a/app/services/service_catalog/sli_slo.py b/app/services/service_catalog/sli_slo.py index 0dcd1ac..ba0821b 100644 --- a/app/services/service_catalog/sli_slo.py +++ b/app/services/service_catalog/sli_slo.py @@ -3,6 +3,7 @@ from app.modules.db import maintenance_repo, services_repo from app.modules.db.models import AlertGroup from app.services.serializers.services import serialize_utc_datetime +from app.modules.common import as_utc_naive, utc_now SLI_TYPE_ACK_LATENCY = "alert_ack_latency" @@ -51,7 +52,7 @@ def normalize_slo_window_days(days): def evaluate_service_slos(slos, *, now=None, persist=True): """Evaluate a list of SLOs and return a dict keyed by SLO id.""" - now = now or datetime.utcnow() + now = now or utc_now() evaluations = {} for slo in slos: @@ -62,7 +63,7 @@ def evaluate_service_slos(slos, *, now=None, persist=True): def evaluate_service_slo(slo, *, now=None, persist=True): """Evaluate one Service Level Objective.""" - now = now or datetime.utcnow() + now = now or utc_now() window_days = normalize_slo_window_days(slo.window_days) since = now - timedelta(days=window_days) sli = slo.sli @@ -343,8 +344,8 @@ def _persist_measurement(slo, evaluation): "service": slo.service_id, "sli": slo.sli_id, "slo": slo.id, - "window_start": datetime.fromisoformat(evaluation["window"]["since"].replace("Z", "+00:00")).replace(tzinfo=None), - "window_end": datetime.fromisoformat(evaluation["window"]["until"].replace("Z", "+00:00")).replace(tzinfo=None), + "window_start": as_utc_naive(evaluation["window"]["since"]), + "window_end": as_utc_naive(evaluation["window"]["until"]), "status": evaluation["status"], "value_basis_points": evaluation.get("value_basis_points"), "value_count": evaluation.get("value_count"), diff --git a/app/services/service_catalog/standards.py b/app/services/service_catalog/standards.py index 1f99dae..8fd3f29 100644 --- a/app/services/service_catalog/standards.py +++ b/app/services/service_catalog/standards.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import ServiceStandard, ServiceStandardCheck +from app.modules.common import utc_now APPLICABILITY_FIELDS = { @@ -420,8 +421,8 @@ def create_service_standard(data, actor_user=None): values.get("applies_to") ) values["created_by"] = actor_user.id if actor_user else None - values["created_at"] = datetime.utcnow() - values["updated_at"] = datetime.utcnow() + values["created_at"] = utc_now() + values["updated_at"] = utc_now() return ServiceStandard.create(**values) @@ -437,7 +438,7 @@ def update_service_standard(standard, data): for field, value in values.items(): setattr(standard, field, value) - standard.updated_at = datetime.utcnow() + standard.updated_at = utc_now() standard.save() return standard @@ -445,7 +446,7 @@ def update_service_standard(standard, data): def delete_service_standard(standard): database = ServiceStandard._meta.database - now = datetime.utcnow() + now = utc_now() with database.atomic(): ServiceStandardCheck.update( @@ -497,8 +498,8 @@ def create_standard_check(standard, data): values.get("configuration"), ) values["standard"] = standard.id - values["created_at"] = datetime.utcnow() - values["updated_at"] = datetime.utcnow() + values["created_at"] = utc_now() + values["updated_at"] = utc_now() return ServiceStandardCheck.create(**values) @@ -516,7 +517,7 @@ def update_standard_check(check, data): for field, value in values.items(): setattr(check, field, value) - check.updated_at = datetime.utcnow() + check.updated_at = utc_now() check.save() return check @@ -525,7 +526,7 @@ def update_standard_check(check, data): def delete_standard_check(check): check.deleted = True check.enabled = False - check.updated_at = datetime.utcnow() + check.updated_at = utc_now() check.save() return check diff --git a/app/services/service_catalog/timeline.py b/app/services/service_catalog/timeline.py index 23de9c7..7f36a0d 100644 --- a/app/services/service_catalog/timeline.py +++ b/app/services/service_catalog/timeline.py @@ -3,6 +3,7 @@ from peewee import IntegrityError from app.modules.db.models import ServiceEvent +from app.modules.common import utc_now def publish_service_event(service, *, category, event_type, title, summary=None, source="incidentrelay", source_ref=None, dedup_key=None, external_url=None, actor_user=None, actor_type=None, actor_label=None, severity=None, status=None, occurred_at=None, payload=None): @@ -31,7 +32,7 @@ def publish_service_event(service, *, category, event_type, title, summary=None, actor_label=actor_label, severity=severity, status=status, - occurred_at=occurred_at or datetime.utcnow(), + occurred_at=occurred_at or utc_now(), payload=payload or {}, ) except IntegrityError: diff --git a/app/services/silences.py b/app/services/silences.py index 2be5190..619e291 100644 --- a/app/services/silences.py +++ b/app/services/silences.py @@ -1,18 +1,673 @@ -from app.modules.db import silences_repo +from __future__ import annotations + +from datetime import datetime + +from app.db import database_proxy +from app.modules.common import utc_now +from app.modules.db import alerts_repo, audit_repo, silences_repo +from app.modules.db.models import ( + Alert, + AlertGroup, + Silence, + SilenceAlertApplication, +) +from app.services.alerts.escalation import apply_initial_escalation_policy_assignment +from app.services.alerts.notification_queue import schedule_group_notification from app.services.routing.matcher.match_context import alert_rule_matches -def find_active_silence(team_id, alert_data): - """Return the first active silence matching an alert.""" +RELEASE_REASON_DISABLED = "disabled" +RELEASE_REASON_EXPIRED = "expired" +RELEASE_REASON_UPDATED = "updated" + + +def find_active_silences( + team_id: int | None, + alert_data: dict | Alert, + *, + now: datetime | None = None, +) -> list[Silence]: + """Return all active silences matching an alert.""" if not team_id: - return None + return [] - for silence in silences_repo.list_active_silences(team_id): + return [ + silence + for silence in silences_repo.list_active_silences(team_id, now=now) if alert_rule_matches( alert_data, silence, team=silence.team, - ): - return silence + ) + ] + + +def find_active_silence( + team_id: int | None, + alert_data: dict | Alert, + *, + now: datetime | None = None, +) -> Silence | None: + """Return the first active silence matching an alert.""" + silences = find_active_silences(team_id, alert_data, now=now) + return silences[0] if silences else None + + +def _silence_is_active( + silence: Silence, + now: datetime, +) -> bool: + return bool( + silence.enabled + and not silence.deleted + and silence.starts_at <= now < silence.ends_at + ) + + +def _alert_matches_silence(alert: Alert, silence: Silence) -> bool: + return alert_rule_matches( + alert, + silence, + team=alert.team, + route=alert.route, + service=alert.service, + priority=alert.priority_slug, + ) + + +def _record_transition_audit( + *, + action: str, + group: AlertGroup, + silence: Silence | None, + previous_status: str, + new_status: str, + trigger_source: str, +) -> None: + audit_repo.create_audit_log( + action=action, + object_type="alert_group", + object_id=group.id, + group_id=group.team.group_id if group.team else None, + team_id=group.team_id, + message=( + f"Silence {silence.name} changed alert group " + f"from {previous_status} to {new_status}" + if silence + else ( + f"Silence lifecycle changed alert group from " + f"{previous_status} to {new_status}" + ) + ), + data={ + "silence_id": silence.id if silence else None, + "silence_name": silence.name if silence else None, + "previous_status": previous_status, + "new_status": new_status, + "trigger_source": trigger_source, + }, + ) + + +def _record_alert_application_audit( + *, + action: str, + alert: Alert, + silence: Silence | None, + previous_status: str, + new_status: str, + trigger_source: str, +) -> None: + audit_repo.create_audit_log( + action=action, + object_type="alert", + object_id=alert.id, + group_id=alert.team.group_id if alert.team else None, + team_id=alert.team_id, + message=( + f"Silence {silence.name} changed alert " + f"from {previous_status} to {new_status}" + if silence + else ( + f"Silence lifecycle changed alert from " + f"{previous_status} to {new_status}" + ) + ), + data={ + "silence_id": silence.id if silence else None, + "silence_name": silence.name if silence else None, + "alert_group_id": alert.group_id, + "previous_status": previous_status, + "new_status": new_status, + "trigger_source": trigger_source, + }, + ) + + +def _pause_group_after_silencing( + group: AlertGroup, + *, + silence: Silence, + previous_status: str, + trigger_source: str, +) -> AlertGroup: + alerts_repo.clear_alert_group_notification(group) + group.next_escalation_at = None + group.updated_at = utc_now() + group.save() + + alerts_repo.create_alert_event( + group_id=group.id, + event_type="silenced", + message=f"Alert silenced by active Silence: {silence.name}", + ) + _record_transition_audit( + action="silence.alert_applied", + group=group, + silence=silence, + previous_status=previous_status, + new_status=group.status, + trigger_source=trigger_source, + ) + return group + + +def _restart_group_after_unsilencing( + group: AlertGroup, + *, + silence: Silence | None, + previous_status: str, + trigger_source: str, + now: datetime, +) -> AlertGroup: + fallback_rotation = group.route.rotation if group.route else group.rotation + policy_rule, rotation, assignee, next_escalation_at = ( + apply_initial_escalation_policy_assignment( + group.escalation_policy, + fallback_rotation, + now=now, + ) + ) + + group.escalation_rule = policy_rule + group.rotation = rotation + group.assignee = assignee + group.next_escalation_at = next_escalation_at + group.last_escalated_at = None + group.escalation_repeat_count = 0 + group.escalation_level = 0 + group.reminder_count = 0 + group.updated_at = now + group.save() + + if group.last_notification_at: + alerts_repo.schedule_alert_group_notification( + group, + due_at=now, + reason="reactivated", + ) + else: + schedule_group_notification( + group, + reason="notification", + now=now, + ) + + alerts_repo.create_alert_event( + group_id=group.id, + event_type="unsilenced", + message=( + f"Alert active again after Silence ended: {silence.name}" + if silence + else "Alert active again: no active Silence matches" + ), + ) + _record_transition_audit( + action="silence.alert_reactivated", + group=group, + silence=silence, + previous_status=previous_status, + new_status=group.status, + trigger_source=trigger_source, + ) + return group + + +def record_new_alert_silences( + alert: Alert, + silences: list[Silence], + *, + now: datetime | None = None, +) -> int: + """Persist all active Silences that matched a newly created alert.""" + now = now or utc_now() + created = 0 + + for silence in silences: + _, was_created = silences_repo.get_or_create_application( + silence=silence, + alert=alert, + previous_status="firing", + source="new_alert", + now=now, + ) + created += int(was_created) + + return created + + +def apply_silence_to_existing_alerts( + silence: Silence, + *, + now: datetime | None = None, + trigger_source: str = "api", +) -> dict[str, int]: + """Apply an opted-in active Silence to existing firing alerts.""" + now = now or utc_now() + result = {"matched": 0, "silenced": 0, "groups_changed": 0} + + if not silence.apply_to_existing or not _silence_is_active(silence, now): + return result + + alerts = list( + Alert.select() + .join(AlertGroup) + .where( + (Alert.team == silence.team_id) + & (Alert.status.in_(("firing", "silenced"))) + & (AlertGroup.status.in_(("firing", "silenced"))) + & (AlertGroup.merged_into.is_null(True)) + ) + .order_by(Alert.id.asc()) + ) + changed_group_ids: set[int] = set() + + with database_proxy.atomic(): + for alert in alerts: + if not _alert_matches_silence(alert, silence): + continue + + result["matched"] += 1 + previous_status = ( + alert.status + if alert.status in {"firing", "acknowledged"} + else ( + alert.previous_status + if alert.previous_status in {"firing", "acknowledged"} + else "firing" + ) + ) + _, created = silences_repo.get_or_create_application( + silence=silence, + alert=alert, + previous_status=previous_status, + source="retroactive", + now=now, + ) + if not created or alert.status == "silenced": + continue + + alert.previous_status = alert.status + alert.status = "silenced" + alert.silenced = True + alert.next_escalation_at = None + alert.save() + alerts_repo.create_alert_event( + alert_id=alert.id, + group_id=alert.group_id, + event_type="silenced", + message=f"Retroactively matched silence: {silence.name}", + ) + _record_alert_application_audit( + action="silence.alert_applied", + alert=alert, + silence=silence, + previous_status=previous_status, + new_status=alert.status, + trigger_source=trigger_source, + ) + changed_group_ids.add(alert.group_id) + result["silenced"] += 1 + + for group_id in changed_group_ids: + group = AlertGroup.get_by_id(group_id) + previous_status = group.status + group = alerts_repo.recalculate_alert_group(group) + if previous_status == "firing" and group.status == "silenced": + _pause_group_after_silencing( + group, + silence=silence, + previous_status=previous_status, + trigger_source=trigger_source, + ) + result["groups_changed"] += 1 + + silence.reconciled_at = now + silence.updated_at = now + silence.save() + + return result + + +def _release_application_alert( + application: SilenceAlertApplication, + *, + silence: Silence, + reason: str, + now: datetime, + trigger_source: str, +) -> int | None: + alert = application.alert + silences_repo.release_application(application, reason=reason, now=now) + + if alert.status == "resolved": + return None + + if silences_repo.has_other_active_application(alert.id): + return None + + if alert.status != "silenced": + return None + + restored_status = application.previous_status + if restored_status not in {"firing", "acknowledged"}: + restored_status = "firing" + + previous_status = alert.status + alert.previous_status = previous_status + alert.status = restored_status + alert.silenced = False + alert.next_escalation_at = None + alert.save() + alerts_repo.create_alert_event( + alert_id=alert.id, + group_id=alert.group_id, + event_type="unsilenced", + message=f"Silence no longer applies: {silence.name}", + ) + _record_alert_application_audit( + action="silence.alert_reactivated", + alert=alert, + silence=silence, + previous_status=previous_status, + new_status=alert.status, + trigger_source=trigger_source, + ) + return alert.group_id + + +def release_silence_applications( + silence: Silence, + *, + reason: str, + now: datetime | None = None, + trigger_source: str = "scheduler", + applications: list[SilenceAlertApplication] | None = None, + respect_reactivate_on_end: bool = True, +) -> dict[str, int]: + """Release alerts no longer covered by one Silence.""" + now = now or utc_now() + if applications is None: + applications = silences_repo.list_active_applications_for_silence( + silence.id + ) + result = { + "released": 0, + "reactivated": 0, + "groups_changed": 0, + "retained": 0, + } + + if respect_reactivate_on_end and not silence.reactivate_on_end: + result["retained"] = len(applications) + silence.reconciled_at = now + silence.updated_at = now + silence.save() + if applications: + audit_repo.create_audit_log( + action="silence.alerts_retained", + object_type="silence", + object_id=silence.id, + group_id=silence.team.group_id if silence.team else None, + team_id=silence.team_id, + message=( + f"Silence {silence.name} ended with automatic " + "reactivation disabled" + ), + data={ + "silence_id": silence.id, + "silence_name": silence.name, + "retained_alerts": len(applications), + "reason": reason, + "trigger_source": trigger_source, + }, + ) + return result + + changed_group_ids: set[int] = set() + + with database_proxy.atomic(): + for application in applications: + group_id = _release_application_alert( + application, + silence=silence, + reason=reason, + now=now, + trigger_source=trigger_source, + ) + result["released"] += 1 + if group_id is not None: + changed_group_ids.add(group_id) + result["reactivated"] += 1 + + for group_id in changed_group_ids: + group = AlertGroup.get_by_id(group_id) + previous_status = group.status + group = alerts_repo.recalculate_alert_group(group) + if previous_status == "silenced" and group.status == "firing": + _restart_group_after_unsilencing( + group, + silence=silence, + previous_status=previous_status, + trigger_source=trigger_source, + now=now, + ) + result["groups_changed"] += 1 + + silence.reconciled_at = now + silence.updated_at = now + silence.save() + + return result + + +def reconcile_silence( + silence: Silence, + *, + now: datetime | None = None, + trigger_source: str = "api", +) -> dict[str, int]: + """Reconcile one Silence after create, update, disable, start or expiry.""" + now = now or utc_now() + + if not _silence_is_active(silence, now): + if silence.enabled and not silence.deleted and now < silence.starts_at: + result = release_silence_applications( + silence, + reason=RELEASE_REASON_UPDATED, + now=now, + trigger_source=trigger_source, + respect_reactivate_on_end=False, + ) + silence.reconciled_at = None + silence.updated_at = now + silence.save() + return result + + reason = ( + RELEASE_REASON_DISABLED + if not silence.enabled or silence.deleted + else RELEASE_REASON_EXPIRED + ) + return release_silence_applications( + silence, + reason=reason, + now=now, + trigger_source=trigger_source, + ) + + # Release applications invalidated by matcher/team changes. New-alert + # applications remain active when they still match, even if retroactive + # application is later disabled for this Silence. + invalid_applications = [] + for application in silences_repo.list_active_applications_for_silence(silence.id): + alert = application.alert + should_keep = ( + alert.team_id == silence.team_id + and _alert_matches_silence(alert, silence) + and ( + application.source == "new_alert" + or silence.apply_to_existing + ) + ) + if not should_keep: + invalid_applications.append(application) + + if invalid_applications: + release_silence_applications( + silence, + reason=RELEASE_REASON_UPDATED, + now=now, + trigger_source=trigger_source, + applications=invalid_applications, + respect_reactivate_on_end=False, + ) + + return apply_silence_to_existing_alerts( + silence, + now=now, + trigger_source=trigger_source, + ) + + +def reconcile_orphan_silenced_alerts( + *, + now: datetime | None = None, +) -> dict[str, int]: + """Backfill or reactivate silenced alerts created before application tracking.""" + now = now or utc_now() + active_application_alerts = ( + SilenceAlertApplication + .select(SilenceAlertApplication.alert) + .where(SilenceAlertApplication.active == True) + ) + alerts = list( + Alert.select() + .join(AlertGroup) + .where( + (Alert.status == "silenced") + & (AlertGroup.status != "resolved") + & (Alert.id.not_in(active_application_alerts)) + ) + .order_by(Alert.id.asc()) + ) + result = {"backfilled": 0, "reactivated": 0, "groups_changed": 0} + changed_group_ids: set[int] = set() + + with database_proxy.atomic(): + for alert in alerts: + matching = find_active_silences( + alert.team_id, + alert, + now=now, + ) + if matching: + result["backfilled"] += record_new_alert_silences( + alert, + matching, + now=now, + ) + continue + + previous_status = alert.status + alert.previous_status = previous_status + alert.status = "firing" + alert.silenced = False + alert.next_escalation_at = None + alert.save() + alerts_repo.create_alert_event( + alert_id=alert.id, + group_id=alert.group_id, + event_type="unsilenced", + message="No active Silence matches this alert", + ) + _record_alert_application_audit( + action="silence.alert_reactivated", + alert=alert, + silence=None, + previous_status=previous_status, + new_status=alert.status, + trigger_source="scheduler", + ) + changed_group_ids.add(alert.group_id) + result["reactivated"] += 1 + + for group_id in changed_group_ids: + group = AlertGroup.get_by_id(group_id) + previous_status = group.status + group = alerts_repo.recalculate_alert_group(group) + if previous_status == "silenced" and group.status == "firing": + _restart_group_after_unsilencing( + group, + silence=None, + previous_status=previous_status, + trigger_source="scheduler", + now=now, + ) + result["groups_changed"] += 1 + + return result + + +def process_silence_lifecycle( + *, + now: datetime | None = None, +) -> dict[str, int]: + """Process scheduled Silence starts and releases idempotently.""" + now = now or utc_now() + result = { + "silences_started": 0, + "silences_released": 0, + "alerts_silenced": 0, + "alerts_reactivated": 0, + "legacy_alerts_backfilled": 0, + } + + orphaned = reconcile_orphan_silenced_alerts(now=now) + result["legacy_alerts_backfilled"] = orphaned["backfilled"] + result["alerts_reactivated"] += orphaned["reactivated"] + + for silence in silences_repo.list_due_retroactive_silences(now=now): + applied = apply_silence_to_existing_alerts( + silence, + now=now, + trigger_source="scheduler", + ) + result["silences_started"] += 1 + result["alerts_silenced"] += applied["silenced"] + + for silence in silences_repo.list_silences_with_due_releases(now=now): + released = release_silence_applications( + silence, + reason=( + RELEASE_REASON_DISABLED + if not silence.enabled or silence.deleted + else RELEASE_REASON_EXPIRED + ), + now=now, + trigger_source="scheduler", + ) + result["silences_released"] += 1 + result["alerts_reactivated"] += released["reactivated"] - return None + return result diff --git a/app/services/user_oncall_status.py b/app/services/user_oncall_status.py index c22bf82..5f8d967 100644 --- a/app/services/user_oncall_status.py +++ b/app/services/user_oncall_status.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from app.modules.db.models import ( EscalationPolicy, @@ -12,29 +12,15 @@ User, ) from app.services.calendar_service import build_rotation_calendar +from app.modules.common import as_utc_naive, utc_now +from app.services.serializers.common import serialize_utc_datetime DEFAULT_LOOKAHEAD_DAYS = 30 -def _utc_naive_now(): - return datetime.utcnow() - - def _parse_event_datetime(value): - if isinstance(value, datetime): - return value - - text = str(value) - if text.endswith("Z"): - text = text[:-1] + "+00:00" - - parsed = datetime.fromisoformat(text) - - if parsed.tzinfo is not None: - parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) - - return parsed + return as_utc_naive(value) def _display_name(name, slug, fallback="-"): @@ -74,12 +60,8 @@ def _serialize_user_oncall_event(event): "type": event.get("type"), "timezone": event.get("timezone") or "UTC", - "start": _parse_event_datetime(event["start"]).replace( - tzinfo=timezone.utc, - ).isoformat().replace("+00:00", "Z"), - "end": _parse_event_datetime(event["end"]).replace( - tzinfo=timezone.utc, - ).isoformat().replace("+00:00", "Z"), + "start": serialize_utc_datetime(event["start"]), + "end": serialize_utc_datetime(event["end"]), } @@ -166,14 +148,7 @@ def _list_user_rotations(user, *, start_at=None, end_at=None): def _serialize_datetime_utc(value): - parsed = _parse_event_datetime(value) - - return ( - parsed - .replace(tzinfo=timezone.utc) - .isoformat() - .replace("+00:00", "Z") - ) + return serialize_utc_datetime(value) def _policy_display_name(policy): @@ -414,7 +389,7 @@ def get_user_oncall_status( This uses the same calendar calculation as the calendar UI and future shift email notifications, so UI and scheduler stay consistent. """ - now = now or _utc_naive_now() + now = now or utc_now() lookahead_days = max(1, min(int(lookahead_days or DEFAULT_LOOKAHEAD_DAYS), 90)) lookbehind_days = max(lookahead_days, 7) diff --git a/app/settings.py b/app/settings.py index ce335d7..64d4bc8 100644 --- a/app/settings.py +++ b/app/settings.py @@ -130,12 +130,100 @@ class Config: JWT_COOKIE_NAME = settings.get("auth", "jwt_cookie_name", "incidentrelay_jwt") JWT_COOKIE_SECURE = settings.get_bool("auth", "jwt_cookie_secure", False) - SSO_SECRET_ENCRYPTION_KEY = settings.get("sso", "secret_encryption_key", SECRET_KEY) + SECRET_ENCRYPTION_KEY = settings.get("main", "secret_encryption_key", SECRET_KEY) + SSO_SECRET_ENCRYPTION_KEY = settings.get("sso", "secret_encryption_key", SECRET_ENCRYPTION_KEY) REMINDER_INTERVAL_SECONDS = settings.get_int("alerts", "reminder_interval_seconds", 60) ALERT_GROUP_WINDOW_SECONDS = settings.get_int("alerts", "alert_group_window_seconds", 3600) + MAINTENANCE_LIFECYCLE_CHECK_INTERVAL_SECONDS = settings.get_int( + "maintenance", "lifecycle_check_interval_seconds", 30 + ) + MAINTENANCE_LIFECYCLE_BATCH_SIZE = settings.get_int( + "maintenance", "lifecycle_batch_size", 500 + ) SCHEDULER_LOCK_TTL_SECONDS = settings.get_int("scheduler", "lock_ttl_seconds", 120) + ORCHESTRATION_PENDING_CHECK_INTERVAL_SECONDS = settings.get_int( + "orchestration", "pending_check_interval_seconds", 10 + ) + ORCHESTRATION_PENDING_BATCH_SIZE = settings.get_int( + "orchestration", "pending_batch_size", 100 + ) + ORCHESTRATION_PENDING_CLAIM_TTL_SECONDS = settings.get_int( + "orchestration", "pending_claim_ttl_seconds", 300 + ) + ORCHESTRATION_PENDING_MAX_ATTEMPTS = settings.get_int( + "orchestration", "pending_max_attempts", 5 + ) + ORCHESTRATION_PENDING_RETRY_BASE_SECONDS = settings.get_int( + "orchestration", "pending_retry_base_seconds", 30 + ) + ORCHESTRATION_DROPPED_TRACE_RETENTION_DAYS = settings.get_int( + "orchestration", "dropped_trace_retention_days", 7 + ) + ORCHESTRATION_PENDING_EVENT_RETENTION_DAYS = settings.get_int( + "orchestration", "pending_event_retention_days", 30 + ) + ORCHESTRATION_RETENTION_CLEANUP_INTERVAL_SECONDS = settings.get_int( + "orchestration", "retention_cleanup_interval_seconds", 86400 + ) + ORCHESTRATION_WEBHOOK_CHECK_INTERVAL_SECONDS = settings.get_int( + "orchestration", "webhook_check_interval_seconds", 5 + ) + ORCHESTRATION_WEBHOOK_BATCH_SIZE = settings.get_int( + "orchestration", "webhook_batch_size", 50 + ) + ORCHESTRATION_WEBHOOK_CLAIM_TTL_SECONDS = settings.get_int( + "orchestration", "webhook_claim_ttl_seconds", 300 + ) + ORCHESTRATION_WEBHOOK_RETRY_BASE_SECONDS = settings.get_int( + "orchestration", "webhook_retry_base_seconds", 30 + ) + ORCHESTRATION_WEBHOOK_MAX_RESPONSE_BYTES = settings.get_int( + "orchestration", "webhook_max_response_bytes", 65536 + ) + ORCHESTRATION_WEBHOOK_MAX_REDIRECTS = settings.get_int( + "orchestration", "webhook_max_redirects", 3 + ) + ORCHESTRATION_WEBHOOK_ALLOW_HTTP = settings.get_bool( + "orchestration", "webhook_allow_http", False + ) + ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_ALLOWLIST = settings.get( + "orchestration", "webhook_private_network_allowlist", "" + ) + ORCHESTRATION_WEBHOOK_PER_GROUP_CONCURRENCY = settings.get_int( + "orchestration", "webhook_per_group_concurrency", 2 + ) + ORCHESTRATION_WEBHOOK_PER_GROUP_RATE_PER_MINUTE = settings.get_int( + "orchestration", "webhook_per_group_rate_per_minute", 60 + ) + ORCHESTRATION_WEBHOOK_EXECUTION_RETENTION_DAYS = settings.get_int( + "orchestration", "webhook_execution_retention_days", 30 + ) + ORCHESTRATION_REPLAY_MAX_EVENTS = settings.get_int( + "orchestration", "replay_max_events", 100 + ) + ORCHESTRATION_REPLAY_DROP_WARNING_PERCENT = settings.get_int( + "orchestration", "replay_drop_warning_percent", 20 + ) + ORCHESTRATION_TRACE_MAX_DEPTH = settings.get_int( + "orchestration", "trace_max_depth", 12 + ) + ORCHESTRATION_TRACE_MAX_STRING_CHARS = settings.get_int( + "orchestration", "trace_max_string_chars", 2048 + ) + ORCHESTRATION_TRACE_MAX_ITEMS = settings.get_int( + "orchestration", "trace_max_items", 512 + ) + ORCHESTRATION_SIMULATION_MAX_PAYLOAD_BYTES = settings.get_int( + "orchestration", "simulation_max_payload_bytes", 1048576 + ) + ORCHESTRATION_SIMULATION_MAX_DIFFS = settings.get_int( + "orchestration", "simulation_max_diffs", 512 + ) + ORCHESTRATION_SHADOW_METRICS_MAX_EXECUTIONS = settings.get_int( + "orchestration", "shadow_metrics_max_executions", 5000 + ) USER_NOTIFICATION_RULES_CHECK_INTERVAL_SECONDS = settings.get_int( "scheduler", "user_notification_rules_check_interval_seconds", @@ -240,6 +328,13 @@ class Config: 100, ) + + SILENCE_LIFECYCLE_CHECK_INTERVAL_SECONDS = settings.get_int( + "alerts", + "silence_lifecycle_check_interval_seconds", + 30, + ) + ALERT_EXPLAIN_TRACE_RETENTION_DAYS = settings.get_int( "alerts", "alert_explain_trace_retention_days", diff --git a/app/static/css/audit_log.css b/app/static/css/audit_log.css new file mode 100644 index 0000000..83a5870 --- /dev/null +++ b/app/static/css/audit_log.css @@ -0,0 +1,102 @@ +.audit-log-header { + align-items: flex-start; + gap: 16px; +} + +.audit-log-filters { + display: grid; + grid-template-columns: minmax(240px, 2fr) repeat(6, minmax(150px, 1fr)); + gap: 12px; + padding: 16px; + border-top: 1px solid var(--md-border, #dfe3e8); + border-bottom: 1px solid var(--md-border, #dfe3e8); +} + +.audit-filter { + min-width: 0; +} + +.audit-filter label { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 600; +} + +.audit-log-table td { + vertical-align: top; +} + +.audit-log-action { + display: inline-flex; + max-width: 280px; + overflow-wrap: anywhere; +} + +.audit-log-message { + max-width: 360px; + white-space: normal; + overflow-wrap: anywhere; +} + +.audit-log-scope, +.audit-log-object, +.audit-log-actor { + display: flex; + flex-direction: column; + gap: 3px; +} + +.audit-log-secondary { + color: var(--md-text-muted, #667085); + font-size: 12px; +} + +.audit-log-json-card { + margin-top: 16px; +} + +.audit-log-json { + max-height: 480px; + margin: 0; + padding: 16px; + overflow: auto; + border-radius: 0 0 10px 10px; + background: var(--md-code-bg, #111827); + color: var(--md-code-text, #e5e7eb); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 1500px) { + .audit-log-filters { + grid-template-columns: repeat(4, minmax(170px, 1fr)); + } + + .audit-filter-search { + grid-column: span 2; + } +} + +@media (max-width: 900px) { + .audit-log-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .audit-filter-search { + grid-column: span 2; + } +} + +@media (max-width: 600px) { + .audit-log-filters { + grid-template-columns: 1fr; + } + + .audit-filter-search { + grid-column: auto; + } +} diff --git a/app/static/css/material.css b/app/static/css/material.css index df22e3f..d791b8e 100644 --- a/app/static/css/material.css +++ b/app/static/css/material.css @@ -3192,3 +3192,43 @@ body.md-theme .ui-status-badge-muted { color: var(--md-muted); border-color: var(--md-border); } + +/* Profile interface settings layout */ +body.md-theme .profile-settings-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +body.md-theme .profile-interface-fields .form-field { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + min-width: 0; +} + +body.md-theme .profile-interface-fields .form-field > span:first-child { + display: block; +} + +body.md-theme .profile-interface-fields .input, +body.md-theme .profile-interface-fields .ts-wrapper { + width: 100%; + min-width: 0; +} + +body.md-theme .profile-interface-fields .field-hint { + display: block; + font-weight: 600; + line-height: 1.45; +} + +@media (max-width: 1300px) { + body.md-theme .profile-settings-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + body.md-theme .profile-settings-grid { + grid-template-columns: 1fr; + } +} diff --git a/app/static/css/orchestrations.css b/app/static/css/orchestrations.css new file mode 100644 index 0000000..51a81bd --- /dev/null +++ b/app/static/css/orchestrations.css @@ -0,0 +1,56 @@ +.orchestration-workspace { overflow: hidden; } +.orchestration-workspace-header { align-items: flex-start; } +.orchestration-workspace-header h2 { margin: 10px 0 4px; } +.orchestration-tabs { padding: 0 20px; border-bottom: 1px solid var(--border-color, #dfe4ea); overflow-x: auto; } +.orchestration-tab-panel { padding: 20px; } +.orchestration-rule-list { display: grid; gap: 12px; } +.orchestration-rule-card { border: 1px solid var(--border-color, #dfe4ea); border-radius: 12px; padding: 14px; background: var(--card-background, #fff); } +.orchestration-rule-card-header, .orchestration-rule-controls, .orchestration-condition-group-header, .orchestration-action-row { display: flex; gap: 8px; align-items: center; } +.orchestration-rule-card-header { justify-content: space-between; } +.orchestration-rule-card-body { display: grid; gap: 8px; margin: 12px 0; } +.orchestration-rule-card-body > div { display: grid; grid-template-columns: 72px 1fr; gap: 8px; } +.orchestration-keyword { font-weight: 700; color: #344054; letter-spacing: .04em; } +.orchestration-rule-controls { justify-content: flex-end; flex-wrap: wrap; } +.orchestration-code, .orchestration-output { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.orchestration-json-panel { display: grid; gap: 12px; } +.orchestration-json-actions { justify-content: flex-end; } +.orchestration-output { min-height: 180px; max-height: 620px; overflow: auto; padding: 14px; border: 1px solid var(--border-color, #dfe4ea); border-radius: 10px; background: #0f172a; color: #e2e8f0; white-space: pre-wrap; word-break: break-word; } +.orchestration-simulator-grid > .card { min-width: 0; } +.orchestration-condition-editor, .orchestration-action-editor { display: grid; gap: 10px; } +.orchestration-condition-group { border-left: 3px solid #84adff; padding: 10px 0 4px 12px; margin: 4px 0; } +.orchestration-condition-group-header { flex-wrap: wrap; margin-bottom: 8px; } +.orchestration-condition-group-header select { width: 110px; } +.orchestration-condition-children { display: grid; gap: 8px; } +.orchestration-condition-leaf { display: grid; grid-template-columns: minmax(160px, 1.4fr) minmax(140px, .9fr) minmax(180px, 1.4fr) auto; gap: 8px; align-items: center; } +.orchestration-action-row { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(300px, 2fr) auto; border: 1px solid var(--border-color, #dfe4ea); border-radius: 10px; padding: 10px; } +.orchestration-action-params { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } +.orchestration-message { padding: 12px; border-radius: 8px; white-space: pre-wrap; margin-bottom: 12px; } +.orchestration-message.validation-success { background: #ecfdf3; color: #027a48; border: 1px solid #abefc6; } +.orchestration-message.validation-error { background: #fef3f2; color: #b42318; border: 1px solid #fecdca; } +.orchestration-metrics { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } +.orchestration-metric { min-width: 140px; display: flex; justify-content: space-between; gap: 18px; padding: 9px 12px; border: 1px solid var(--border-color, #dfe4ea); border-radius: 8px; } +.orchestration-diff-grid { margin-top: 18px; } +.orchestration-rule-editor-grid { align-items: start; } +body.md-theme #orchestration-webhook-modal .app-modal-dialog-wide, +#orchestration-webhook-modal .app-modal-dialog-wide { width: min(1120px, calc(100vw - 48px)); } +.orchestration-webhook-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; } +.orchestration-webhook-section { min-width: 0; margin: 0; overflow: hidden; } +.orchestration-webhook-section .form-body { min-width: 0; } +.orchestration-webhook-section .input, +.orchestration-webhook-section .json-editor { width: 100%; box-sizing: border-box; } +.orchestration-webhook-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.orchestration-webhook-options > div { display: grid; gap: 9px; min-width: 0; } +.btn-danger { color: #b42318; border-color: #fda29b; } +.btn-success { color: #fff; background: #039855; border-color: #039855; } +.status-badge.status-warning { background: #fffaeb; color: #b54708; } +.status-badge.status-muted { background: #f2f4f7; color: #475467; } +.status-badge.status-active { background: #ecfdf3; color: #027a48; } +@media (max-width: 900px) { + .orchestration-condition-leaf, .orchestration-action-row { grid-template-columns: 1fr; } + .orchestration-rule-card-body > div { grid-template-columns: 1fr; } + .orchestration-webhook-grid, .orchestration-webhook-options { grid-template-columns: 1fr; } +} + +.orchestration-rule-card[draggable="true"] { cursor: grab; } +.orchestration-rule-card[draggable="true"]:active { cursor: grabbing; } +.orchestration-rule-card.is-drag-over { outline: 2px solid currentColor; outline-offset: 3px; } diff --git a/app/static/css/services.css b/app/static/css/services.css index 757ecc7..57f9436 100644 --- a/app/static/css/services.css +++ b/app/static/css/services.css @@ -624,3 +624,27 @@ button.impact-path-node { .impact-history-toolbar { justify-content: flex-end; } + +.service-orchestration-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + min-width: 0; +} + +.service-orchestration-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + min-width: 0; +} + +.service-orchestration-link { + min-width: 0; + overflow: hidden; + color: var(--link-color, #2563eb); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/app/static/css/theme_dark.css b/app/static/css/theme_dark.css new file mode 100644 index 0000000..c247ef7 --- /dev/null +++ b/app/static/css/theme_dark.css @@ -0,0 +1,426 @@ +/* IncidentRelay dark color scheme. + * + * The active scheme is resolved by js/core/theme.js before stylesheets load. + * Keep component-specific rules here as overrides so existing page styles do + * not need to duplicate complete light and dark definitions. + */ + +html[data-color-scheme="dark"] { + color-scheme: dark; + + --bg: #0b1220; + --sidebar-bg: #020617; + --sidebar-border: #1e293b; + --card-bg: #111827; + --text: #e5edf6; + --muted: #94a3b8; + --border: #334155; + --primary: #60a5fa; + --primary-hover: #93c5fd; + --danger: #f87171; + --warning: #fbbf24; + --success: #4ade80; + --info: #38bdf8; + --shadow: 0 12px 36px rgba(0, 0, 0, 0.34); + + /* Compatibility variables used by page-specific styles. */ + --card-background: #111827; + --border-color: #334155; + + --md-bg: #0b1220; + --md-surface: #111827; + --md-surface-soft: #172033; + --md-surface-hover: #1e293b; + + --md-sidebar-bg: #020617; + --md-sidebar-bg-2: #0f172a; + --md-sidebar-border: rgba(148, 163, 184, 0.18); + --md-sidebar-text: #cbd5e1; + --md-sidebar-muted: #94a3b8; + --md-sidebar-active: rgba(96, 165, 250, 0.24); + + --md-border: #334155; + --md-border-strong: #475569; + + --md-text: #e5edf6; + --md-text-soft: #cbd5e1; + --md-muted: #94a3b8; + + --md-primary: #60a5fa; + --md-primary-hover: #93c5fd; + --md-primary-soft: rgba(59, 130, 246, 0.2); + + --md-danger: #f87171; + --md-danger-soft: rgba(248, 113, 113, 0.16); + + --md-warning: #fbbf24; + --md-warning-soft: rgba(251, 191, 36, 0.16); + + --md-success: #4ade80; + --md-success-soft: rgba(74, 222, 128, 0.16); + + --md-info: #38bdf8; + --md-info-soft: rgba(56, 189, 248, 0.16); + + --md-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.32); + --md-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.34); + --md-shadow-lg: 0 22px 58px rgba(0, 0, 0, 0.46); +} + +html[data-color-scheme="dark"] body.md-theme { + background: + radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 34rem), + var(--md-bg); +} + +html[data-color-scheme="dark"] body.md-theme .sidebar { + box-shadow: 8px 0 30px rgba(0, 0, 0, 0.34); +} + +html[data-color-scheme="dark"] body.md-theme .sidebar-toggle { + background: #172033; +} + +html[data-color-scheme="dark"] body.md-theme .topbar, +html[data-color-scheme="dark"] body.md-theme .card, +html[data-color-scheme="dark"] body.md-theme .summary-card, +html[data-color-scheme="dark"] body.md-theme .metric-card, +html[data-color-scheme="dark"] body.md-theme .table-wrapper, +html[data-color-scheme="dark"] body.md-theme .details-body, +html[data-color-scheme="dark"] body.md-theme .calendar-shell, +html[data-color-scheme="dark"] body.md-theme .calendar-legend, +html[data-color-scheme="dark"] body.md-theme .app-modal-dialog, +html[data-color-scheme="dark"] body.md-theme .modal-content, +html[data-color-scheme="dark"] body.md-theme .drawer, +html[data-color-scheme="dark"] body.md-theme .drawer-panel, +html[data-color-scheme="dark"] body.md-theme .popover, +html[data-color-scheme="dark"] body.md-theme .dropdown-menu { + background-color: var(--md-surface); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .card:hover { + box-shadow: var(--md-shadow-md); +} + +html[data-color-scheme="dark"] body.md-theme .card-header { + background: linear-gradient(180deg, var(--md-surface-soft), var(--md-surface)); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .app-modal-header, +html[data-color-scheme="dark"] body.md-theme .modal-header { + background: linear-gradient(180deg, var(--md-surface-soft), var(--md-surface)); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .app-modal-footer, +html[data-color-scheme="dark"] body.md-theme .modal-footer { + background: var(--md-surface); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .page-tabs { + background: var(--md-surface); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .page-tab:hover { + background: var(--md-surface-hover); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .page-tab.is-active { + background: var(--md-surface-soft); + color: var(--md-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.32); +} + +html[data-color-scheme="dark"] body.md-theme .summary-mini-item, +html[data-color-scheme="dark"] body.md-theme .section-box, +html[data-color-scheme="dark"] body.md-theme .editor-panel, +html[data-color-scheme="dark"] body.md-theme .calendar-team-header, +html[data-color-scheme="dark"] body.md-theme .calendar-day-header, +html[data-color-scheme="dark"] body.md-theme .calendar-team-cell, +html[data-color-scheme="dark"] body.md-theme .calendar-month-weekday, +html[data-color-scheme="dark"] body.md-theme .calendar-day-outside-month, +html[data-color-scheme="dark"] body.md-theme .empty-state, +html[data-color-scheme="dark"] body.md-theme .details-item, +html[data-color-scheme="dark"] body.md-theme .list-item, +html[data-color-scheme="dark"] body.md-theme .stack-card, +html[data-color-scheme="dark"] body.md-theme .filter-panel, +html[data-color-scheme="dark"] body.md-theme .toolbar-panel, +html[data-color-scheme="dark"] body.md-theme .profile-group-item { + background-color: var(--md-surface-soft); + color: var(--md-text); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .calendar-team-header, +html[data-color-scheme="dark"] body.md-theme .calendar-day-header, +html[data-color-scheme="dark"] body.md-theme .calendar-team-cell, +html[data-color-scheme="dark"] body.md-theme .calendar-day-cell, +html[data-color-scheme="dark"] body.md-theme .calendar-month-weekday, +html[data-color-scheme="dark"] body.md-theme .calendar-month-day-cell { + background-color: var(--md-surface); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .calendar-day-outside-month { + background-color: #0f172a; +} + +html[data-color-scheme="dark"] body.md-theme h1, +html[data-color-scheme="dark"] body.md-theme h2, +html[data-color-scheme="dark"] body.md-theme h3, +html[data-color-scheme="dark"] body.md-theme h4, +html[data-color-scheme="dark"] body.md-theme .profile-hero h2, +html[data-color-scheme="dark"] body.md-theme .profile-group-name, +html[data-color-scheme="dark"] body.md-theme .details-value, +html[data-color-scheme="dark"] body.md-theme .row-title, +html[data-color-scheme="dark"] body.md-theme .item-title { + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .page-subtitle, +html[data-color-scheme="dark"] body.md-theme .card-subtitle, +html[data-color-scheme="dark"] body.md-theme .row-subtitle, +html[data-color-scheme="dark"] body.md-theme .help-text, +html[data-color-scheme="dark"] body.md-theme .field-hint, +html[data-color-scheme="dark"] body.md-theme .details-label, +html[data-color-scheme="dark"] body.md-theme .profile-meta, +html[data-color-scheme="dark"] body.md-theme .profile-eyebrow { + color: var(--md-muted); +} + +html[data-color-scheme="dark"] body.md-theme .input, +html[data-color-scheme="dark"] body.md-theme input[type="text"], +html[data-color-scheme="dark"] body.md-theme input[type="email"], +html[data-color-scheme="dark"] body.md-theme input[type="password"], +html[data-color-scheme="dark"] body.md-theme input[type="number"], +html[data-color-scheme="dark"] body.md-theme input[type="datetime-local"], +html[data-color-scheme="dark"] body.md-theme input[type="date"], +html[data-color-scheme="dark"] body.md-theme input[type="time"], +html[data-color-scheme="dark"] body.md-theme input[type="search"], +html[data-color-scheme="dark"] body.md-theme input[type="tel"], +html[data-color-scheme="dark"] body.md-theme input[type="url"], +html[data-color-scheme="dark"] body.md-theme select, +html[data-color-scheme="dark"] body.md-theme textarea { + background-color: #0f172a; + border-color: var(--md-border); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme input:disabled, +html[data-color-scheme="dark"] body.md-theme select:disabled, +html[data-color-scheme="dark"] body.md-theme textarea:disabled { + background-color: #172033; + color: #94a3b8; +} + +html[data-color-scheme="dark"] body.md-theme input::placeholder, +html[data-color-scheme="dark"] body.md-theme textarea::placeholder { + color: #64748b; +} + +html[data-color-scheme="dark"] body.md-theme .btn:not(.btn-primary):not(.btn-danger):not(.btn-warning):not(.btn-success):not(.btn-info), +html[data-color-scheme="dark"] body.md-theme .topbar-icon-button, +html[data-color-scheme="dark"] body.md-theme .app-modal-close { + background-color: var(--md-surface-soft); + border-color: var(--md-border); + color: var(--md-text-soft); +} + +html[data-color-scheme="dark"] body.md-theme .btn:not(.btn-primary):not(.btn-danger):not(.btn-warning):not(.btn-success):not(.btn-info):hover, +html[data-color-scheme="dark"] body.md-theme .topbar-icon-button:hover, +html[data-color-scheme="dark"] body.md-theme .app-modal-close:hover { + background-color: var(--md-surface-hover); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .data-table, +html[data-color-scheme="dark"] body.md-theme table { + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .data-table th, +html[data-color-scheme="dark"] body.md-theme table th { + background-color: #172033; + border-color: var(--md-border); + color: var(--md-text-soft); +} + +html[data-color-scheme="dark"] body.md-theme .data-table td, +html[data-color-scheme="dark"] body.md-theme table td { + background-color: var(--md-surface); + border-color: var(--md-border); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .data-table tr:hover td, +html[data-color-scheme="dark"] body.md-theme table tbody tr:hover td { + background-color: var(--md-surface-hover); +} + +html[data-color-scheme="dark"] body.md-theme .table-toolbar, +html[data-color-scheme="dark"] body.md-theme .table-active-filters, +html[data-color-scheme="dark"] body.md-theme .table-pagination, +html[data-color-scheme="dark"] body.md-theme .alerts-pagination { + background-color: var(--md-surface); + border-color: var(--md-border); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .table-pagination-left, +html[data-color-scheme="dark"] body.md-theme .table-pagination-center, +html[data-color-scheme="dark"] body.md-theme .table-pagination-actions, +html[data-color-scheme="dark"] body.md-theme .table-title { + color: var(--md-text-soft); +} + +html[data-color-scheme="dark"] body.md-theme .table-filter-chip, +html[data-color-scheme="dark"] body.md-theme .table-meta-pill { + background-color: var(--md-surface-soft); + color: var(--md-text-soft); +} + +html[data-color-scheme="dark"] body.md-theme .page-tab, +html[data-color-scheme="dark"] body.md-theme .tab-button, +html[data-color-scheme="dark"] body.md-theme [role="tab"] { + color: var(--md-muted); +} + +html[data-color-scheme="dark"] body.md-theme .page-tab.is-active, +html[data-color-scheme="dark"] body.md-theme .tab-button.is-active, +html[data-color-scheme="dark"] body.md-theme [role="tab"][aria-selected="true"] { + color: var(--md-primary); +} + +html[data-color-scheme="dark"] body.md-theme .app-action-menu-list, +html[data-color-scheme="dark"] body.md-theme .app-action-menu-list.app-action-menu-portal { + background-color: var(--md-surface); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .app-action-menu-item { + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .app-action-menu-item:hover { + background-color: var(--md-surface-hover); +} + +html[data-color-scheme="dark"] body.md-theme .app-action-menu-item.is-danger { + color: var(--md-danger); +} + +html[data-color-scheme="dark"] body.md-theme .app-action-menu-item.is-danger:hover { + background-color: var(--md-danger-soft); +} + +html[data-color-scheme="dark"] body.md-theme .select2-container .select2-selection--single, +html[data-color-scheme="dark"] body.md-theme .select2-container .select2-selection--multiple, +html[data-color-scheme="dark"] body.md-theme .select2-dropdown, +html[data-color-scheme="dark"] body.md-theme .select2-search__field { + background-color: #0f172a; + border-color: var(--md-border); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .select2-container--default .select2-selection--single .select2-selection__rendered, +html[data-color-scheme="dark"] body.md-theme .select2-container--default .select2-results__option, +html[data-color-scheme="dark"] body.md-theme .select2-container--default .select2-search--dropdown .select2-search__field { + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .select2-container--default .select2-results__option--highlighted[aria-selected], +html[data-color-scheme="dark"] body.md-theme .select2-container--default .select2-results__option--selected { + background-color: var(--md-surface-hover); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .ts-wrapper .ts-control, +html[data-color-scheme="dark"] body.md-theme .ts-dropdown, +html[data-color-scheme="dark"] body.md-theme .ts-dropdown .active { + background-color: #0f172a; + border-color: var(--md-border); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .ts-dropdown .option:hover, +html[data-color-scheme="dark"] body.md-theme .ts-dropdown .active { + background-color: var(--md-surface-hover); +} + +html[data-color-scheme="dark"] body.md-theme pre, +html[data-color-scheme="dark"] body.md-theme code, +html[data-color-scheme="dark"] body.md-theme .code-block, +html[data-color-scheme="dark"] body.md-theme .json-view, +html[data-color-scheme="dark"] body.md-theme .payload-view { + background-color: #020617; + color: #dbeafe; + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme hr { + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme canvas { + color-scheme: dark; +} + +html[data-color-scheme="dark"] body.md-theme .cytoscape-container, +html[data-color-scheme="dark"] body.md-theme .dependency-graph, +html[data-color-scheme="dark"] body.md-theme .chart-container { + background-color: var(--md-surface); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .badge-muted, +html[data-color-scheme="dark"] body.md-theme .badge-neutral, +html[data-color-scheme="dark"] body.md-theme .pill-muted { + background-color: var(--md-surface-soft); + color: var(--md-text-soft); + border-color: var(--md-border); +} + +html[data-color-scheme="dark"] body.md-theme .orchestration-rule-card, +html[data-color-scheme="dark"] body.md-theme .orchestration-action-row, +html[data-color-scheme="dark"] body.md-theme .orchestration-metric { + background-color: var(--md-surface-soft); + border-color: var(--md-border); + color: var(--md-text); +} + +html[data-color-scheme="dark"] body.md-theme .orchestration-keyword { + color: var(--md-text-soft); +} + +html[data-color-scheme="dark"] body.md-theme .orchestration-message.validation-success, +html[data-color-scheme="dark"] body.md-theme .status-badge.status-active { + background-color: var(--md-success-soft); + border-color: rgba(74, 222, 128, 0.38); + color: var(--md-success); +} + +html[data-color-scheme="dark"] body.md-theme .orchestration-message.validation-error { + background-color: var(--md-danger-soft); + border-color: rgba(248, 113, 113, 0.38); + color: var(--md-danger); +} + +html[data-color-scheme="dark"] body.md-theme .status-badge.status-warning { + background-color: var(--md-warning-soft); + color: var(--md-warning); +} + +html[data-color-scheme="dark"] body.md-theme .status-badge.status-muted { + background-color: var(--md-surface-soft); + color: var(--md-muted); +} + +html[data-color-scheme="dark"] body.md-theme ::selection { + background: rgba(96, 165, 250, 0.35); + color: #ffffff; +} diff --git a/app/static/i18n/de/audit_logs.json b/app/static/i18n/de/audit_logs.json new file mode 100644 index 0000000..1e1a48e --- /dev/null +++ b/app/static/i18n/de/audit_logs.json @@ -0,0 +1,64 @@ +{ + "nav.audit_log": "Audit-Protokoll", + "pages.audit-log.title": "Audit-Protokoll", + "pages.audit-log.subtitle": "Verlauf administrativer und sicherheitsrelevanter Änderungen", + "audit.summary.entries": "Einträge", + "audit.summary.entries_hint": "Einträge entsprechend den aktuellen Filtern", + "audit.summary.actors": "Akteure", + "audit.summary.actors_hint": "Benutzer in den gefilterten Ergebnissen", + "audit.summary.actions": "Aktionen", + "audit.summary.actions_hint": "Unterschiedliche Aktionstypen im Ergebnis", + "audit.summary.groups": "Gruppen", + "audit.summary.groups_hint": "Für Ihr Konto sichtbare Gruppen", + "audit.list.title": "Administrative Aktivitäten", + "audit.list.subtitle": "Änderungen aus Oberfläche und API nachvollziehen.", + "audit.list.global_scope": "Globale Administratoren sehen alle Audit-Einträge, einschließlich globaler Einträge ohne Gruppe.", + "audit.list.editor_scope": "Gruppen-Editoren sehen nur Einträge aus Gruppen, in denen sie die Rolle editor besitzen.", + "audit.actions.reload": "Neu laden", + "audit.actions.clear_filters": "Filter zurücksetzen", + "audit.actions.view": "Anzeigen", + "audit.actions.close": "Schließen", + "audit.filters.search": "Suche", + "audit.filters.search_placeholder": "Aktion, Objekt, Nachricht oder Akteur", + "audit.filters.group": "Gruppe", + "audit.filters.all_groups": "Alle zugänglichen Gruppen", + "audit.filters.actor": "Akteur", + "audit.filters.all_actors": "Alle Akteure", + "audit.filters.action": "Aktion", + "audit.filters.all_actions": "Alle Aktionen", + "audit.filters.object_type": "Objekttyp", + "audit.filters.all_object_types": "Alle Objekttypen", + "audit.filters.date_from": "Von", + "audit.filters.date_to": "Bis", + "audit.table.time": "Zeit", + "audit.table.actor": "Akteur", + "audit.table.action": "Aktion", + "audit.table.object": "Objekt", + "audit.table.scope": "Bereich", + "audit.table.message": "Nachricht", + "audit.table.details": "Details", + "audit.row.system": "System", + "audit.row.api_token": "API-Token: {name}", + "audit.row.global_scope": "Global", + "audit.row.team": "Team: {name}", + "audit.row.not_available": "Nicht verfügbar", + "audit.empty": "Für die aktuellen Filter wurden keine Audit-Einträge gefunden.", + "audit.pagination.rows": "Zeilen", + "audit.pagination.page": "Seite", + "audit.pagination.page_value": "Seite {page} / {total}", + "audit.pagination.range": "{from}–{to} / {total}", + "audit.pagination.previous": "Zurück", + "audit.pagination.next": "Weiter", + "audit.details.title": "Audit-Eintrag", + "audit.details.entry": "Audit-Eintrag #{id}", + "audit.details.time": "Zeit", + "audit.details.actor": "Akteur", + "audit.details.action": "Aktion", + "audit.details.object": "Objekt", + "audit.details.group": "Gruppe", + "audit.details.team": "Team", + "audit.details.message": "Nachricht", + "audit.details.payload": "Gespeicherte Daten", + "audit.details.payload_hint": "Sensible Werte werden vor dem Speichern des Audit-Eintrags geschwärzt.", + "audit.errors.access_denied": "Zum Anzeigen des Audit-Protokolls ist eine globale Administrator- oder Gruppen-Editor-Rolle erforderlich." +} diff --git a/app/static/i18n/de/maintenance.json b/app/static/i18n/de/maintenance.json index 476d1de..4d8be8e 100644 --- a/app/static/i18n/de/maintenance.json +++ b/app/static/i18n/de/maintenance.json @@ -134,5 +134,13 @@ "maintenance.permissions.duplicate": "Die Rolle des Teammanagers ist erforderlich, um dieses Wartungsfenster zu duplizieren.", "maintenance.permissions.cancel": "Die Rolle des Teammanagers ist erforderlich, um dieses Wartungsfenster abzubrechen.", "maintenance.permissions.delete": "Die Löschberechtigung ist erforderlich, um dieses Wartungsfenster zu löschen.", - "maintenance.badge.default": "Wartung" + "maintenance.badge.default": "Wartung", + "maintenance.form.apply_to_existing": "Auf bestehende ungelöste Alarme anwenden", + "maintenance.form.apply_to_existing_help": "Optional. Passende ungelöste Alarmgruppen, die beim Start bereits bestanden, werden sofort berücksichtigt.", + "maintenance.form.apply_to_existing_unavailable": "„Vorfall unterdrücken“ verhindert die Erstellung neuer Alarmgruppen und kann nicht rückwirkend angewendet werden.", + "maintenance.form.reactivate_on_end": "Betroffene Alarme nach Ende der Wartung reaktivieren", + "maintenance.form.reactivate_on_end_help": "Standardmäßig aktiviert. Effekte werden erst nach dem letzten anwendbaren Wartungsfenster aufgehoben.", + "maintenance.form.reactivate_on_end_warning": "Betroffene Alarme behalten den Wartungseffekt nach Ende dieses Fensters bei. Aktivieren Sie die Option später und speichern Sie das Fenster, um sie freizugeben.", + "maintenance.details.apply_to_existing": "Bestehende Alarme", + "maintenance.details.reactivate_on_end": "Nach Ende reaktivieren" } diff --git a/app/static/i18n/de/orchestrations.json b/app/static/i18n/de/orchestrations.json new file mode 100644 index 0000000..00e7bb6 --- /dev/null +++ b/app/static/i18n/de/orchestrations.json @@ -0,0 +1,137 @@ +{ + "nav.event_orchestration": "Ereignis-Orchestrierung", + "pages.orchestrations.title": "Ereignis-Orchestrierung", + "pages.orchestrations.subtitle": "Eingehende Ereignisse routen, anreichern, unterdrücken und automatisieren", + "orchestrations.summary.total": "Orchestrierungen", + "orchestrations.summary.total_hint": "Definitions in the selected group", + "orchestrations.summary.active": "Aktiv", + "orchestrations.summary.active_hint": "Applied to production events", + "orchestrations.summary.shadow": "Schattenmodus", + "orchestrations.summary.shadow_hint": "Evaluated without changing behavior", + "orchestrations.summary.drafts": "Entwürfe", + "orchestrations.summary.drafts_hint": "Unpublished working versions", + "orchestrations.list.title": "Ereignis-Orchestrierungen", + "orchestrations.list.items": "orchestrations", + "orchestrations.actions.create": "Neue Orchestrierung", + "orchestrations.actions.reload": "Neu laden", + "orchestrations.actions.open": "Öffnen", + "orchestrations.actions.back": "Zurück", + "orchestrations.actions.save_draft": "Entwurf speichern", + "orchestrations.actions.validate": "Validieren", + "orchestrations.actions.publish": "Veröffentlichen", + "orchestrations.actions.add_rule": "Regel hinzufügen", + "orchestrations.actions.json_view": "JSON-Ansicht", + "orchestrations.actions.builder_view": "Builder", + "orchestrations.actions.apply_json": "JSON anwenden", + "orchestrations.actions.format_json": "JSON formatieren", + "orchestrations.actions.run_simulation": "Simulation starten", + "orchestrations.actions.view": "Anzeigen", + "orchestrations.actions.rollback": "Zurückrollen", + "orchestrations.actions.trace": "Trace", + "orchestrations.actions.add_webhook": "Webhook hinzufügen", + "orchestrations.actions.save": "Speichern", + "orchestrations.actions.save_runtime": "Runtime speichern", + "orchestrations.actions.delete": "Löschen", + "orchestrations.actions.edit": "Bearbeiten", + "orchestrations.actions.duplicate": "Duplizieren", + "orchestrations.actions.save_rule": "Regel speichern", + "orchestrations.search.placeholder": "Orchestrierungen suchen", + "orchestrations.filters.all_modes": "Alle Modi", + "orchestrations.filters.all_scopes": "Alle Bereiche", + "orchestrations.mode.active": "Aktiv", + "orchestrations.mode.shadow": "Schatten", + "orchestrations.mode.disabled": "Deaktiviert", + "orchestrations.scope.global": "Global", + "orchestrations.scope.service": "Service", + "orchestrations.table.name": "Name", + "orchestrations.table.scope": "Bereich", + "orchestrations.table.mode": "Modus", + "orchestrations.table.compatibility": "Kompatibilität", + "orchestrations.table.version": "Version", + "orchestrations.table.updated": "Aktualisiert", + "orchestrations.table.actions": "Aktionen", + "orchestrations.empty.loading": "Orchestrierungen werden geladen...", + "orchestrations.empty.none": "Keine Orchestrierungen gefunden", + "orchestrations.tabs.rules": "Regeln", + "orchestrations.tabs.simulator": "Simulator", + "orchestrations.tabs.versions": "Versionen", + "orchestrations.tabs.executions": "Ausführungen", + "orchestrations.tabs.webhooks": "Webhook-Aktionen", + "orchestrations.tabs.settings": "Einstellungen", + "orchestrations.rules.title": "Geordnete Regeln", + "orchestrations.rules.help": "Rules run from top to bottom. Use nested groups for AND, OR and NOT logic.", + "orchestrations.rules.definition_json": "Definition JSON", + "orchestrations.rules.empty": "Dieser Entwurf enthält noch keine Regeln.", + "orchestrations.rules.catch_all": "Matches every event", + "orchestrations.rules.no_actions": "No actions", + "orchestrations.rules.no_draft": "No draft", + "orchestrations.simulator.input": "Eingabeereignis", + "orchestrations.simulator.help": "Test a draft without creating alerts or executing webhooks.", + "orchestrations.simulator.source": "Input format", + "orchestrations.simulator.normalized": "Normalized event", + "orchestrations.simulator.payload": "Event JSON", + "orchestrations.simulator.compare_active": "Compare with active version", + "orchestrations.simulator.result": "Simulationsergebnis", + "orchestrations.simulator.result_help": "Full deterministic rule and action trace.", + "orchestrations.simulator.no_result": "Run a simulation to see the result.", + "orchestrations.versions.title": "Versionsverlauf", + "orchestrations.versions.status": "Status", + "orchestrations.versions.comment": "Comment", + "orchestrations.versions.hash": "Definition hash", + "orchestrations.versions.published": "Published", + "orchestrations.versions.selected_definition": "Selected definition", + "orchestrations.versions.active_diff": "Diff against active", + "orchestrations.versions.no_diff": "No differences", + "orchestrations.executions.title": "Ausführungsverlauf", + "orchestrations.executions.source": "Source", + "orchestrations.executions.disposition": "Disposition", + "orchestrations.executions.matches": "Matched rules", + "orchestrations.executions.duration": "Duration", + "orchestrations.executions.created": "Created", + "orchestrations.webhooks.title": "Webhook-Aktionen", + "orchestrations.webhooks.help": "Reusable encrypted outbound actions executed asynchronously.", + "orchestrations.webhooks.name": "Name", + "orchestrations.webhooks.method": "Method", + "orchestrations.webhooks.retry": "Retries", + "orchestrations.webhooks.status": "Status", + "orchestrations.webhooks.editor_title": "Webhook action", + "orchestrations.webhooks.secret_help": "Headers are encrypted and are never returned by the API.", + "orchestrations.webhooks.headers": "Secret headers JSON", + "orchestrations.webhooks.body": "Body template", + "orchestrations.webhooks.timeout": "Timeout, seconds", + "orchestrations.webhooks.delete_title": "Delete webhook action", + "orchestrations.webhooks.delete_message": "This action will no longer be available to orchestration rules.", + "orchestrations.settings.metadata": "Metadaten", + "orchestrations.settings.runtime": "Runtime", + "orchestrations.settings.runtime_help": "A published version is required before active or shadow mode can be enabled.", + "orchestrations.form.name": "Name", + "orchestrations.form.description": "Beschreibung", + "orchestrations.form.scope": "Bereich", + "orchestrations.form.service": "Service", + "orchestrations.form.compatibility": "Kompatibilitätsmodus", + "orchestrations.form.mode": "Runtime-Modus", + "orchestrations.create.title": "Ereignis-Orchestrierung erstellen", + "orchestrations.create.help": "Create a disabled orchestration with an initial draft.", + "orchestrations.rule_editor.title": "Regel-Editor", + "orchestrations.rule_editor.help": "Build conditions and actions without writing JSON.", + "orchestrations.rule_editor.processing_mode": "After match", + "orchestrations.rule_editor.enabled": "Aktiviert", + "orchestrations.rule_editor.disabled": "Deaktiviert", + "orchestrations.rule_editor.conditions": "Bedingungen", + "orchestrations.rule_editor.condition": "Bedingung", + "orchestrations.rule_editor.group": "Gruppe", + "orchestrations.rule_editor.actions": "Aktionen", + "orchestrations.rule_editor.action": "Aktion", + "orchestrations.validation.valid": "Der Entwurf ist gültig.", + "orchestrations.publish.title": "Publish draft", + "orchestrations.publish.message": "Publish this immutable version and make it active? Catch-all drop rules require explicit confirmation.", + "orchestrations.rollback.title": "Rollback version", + "orchestrations.rollback.message": "Publish a new immutable copy of this historical version?", + "orchestrations.delete.title": "Delete orchestration", + "orchestrations.delete.message": "Disable and archive this orchestration?", + "orchestrations.errors.invalid_json": "Ungültiges JSON", + "orchestrations.errors.rules_required": "Die Definition muss ein rules-Array enthalten", + "orchestrations.webhooks.private_network_policy": "Richtlinie für private Netzwerke", + "orchestrations.edit.title": "Ereignis-Orchestrierung bearbeiten", + "orchestrations.edit.help": "Metadaten, Geltungsbereich und Laufzeiteinstellungen ändern." +} diff --git a/app/static/i18n/de/profile.json b/app/static/i18n/de/profile.json index fbcd217..1bc2c8c 100644 --- a/app/static/i18n/de/profile.json +++ b/app/static/i18n/de/profile.json @@ -7,6 +7,15 @@ "profile.tabs.tokens": "API Token", "profile.personal.title": "Persönliche Informationen", "profile.personal.subtitle": "Von den Meldekanälen verwendete Kontaktangaben.", + "profile.interface.title": "Benutzeroberfläche", + "profile.interface.subtitle": "Sprache, Darstellung und lokale Zeiteinstellungen.", + "profile.fields.language": "Sprache", + "profile.fields.language_help": "Die Oberflächensprache ändert sich nach dem Speichern des Profils.", + "profile.fields.theme": "Design", + "profile.fields.theme_help": "Die Systemeinstellung folgt dem Betriebssystem oder Browser.", + "profile.theme.system": "Systemeinstellung", + "profile.theme.light": "Hell", + "profile.theme.dark": "Dunkel", "profile.fields.username": "Benutzername", "profile.fields.display_name": "Anzeigename", "profile.fields.email": "E-Mail", diff --git a/app/static/i18n/de/routes.json b/app/static/i18n/de/routes.json index d73925b..d47d1f4 100644 --- a/app/static/i18n/de/routes.json +++ b/app/static/i18n/de/routes.json @@ -193,9 +193,13 @@ "matcher.preset.disabled_hint": "Dieses Preset ist deaktiviert. Das Matching wird nicht erfolgreich sein, bis das Preset aktiviert ist.", "matcher.preset.match_both": "Preset \"{name}\" v{version} und die zusätzlichen Matcher müssen beide übereinstimmen.", "routes.form.datadog_help": "Erstellen Sie eine Datadog-Webhooks-Integration, die den empfohlenen benutzerdefinierten Payload an diese Route sendet. Fügen Sie das Route-Intake-Token als benutzerdefinierten Header Authorization: Bearer hinzu.", + "routes.form.uptime_kuma_help": "Uptime Kuma sendet den standardmäßigen Webhook-JSON-Payload an diese Route. Fügen Sie das Route-Intake-Token als Authorization: Bearer-Header hinzu. DOWN öffnet einen Alert, UP oder Wartung löst ihn auf.", "routes.intake.datadog_example_comment": "Beispiel für einen benutzerdefinierten Datadog-Webhooks-Payload", "routes.intake.datadog_subtitle": "Verwenden Sie diesen Endpunkt in der Datadog-Webhooks-Integration und fügen Sie das Route-Token als benutzerdefinierten Header Authorization: Bearer hinzu.", "routes.intake.datadog_help": "Konfigurieren Sie einen JSON-Payload mit ALERT_CYCLE_KEY und ALERT_TRANSITION, damit Recovery dasselbe IncidentRelay-Alert aktualisiert.", + "routes.intake.uptime_kuma_example_comment": "Beispiel für den standardmäßigen Uptime-Kuma-Webhook-Payload", + "routes.intake.uptime_kuma_subtitle": "Verwenden Sie diesen Endpoint und das Route-Token in einer Uptime-Kuma-Webhook-Benachrichtigung.", + "routes.intake.uptime_kuma_help": "Erstellen Sie in Uptime Kuma eine Webhook-Benachrichtigung mit POST, application/json, der Route-Intake-URL und einem zusätzlichen Authorization-Header. Behalten Sie den Standard-Request-Body bei.", "routes.form.webhook_pd_help": "Webhook-Routen akzeptieren generische IncidentRelay-Payloads sowie PagerDuty-Events-API-v2-kompatible Trigger-, Acknowledge- und Resolve-Ereignisse. Verwenden Sie das Intake-Token der Route als routing_key.", "routes.intake.generic_example_comment": "Generisches IncidentRelay-Webhook-Format", "routes.intake.pagerduty_example_comment": "PagerDuty-Events-API-v2-kompatibles Format", diff --git a/app/static/i18n/de/services.json b/app/static/i18n/de/services.json index b6948b5..d5b0097 100644 --- a/app/static/i18n/de/services.json +++ b/app/static/i18n/de/services.json @@ -127,5 +127,9 @@ "services.dependencies.hard": "hart", "services.dependencies.soft": "Weich", "services.dependencies.external": "Außen", - "services.dependencies.informational": "Informativ" + "services.dependencies.informational": "Informativ", + "services.details.event_orchestrations": "Ereignis-Orchestrierungen", + "services.details.orchestration_disabled": "Deaktiviert", + "services.details.orchestration_active": "Aktiv", + "services.details.orchestration_shadow": "Schattenmodus" } diff --git a/app/static/i18n/de/silences.json b/app/static/i18n/de/silences.json index a01fae1..87a0d2e 100644 --- a/app/static/i18n/de/silences.json +++ b/app/static/i18n/de/silences.json @@ -78,5 +78,16 @@ "silences.permissions.disable_denied": "Sie haben keine Erlaubnis, dieses Stummschaltung zu deaktivieren.", "silences.confirm.disable_title": "Deaktivieren Sie dieses Stummschaltung?", "silences.confirm.disable_message": "Deaktivierte Stillschweigen unterdrücken nicht mehr übereinstimmende Warnungen.", - "silences.confirm.disable": "Deaktivieren" + "silences.confirm.disable": "Deaktivieren", + "silences.details.apply_to_existing": "Bestehende Alarme", + "silences.details.apply_to_existing_enabled": "Auf passende ungelöste Alarme anwenden", + "silences.details.apply_to_existing_disabled": "Nur neue Alarme", + "silences.details.reactivate_on_end": "Nach Ende des Silence", + "silences.details.reactivate_on_end_enabled": "Betroffene Alarme reaktivieren", + "silences.details.reactivate_on_end_disabled": "Betroffene Alarme stummgeschaltet lassen", + "silences.form.apply_to_existing": "Auf bestehende ungelöste Alarme anwenden", + "silences.form.apply_to_existing_help": "Optional. Passende aktive Alarme werden sofort stummgeschaltet. Bereits gesendete Benachrichtigungen bleiben im Verlauf erhalten.", + "silences.form.reactivate_on_end": "Stummgeschaltete Alarme nach Ende dieses Silence reaktivieren", + "silences.form.reactivate_on_end_help": "Standardmäßig aktiviert. Alarme werden erst reaktiviert, wenn kein weiteres passendes Silence mehr gilt.", + "silences.form.reactivate_on_end_warning": "Betroffene Alarme bleiben nach Ende dieses Silence stummgeschaltet. Aktivieren Sie diese Option später und speichern Sie das Silence, um sie freizugeben." } diff --git a/app/static/i18n/en/audit_logs.json b/app/static/i18n/en/audit_logs.json new file mode 100644 index 0000000..50bbe49 --- /dev/null +++ b/app/static/i18n/en/audit_logs.json @@ -0,0 +1,64 @@ +{ + "nav.audit_log": "Audit log", + "pages.audit-log.title": "Audit log", + "pages.audit-log.subtitle": "Administrative activity and security history", + "audit.summary.entries": "Entries", + "audit.summary.entries_hint": "Records matching the current filters", + "audit.summary.actors": "Actors", + "audit.summary.actors_hint": "Users represented in the result", + "audit.summary.actions": "Actions", + "audit.summary.actions_hint": "Distinct action types in the result", + "audit.summary.groups": "Groups", + "audit.summary.groups_hint": "Groups visible to your account", + "audit.list.title": "Administrative activity", + "audit.list.subtitle": "Review changes made through the application and API.", + "audit.list.global_scope": "Global administrators can review all audit records, including global entries without a group.", + "audit.list.editor_scope": "Group editors can review only records belonging to groups where they have the editor role.", + "audit.actions.reload": "Reload", + "audit.actions.clear_filters": "Clear filters", + "audit.actions.view": "View", + "audit.actions.close": "Close", + "audit.filters.search": "Search", + "audit.filters.search_placeholder": "Action, object, message or actor", + "audit.filters.group": "Group", + "audit.filters.all_groups": "All accessible groups", + "audit.filters.actor": "Actor", + "audit.filters.all_actors": "All actors", + "audit.filters.action": "Action", + "audit.filters.all_actions": "All actions", + "audit.filters.object_type": "Object type", + "audit.filters.all_object_types": "All object types", + "audit.filters.date_from": "From", + "audit.filters.date_to": "To", + "audit.table.time": "Time", + "audit.table.actor": "Actor", + "audit.table.action": "Action", + "audit.table.object": "Object", + "audit.table.scope": "Scope", + "audit.table.message": "Message", + "audit.table.details": "Details", + "audit.row.system": "System", + "audit.row.api_token": "API token: {name}", + "audit.row.global_scope": "Global", + "audit.row.team": "Team: {name}", + "audit.row.not_available": "Not available", + "audit.empty": "No audit records match the current filters.", + "audit.pagination.rows": "Rows", + "audit.pagination.page": "Page", + "audit.pagination.page_value": "Page {page} / {total}", + "audit.pagination.range": "{from}–{to} / {total}", + "audit.pagination.previous": "Previous", + "audit.pagination.next": "Next", + "audit.details.title": "Audit entry", + "audit.details.entry": "Audit entry #{id}", + "audit.details.time": "Time", + "audit.details.actor": "Actor", + "audit.details.action": "Action", + "audit.details.object": "Object", + "audit.details.group": "Group", + "audit.details.team": "Team", + "audit.details.message": "Message", + "audit.details.payload": "Recorded data", + "audit.details.payload_hint": "Sensitive values are redacted before an audit entry is stored.", + "audit.errors.access_denied": "Global admin or group editor role is required to view the audit log." +} diff --git a/app/static/i18n/en/maintenance.json b/app/static/i18n/en/maintenance.json index 208bb6f..3dbff60 100644 --- a/app/static/i18n/en/maintenance.json +++ b/app/static/i18n/en/maintenance.json @@ -134,5 +134,13 @@ "maintenance.permissions.duplicate": "Team manager role is required to duplicate this maintenance window.", "maintenance.permissions.cancel": "Team manager role is required to cancel this maintenance window.", "maintenance.permissions.delete": "Delete permission is required to delete this maintenance window.", - "maintenance.badge.default": "Maintenance" + "maintenance.badge.default": "Maintenance", + "maintenance.form.apply_to_existing": "Apply to existing unresolved alerts", + "maintenance.form.apply_to_existing_help": "Optional. Matching unresolved alert groups that already existed when the window starts are affected immediately.", + "maintenance.form.apply_to_existing_unavailable": "Suppress incident prevents new alert-group creation and cannot be applied retroactively.", + "maintenance.form.reactivate_on_end": "Reactivate affected alerts when this maintenance ends", + "maintenance.form.reactivate_on_end_help": "Enabled by default. Effects are released only after the final applicable maintenance window no longer affects the alert.", + "maintenance.form.reactivate_on_end_warning": "Affected alerts will keep the maintenance effect after this window ends. Enable this option later and save the window to release them.", + "maintenance.details.apply_to_existing": "Existing alerts", + "maintenance.details.reactivate_on_end": "Reactivate after end" } diff --git a/app/static/i18n/en/orchestrations.json b/app/static/i18n/en/orchestrations.json new file mode 100644 index 0000000..71ea56c --- /dev/null +++ b/app/static/i18n/en/orchestrations.json @@ -0,0 +1,137 @@ +{ + "nav.event_orchestration": "Event Orchestration", + "pages.orchestrations.title": "Event Orchestration", + "pages.orchestrations.subtitle": "Route, enrich, suppress and automate incoming events", + "orchestrations.summary.total": "Orchestrations", + "orchestrations.summary.total_hint": "Definitions in the selected group", + "orchestrations.summary.active": "Active", + "orchestrations.summary.active_hint": "Applied to production events", + "orchestrations.summary.shadow": "Shadow", + "orchestrations.summary.shadow_hint": "Evaluated without changing behavior", + "orchestrations.summary.drafts": "Drafts", + "orchestrations.summary.drafts_hint": "Unpublished working versions", + "orchestrations.list.title": "Event orchestrations", + "orchestrations.list.items": "orchestrations", + "orchestrations.actions.create": "New orchestration", + "orchestrations.actions.reload": "Reload", + "orchestrations.actions.open": "Open", + "orchestrations.actions.back": "Back", + "orchestrations.actions.save_draft": "Save draft", + "orchestrations.actions.validate": "Validate", + "orchestrations.actions.publish": "Publish", + "orchestrations.actions.add_rule": "Add rule", + "orchestrations.actions.json_view": "JSON view", + "orchestrations.actions.builder_view": "Builder", + "orchestrations.actions.apply_json": "Apply JSON", + "orchestrations.actions.format_json": "Format JSON", + "orchestrations.actions.run_simulation": "Run simulation", + "orchestrations.actions.view": "View", + "orchestrations.actions.rollback": "Rollback", + "orchestrations.actions.trace": "Trace", + "orchestrations.actions.add_webhook": "Add webhook", + "orchestrations.actions.save": "Save", + "orchestrations.actions.save_runtime": "Save runtime", + "orchestrations.actions.delete": "Delete", + "orchestrations.actions.edit": "Edit", + "orchestrations.actions.duplicate": "Duplicate", + "orchestrations.actions.save_rule": "Save rule", + "orchestrations.search.placeholder": "Search orchestrations", + "orchestrations.filters.all_modes": "All modes", + "orchestrations.filters.all_scopes": "All scopes", + "orchestrations.mode.active": "Active", + "orchestrations.mode.shadow": "Shadow", + "orchestrations.mode.disabled": "Disabled", + "orchestrations.scope.global": "Global", + "orchestrations.scope.service": "Service", + "orchestrations.table.name": "Name", + "orchestrations.table.scope": "Scope", + "orchestrations.table.mode": "Mode", + "orchestrations.table.compatibility": "Compatibility", + "orchestrations.table.version": "Version", + "orchestrations.table.updated": "Updated", + "orchestrations.table.actions": "Actions", + "orchestrations.empty.loading": "Loading orchestrations...", + "orchestrations.empty.none": "No orchestrations found", + "orchestrations.tabs.rules": "Rules", + "orchestrations.tabs.simulator": "Simulator", + "orchestrations.tabs.versions": "Versions", + "orchestrations.tabs.executions": "Executions", + "orchestrations.tabs.webhooks": "Webhook Actions", + "orchestrations.tabs.settings": "Settings", + "orchestrations.rules.title": "Ordered rules", + "orchestrations.rules.help": "Rules run from top to bottom. Use nested groups for AND, OR and NOT logic.", + "orchestrations.rules.definition_json": "Definition JSON", + "orchestrations.rules.empty": "No rules in this draft yet.", + "orchestrations.rules.catch_all": "Matches every event", + "orchestrations.rules.no_actions": "No actions", + "orchestrations.rules.no_draft": "No draft", + "orchestrations.simulator.input": "Input event", + "orchestrations.simulator.help": "Test a draft without creating alerts or executing webhooks.", + "orchestrations.simulator.source": "Input format", + "orchestrations.simulator.normalized": "Normalized event", + "orchestrations.simulator.payload": "Event JSON", + "orchestrations.simulator.compare_active": "Compare with active version", + "orchestrations.simulator.result": "Simulation result", + "orchestrations.simulator.result_help": "Full deterministic rule and action trace.", + "orchestrations.simulator.no_result": "Run a simulation to see the result.", + "orchestrations.versions.title": "Version history", + "orchestrations.versions.status": "Status", + "orchestrations.versions.comment": "Comment", + "orchestrations.versions.hash": "Definition hash", + "orchestrations.versions.published": "Published", + "orchestrations.versions.selected_definition": "Selected definition", + "orchestrations.versions.active_diff": "Diff against active", + "orchestrations.versions.no_diff": "No differences", + "orchestrations.executions.title": "Execution history", + "orchestrations.executions.source": "Source", + "orchestrations.executions.disposition": "Disposition", + "orchestrations.executions.matches": "Matched rules", + "orchestrations.executions.duration": "Duration", + "orchestrations.executions.created": "Created", + "orchestrations.webhooks.title": "Webhook actions", + "orchestrations.webhooks.help": "Reusable encrypted outbound actions executed asynchronously.", + "orchestrations.webhooks.name": "Name", + "orchestrations.webhooks.method": "Method", + "orchestrations.webhooks.retry": "Retries", + "orchestrations.webhooks.status": "Status", + "orchestrations.webhooks.editor_title": "Webhook action", + "orchestrations.webhooks.secret_help": "Headers are encrypted and are never returned by the API.", + "orchestrations.webhooks.headers": "Secret headers JSON", + "orchestrations.webhooks.body": "Body template", + "orchestrations.webhooks.timeout": "Timeout, seconds", + "orchestrations.webhooks.delete_title": "Delete webhook action", + "orchestrations.webhooks.delete_message": "This action will no longer be available to orchestration rules.", + "orchestrations.settings.metadata": "Metadata", + "orchestrations.settings.runtime": "Runtime", + "orchestrations.settings.runtime_help": "A published version is required before active or shadow mode can be enabled.", + "orchestrations.form.name": "Name", + "orchestrations.form.description": "Description", + "orchestrations.form.scope": "Scope", + "orchestrations.form.service": "Service", + "orchestrations.form.compatibility": "Compatibility mode", + "orchestrations.form.mode": "Runtime mode", + "orchestrations.create.title": "Create event orchestration", + "orchestrations.create.help": "Create a disabled orchestration with an initial draft.", + "orchestrations.rule_editor.title": "Rule editor", + "orchestrations.rule_editor.help": "Build conditions and actions without writing JSON.", + "orchestrations.rule_editor.processing_mode": "After match", + "orchestrations.rule_editor.enabled": "Enabled", + "orchestrations.rule_editor.disabled": "Disabled", + "orchestrations.rule_editor.conditions": "Conditions", + "orchestrations.rule_editor.condition": "Condition", + "orchestrations.rule_editor.group": "Group", + "orchestrations.rule_editor.actions": "Actions", + "orchestrations.rule_editor.action": "Action", + "orchestrations.validation.valid": "Draft is valid.", + "orchestrations.publish.title": "Publish draft", + "orchestrations.publish.message": "Publish this immutable version and make it active? Catch-all drop rules require explicit confirmation.", + "orchestrations.rollback.title": "Rollback version", + "orchestrations.rollback.message": "Publish a new immutable copy of this historical version?", + "orchestrations.delete.title": "Delete orchestration", + "orchestrations.delete.message": "Disable and archive this orchestration?", + "orchestrations.errors.invalid_json": "Invalid JSON", + "orchestrations.errors.rules_required": "Definition must contain a rules array", + "orchestrations.webhooks.private_network_policy": "Private network policy", + "orchestrations.edit.title": "Edit event orchestration", + "orchestrations.edit.help": "Update metadata, scope and runtime settings." +} diff --git a/app/static/i18n/en/profile.json b/app/static/i18n/en/profile.json index 25a72af..6a26296 100644 --- a/app/static/i18n/en/profile.json +++ b/app/static/i18n/en/profile.json @@ -7,6 +7,15 @@ "profile.tabs.tokens": "API tokens", "profile.personal.title": "Personal information", "profile.personal.subtitle": "Contact details used by notification channels.", + "profile.interface.title": "Interface", + "profile.interface.subtitle": "Language, appearance and local time settings.", + "profile.fields.language": "Language", + "profile.fields.language_help": "The interface language changes after saving the profile.", + "profile.fields.theme": "Theme", + "profile.fields.theme_help": "Use system default to follow your operating system or browser.", + "profile.theme.system": "System default", + "profile.theme.light": "Light", + "profile.theme.dark": "Dark", "profile.fields.username": "Username", "profile.fields.display_name": "Display name", "profile.fields.email": "Email", diff --git a/app/static/i18n/en/routes.json b/app/static/i18n/en/routes.json index e03ff9b..fb2b9ce 100644 --- a/app/static/i18n/en/routes.json +++ b/app/static/i18n/en/routes.json @@ -198,7 +198,11 @@ "routes.intake.webhook_subtitle": "Use this token as Authorization: Bearer for generic payloads or as routing_key for PagerDuty Events API v2-compatible payloads.", "routes.intake.webhook_help": "Generic payloads use Authorization: Bearer. PagerDuty Events API v2-compatible payloads put the same token in routing_key.", "routes.form.datadog_help": "Create a Datadog Webhooks integration that sends the recommended custom payload to this route. Add the route intake token as an Authorization: Bearer custom header.", + "routes.form.uptime_kuma_help": "Uptime Kuma sends its standard Webhook JSON payload to this route. Add the route intake token as an Authorization: Bearer header. DOWN opens an alert and UP or maintenance resolves it.", "routes.intake.datadog_example_comment": "Datadog Webhooks custom payload example", "routes.intake.datadog_subtitle": "Use this endpoint in the Datadog Webhooks integration and add the route token as an Authorization: Bearer custom header.", - "routes.intake.datadog_help": "Configure a JSON custom payload with ALERT_CYCLE_KEY and ALERT_TRANSITION so recovery updates the same IncidentRelay alert." + "routes.intake.datadog_help": "Configure a JSON custom payload with ALERT_CYCLE_KEY and ALERT_TRANSITION so recovery updates the same IncidentRelay alert.", + "routes.intake.uptime_kuma_example_comment": "Standard Uptime Kuma Webhook payload example", + "routes.intake.uptime_kuma_subtitle": "Use this endpoint and route token in an Uptime Kuma Webhook notification.", + "routes.intake.uptime_kuma_help": "In Uptime Kuma, create a Webhook notification with POST, application/json, the route intake URL, and an additional Authorization header. Keep the default request body." } diff --git a/app/static/i18n/en/services.json b/app/static/i18n/en/services.json index 6197d49..dbbeae3 100644 --- a/app/static/i18n/en/services.json +++ b/app/static/i18n/en/services.json @@ -127,5 +127,9 @@ "services.dependencies.hard": "Hard", "services.dependencies.soft": "Soft", "services.dependencies.external": "External", - "services.dependencies.informational": "Informational" + "services.dependencies.informational": "Informational", + "services.details.event_orchestrations": "Event orchestrations", + "services.details.orchestration_disabled": "Disabled", + "services.details.orchestration_active": "Active", + "services.details.orchestration_shadow": "Shadow" } diff --git a/app/static/i18n/en/silences.json b/app/static/i18n/en/silences.json index bd9ecde..42f46b0 100644 --- a/app/static/i18n/en/silences.json +++ b/app/static/i18n/en/silences.json @@ -78,5 +78,16 @@ "silences.permissions.disable_denied": "You do not have permission to disable this silence.", "silences.confirm.disable_title": "Disable this silence?", "silences.confirm.disable_message": "Disabled silences no longer suppress matching alerts.", - "silences.confirm.disable": "Disable" + "silences.confirm.disable": "Disable", + "silences.details.apply_to_existing": "Existing alerts", + "silences.details.apply_to_existing_enabled": "Apply to matching unresolved alerts", + "silences.details.apply_to_existing_disabled": "New alerts only", + "silences.details.reactivate_on_end": "After Silence ends", + "silences.details.reactivate_on_end_enabled": "Reactivate affected alerts", + "silences.details.reactivate_on_end_disabled": "Keep affected alerts silenced", + "silences.form.apply_to_existing": "Apply to existing unresolved alerts", + "silences.form.apply_to_existing_help": "Optional. Matching firing alerts will be silenced immediately. Already delivered notifications remain in history.", + "silences.form.reactivate_on_end": "Reactivate silenced alerts when this Silence ends", + "silences.form.reactivate_on_end_help": "Enabled by default. Alerts are reactivated only after the final matching Silence no longer applies.", + "silences.form.reactivate_on_end_warning": "Affected alerts will remain silenced after this Silence ends. Enable this option later and save the Silence to release them." } diff --git a/app/static/i18n/fr/alert_details.json b/app/static/i18n/fr/alert_details.json new file mode 100644 index 0000000..4f9c401 --- /dev/null +++ b/app/static/i18n/fr/alert_details.json @@ -0,0 +1,321 @@ +{ + "alert_details.title": "Détails de l’alerte", + "alert_details.close": "Fermer", + "alert_details.tabs.aria": "Onglets des détails de l’alerte", + "alert_details.tabs.summary": "Résumé", + "alert_details.tabs.details": "Détails", + "alert_details.tabs.alerts": "Alertes", + "alert_details.tabs.events": "Événements", + "alert_details.tabs.explain": "Explication", + "alert_details.comments.title": "Commentaires", + "alert_details.actions.refresh": "Actualiser", + "alert_details.comments.placeholder": "Ajouter un commentaire...", + "alert_details.comments.add": "Ajouter le commentaire", + "alert_details.labels": "Libellés", + "alert_details.payload": "Charge utile", + "alert_details.routing.title": "Routage et escalade", + "alert_details.group_alerts": "Alertes de ce groupe", + "alert_details.notification_delivery": "Acheminement des notifications", + "alert_details.explain.title": "Explication du routage", + "alert_details.explain.help": "Correspondance de route, résolution du service, regroupement, affectation, maintenance et décisions de notification.", + "alert_details.actions.acknowledge": "Acquitter", + "alert_details.actions.resolve": "Résoudre", + "alert_details.actions.edit": "Modifier", + "alert_details.actions.delete": "Supprimer", + "alert_details.actions.save": "Enregistrer", + "alert_details.actions.cancel": "Annuler", + "alert_details.actions.open": "Ouvrir", + "alert_details.actions.remove": "Retirer", + "alert_details.actions.accept": "Accepter", + "alert_details.actions.decline": "Refuser", + "alert_details.actions.reset": "Réinitialiser", + "alert_details.actions.create_incident": "Créer un incident", + "alert_details.responder.add": "Ajouter un intervenant", + "alert_details.responder.add_help": "Demandez à un utilisateur, une équipe, une rotation ou une politique d’escalade d’aider à traiter cet incident.", + "alert_details.responder.title": "Intervenant", + "alert_details.responder.select_help": "Sélectionnez le type et l’ID de la cible.", + "alert_details.responder.target_type": "Type de cible", + "alert_details.target.user": "Utilisateur", + "alert_details.target.team": "Équipe", + "alert_details.target.rotation": "Rotation", + "alert_details.target.escalation_policy": "Politique d’escalade", + "alert_details.target.select_user": "Sélectionner un utilisateur", + "alert_details.target.select_team": "Sélectionner une équipe", + "alert_details.target.select_rotation": "Sélectionner une rotation", + "alert_details.target.select_policy": "Sélectionner une politique d’escalade", + "alert_details.target.user_help": "Sélectionnez un intervenant dans l’équipe actuelle de l’incident.", + "alert_details.target.team_help": "Sélectionnez une équipe visible.", + "alert_details.target.rotation_help": "Sélectionnez une rotation de l’équipe de l’incident.", + "alert_details.target.policy_help": "Sélectionnez une politique d’escalade de l’équipe de l’incident.", + "alert_details.form.message": "Message", + "alert_details.responder.default_message": "Merci de nous aider à traiter cet incident.", + "alert_details.responder.expires": "Expiration après (minutes)", + "alert_details.responder.expires_help": "Si l’expiration est vide, la demande d’intervention n’expire pas automatiquement.", + "alert_details.responder.request": "Demander une intervention", + "alert_details.stakeholder.add": "Ajouter une partie prenante", + "alert_details.stakeholder.add_help": "Ajoutez une personne qui doit recevoir les mises à jour de l’incident.", + "alert_details.stakeholder.title": "Partie prenante", + "alert_details.stakeholder.select_help": "Utilisez l’ID d’un utilisateur existant ou une adresse e-mail.", + "alert_details.form.email": "E-mail", + "alert_details.stakeholder.existing_user": "Utilisateur existant", + "alert_details.stakeholder.no_existing_user": "Aucun utilisateur existant", + "alert_details.stakeholder.user_help": "Sélectionnez un utilisateur existant ou saisissez une adresse e-mail externe ci-dessous.", + "alert_details.form.display_name": "Nom affiché", + "alert_details.form.role": "Rôle", + "alert_details.stakeholder.business_owner": "Responsable métier", + "alert_details.stakeholder.technical_owner": "Responsable technique", + "alert_details.stakeholder.manager": "Responsable", + "alert_details.stakeholder.notify_created": "Notifier à la création", + "alert_details.stakeholder.notify_priority": "Notifier lors d’un changement de priorité", + "alert_details.stakeholder.notify_status": "Notifier lors d’un changement de statut", + "alert_details.stakeholder.notify_resolved": "Notifier lors de la résolution", + "alert_details.trace.open_title": "Ouvrir une trace d’explication", + "alert_details.trace.open_help": "Collez le trace_id renvoyé par la réponse API d’une intégration.", + "alert_details.trace.id": "ID de trace", + "alert_details.manual.title": "Créer un incident", + "alert_details.manual.help": "Créez manuellement un incident pour une équipe ou un service.", + "alert_details.manual.incident": "Incident", + "alert_details.manual.incident_help": "Titre, description et gravité.", + "alert_details.form.title": "Titre", + "alert_details.form.title_placeholder": "L’API de stockage est indisponible", + "alert_details.form.description": "Description", + "alert_details.form.description_placeholder": "Impact, liens, premières observations...", + "alert_details.form.severity": "Gravité", + "alert_details.form.priority": "Priorité", + "alert_details.priority.automatic": "Automatique", + "alert_details.priority.auto_help": "Conservez le mode automatique pour laisser les règles de priorité et les valeurs par défaut décider.", + "alert_details.manual.scope": "Périmètre", + "alert_details.manual.scope_help": "Équipe, service facultatif et comportement des notifications.", + "alert_details.form.service": "Service", + "alert_details.manual.service_help": "Lorsqu’un service est sélectionné, l’incident utilise ses valeurs de propriété et d’escalade par défaut.", + "alert_details.manual.notify_team": "Notifier l’équipe", + "alert_details.manual.notify_help": "Lorsque cette option est activée, IncidentRelay planifie la notification via le flux habituel de notification des alertes.", + "alert_details.loading": "Chargement...", + "alert_details.loading.comments": "Chargement des commentaires...", + "alert_details.loading.priority": "Chargement de la priorité...", + "alert_details.loading.responders": "Chargement des intervenants...", + "alert_details.loading.stakeholders": "Chargement des parties prenantes...", + "alert_details.loading.users": "Chargement des utilisateurs...", + "alert_details.loading.links": "Chargement des liens...", + "alert_details.loading.runbooks": "Chargement des procédures...", + "alert_details.comments.empty": "Aucun commentaire pour le moment.", + "alert_details.comments.unknown_user": "Utilisateur inconnu", + "alert_details.comments.edited": "modifié", + "alert_details.comments.group_missing": "L’ID du groupe d’alertes est manquant.", + "alert_details.comments.id_missing": "L’ID du commentaire ou du groupe d’alertes est manquant.", + "alert_details.comments.empty_error": "Le commentaire ne peut pas être vide.", + "alert_details.validation_error": "Erreur de validation", + "alert_details.comments.delete_title": "Supprimer le commentaire", + "alert_details.comments.delete_message": "Supprimer ce commentaire ?", + "alert_details.incident_management.title": "Gestion de l’incident", + "alert_details.incident_management.help": "Priorité, intervenants et parties prenantes.", + "alert_details.responders.title": "Intervenants", + "alert_details.responders.help": "Personnes ou équipes sollicitées pour aider.", + "alert_details.stakeholders.title": "Parties prenantes", + "alert_details.stakeholders.help": "Personnes recevant les mises à jour de l’incident.", + "alert_details.priority.manual_override": "Remplacement manuel", + "alert_details.priority.reset_auto": "Revenir au mode automatique", + "alert_details.priority.incident_aria": "Priorité de l’incident", + "alert_details.responders.empty": "Aucun intervenant sollicité pour le moment.", + "alert_details.stakeholders.empty": "Aucune partie prenante ajoutée pour le moment.", + "alert_details.responder.requested": "Demandée", + "alert_details.responder.accepted": "Acceptée", + "alert_details.responder.declined": "Refusée", + "alert_details.responder.expired": "Expirée", + "alert_details.responder.resolved": "Résolue", + "alert_details.responder.decline_reason": "Motif du refus : ", + "alert_details.responder.resolution_note": "Note de résolution : ", + "alert_details.responder.response": "Réponse : ", + "alert_details.responder.notification_failed": "Échec de la notification.", + "alert_details.responder.notification_failed_prefix": "Échec de la notification : ", + "alert_details.priority.reset_title": "Réinitialiser la priorité de l’incident", + "alert_details.priority.reset_message": "Rétablir la gestion automatique de la priorité pour cet incident ?", + "alert_details.stakeholder.remove_title": "Retirer la partie prenante", + "alert_details.stakeholder.remove_message": "Retirer cette partie prenante de l’incident ?", + "alert_details.responder.decline_title": "Refuser la demande d’intervention", + "alert_details.responder.decline_message": "Refuser cette demande d’intervention ?", + "alert_details.responder.resolve_title": "Résoudre la demande d’intervention", + "alert_details.responder.resolve_message": "Marquer cette demande d’intervention comme résolue ?", + "alert_details.target.generic": "Cible", + "alert_details.target.select_generic": "Sélectionner une cible", + "alert_details.target.loading": "Chargement de {target}...", + "alert_details.target.none": "Aucune option disponible", + "alert_details.target.invalid": "Type de cible non valide.", + "alert_details.target.required": "Sélectionnez une cible d’intervention.", + "alert_details.stakeholder.required": "Sélectionnez un utilisateur existant ou saisissez un e-mail.", + "alert_details.modal.missing_title": "Fenêtre modale manquante", + "alert_details.modal.missing": "La fenêtre modale n° {id} est introuvable sur cette page.", + "alert_details.entity.user_number": "Utilisateur n° {id}", + "alert_details.entity.team_number": "Équipe n° {id}", + "alert_details.entity.rotation_number": "Rotation n° {id}", + "alert_details.entity.policy_number": "Politique d’escalade n° {id}", + "alert_details.entity.service_number": "Service n° {id}", + "alert_details.entity.business_service_number": "Service métier n° {id}", + "alert_details.entity.alert_number": "Alerte n° {id}", + "alert_details.entity.rule_number": "Règle n° {id}", + "alert_details.entity.alert": "Alerte", + "alert_details.entity.other": "autre", + "alert_details.entity.runbook": "procédure", + "alert_details.correlation.symptom": "Symptôme", + "alert_details.correlation.symptom_help": "Cette alerte peut être provoquée par des alertes racines en amont", + "alert_details.correlation.root_cause": "Cause racine", + "alert_details.correlation.root_cause_help": "Cette alerte peut avoir un impact sur des alertes en aval", + "alert_details.correlation.correlated": "Corrélées : {count}", + "alert_details.correlation.best_score": "Meilleur score de corrélation : {score}", + "alert_details.business.unknown": "Inconnu", + "alert_details.business.operational": "Opérationnel", + "alert_details.business.degraded": "Dégradé", + "alert_details.business.partial_outage": "Panne partielle", + "alert_details.business.major_outage": "Panne majeure", + "alert_details.business.maintenance": "Maintenance", + "alert_details.business.impact": "Impact métier", + "alert_details.business.badge": "Métier : {name}", + "alert_details.business.impacted": "Affectés : {count}", + "alert_details.escalation.policy": "Politique", + "alert_details.escalation.rotation": "Rotation", + "alert_details.escalation.policy_title": "Politique d’escalade : {name}", + "alert_details.escalation.simple": "Escalade par rotation simple", + "alert_details.escalation.rule": "Règle n° {position} / {target}", + "alert_details.escalation.next": "Suivante : {time}", + "alert_details.escalation.level": "Niveau : {level}", + "alert_details.escalation.after_reminders": "Après {count} rappel(s)", + "alert_details.escalation.simple_rotation": "Rotation simple", + "alert_details.escalation.rotation_only": "Utilisée uniquement lorsque le mode d’escalade est Rotation", + "alert_details.status.disabled": "Désactivé", + "alert_details.explain.open_tab": "Ouvrez l’onglet Explication pour charger la trace de routage.", + "alert_details.explain.empty": "Aucune trace d’explication.", + "alert_details.explain.loading": "Chargement de la trace d’explication...", + "alert_details.explain.loading_many": "Chargement des traces d’explication...", + "alert_details.explain.none_selected": "Aucune trace d’explication sélectionnée.", + "alert_details.explain.no_incident": "Aucun incident sélectionné.", + "alert_details.explain.no_recorded": "Aucune trace d’explication enregistrée pour cet incident.", + "alert_details.explain.trace": "Trace", + "alert_details.explain.status": "Statut", + "alert_details.explain.outcome": "Résultat", + "alert_details.explain.source": "Source", + "alert_details.explain.dedup_key": "Clé de déduplication", + "alert_details.explain.started": "Démarrée", + "alert_details.explain.finished": "Terminée", + "alert_details.explain.reason": "Motif", + "alert_details.explain.steps_empty": "Aucune étape d’explication enregistrée.", + "alert_details.explain.step": "Étape", + "alert_details.explain.data": "Données", + "alert_details.explain.status.success": "succès", + "alert_details.explain.status.completed": "terminé", + "alert_details.explain.status.warning": "avertissement", + "alert_details.explain.status.error": "erreur", + "alert_details.explain.status.failed": "échec", + "alert_details.explain.status.scheduled": "planifié", + "alert_details.explain.status.stopped": "arrêté", + "alert_details.explain.status.skipped": "ignoré", + "alert_details.explain.status.info": "information", + "alert_details.console.modal_not_found": "Fenêtre modale des détails de l’alerte introuvable", + "alert_details.business.no_components": "Aucun composant affecté dans l’instantané.", + "alert_details.business.status_prefix": "statut : ", + "alert_details.business.score_prefix": "score : ", + "alert_details.business.score_separator": " / score ", + "alert_details.business.via_prefix": "via : ", + "alert_details.business.criticality_prefix": "criticité : ", + "alert_details.business.weight_prefix": "poids : ", + "alert_details.business.more_components": "+{count} autre(s) composant(s) affecté(s)", + "alert_details.business.title": "Impact métier", + "alert_details.business.help": "Services destinés aux clients affectés par cet incident.", + "alert_details.primary.no_message": "Aucun message d’alerte n’a été fourni par l’intégration.", + "alert_details.primary.open_source": "Ouvrir l’événement source", + "alert_details.primary.created": "Créée : {time}", + "alert_details.primary.last_seen": "Dernière observation : {time}", + "alert_details.detail.assignee": "Responsable", + "alert_details.detail.route": "Route", + "alert_details.detail.service": "Service", + "alert_details.detail.next_escalation": "Prochaine escalade", + "alert_details.detail.maintenance": "Maintenance", + "alert_details.primary.no_labels": "Aucun libellé.", + "alert_details.service_context.title": "Contexte du service", + "alert_details.service_context.links": "Liens", + "alert_details.service_context.runbooks": "Procédures", + "alert_details.service_context.no_links": "Aucun lien.", + "alert_details.service_context.no_runbooks": "Aucune procédure correspondante.", + "alert_details.service_context.severity_prefix": "gravité : ", + "alert_details.correlation.possible_symptom": "Symptôme possible", + "alert_details.correlation.possible_root": "Cause racine possible", + "alert_details.correlation.related": "Alerte associée", + "alert_details.correlation.downstream": "Impact possible en aval", + "alert_details.correlation.same_chain": "Même chaîne de dépendances", + "alert_details.correlation.score_prefix": "score ", + "alert_details.correlation.depth_prefix": "profondeur ", + "alert_details.correlation.title": "Corrélation", + "alert_details.correlation.help": "Groupes d’alertes associés en tenant compte des dépendances.", + "alert_details.detail.external_id": "ID externe", + "alert_details.detail.group_key": "Clé de groupe", + "alert_details.detail.service_status": "Statut du service", + "alert_details.detail.service_criticality": "Criticité du service", + "alert_details.detail.escalation_mode": "Mode d’escalade", + "alert_details.detail.policy_rule": "Règle de politique", + "alert_details.detail.last_escalated": "Dernière escalade", + "alert_details.detail.escalation_level": "Niveau d’escalade", + "alert_details.detail.policy_repeat": "Nombre de répétitions de la politique", + "alert_details.detail.default_rotation": "Rotation par défaut", + "alert_details.detail.acknowledged_by": "Acquittée par", + "alert_details.detail.created": "Créée", + "alert_details.detail.last_seen": "Dernière observation", + "alert_details.detail.last_notification": "Dernière notification", + "alert_details.detail.reminder_count": "Nombre de rappels", + "alert_details.detail.reminder_interval": "Intervalle des rappels", + "alert_details.children.created_prefix": "créée=", + "alert_details.children.last_seen_prefix": "dernière_observation=", + "alert_details.children.instance_prefix": "instance=", + "alert_details.children.dedup_prefix": "déduplication=", + "alert_details.children.empty": "Aucune alerte enfant dans ce groupe.", + "alert_details.events.correlation_detected": "Corrélation détectée", + "alert_details.events.correlation_deactivated": "Corrélation désactivée", + "alert_details.events.business_detected": "Impact métier détecté", + "alert_details.events.business_updated": "Impact métier mis à jour", + "alert_details.events.business_deactivated": "Impact métier désactivé", + "alert_details.events.merged": "Fusionné", + "alert_details.events.merge_target_updated": "Cible de fusion mise à jour", + "alert_details.events.acknowledged": "Acquittée", + "alert_details.events.resolved": "Résolue", + "alert_details.events.notification": "Notification", + "alert_details.events.reminder": "Rappel", + "alert_details.events.escalation": "Escalade", + "alert_details.events.empty": "Aucun événement.", + "alert_details.delivery.empty": "Aucun enregistrement d’acheminement.", + "alert_details.delivery.failed_prefix": "échec : ", + "alert_details.delivery.sent": "envoyé", + "alert_details.delivery.configured_channel": "configured_channel_id", + "alert_details.delivery.requested_channel": "requested_channel_id", + "alert_details.delivery.external_channel": "external_channel_id", + "alert_details.delivery.message_id": "message_id", + "alert_details.permissions.ack": "Vous n’avez pas l’autorisation d’acquitter cette alerte.", + "alert_details.permissions.resolve": "Vous n’avez pas l’autorisation de résoudre cette alerte.", + "alert_details.bulk.ack_required": "Sélectionnez au moins un groupe d’alertes déclenché à acquitter.", + "alert_details.bulk.resolve_required": "Sélectionnez au moins un groupe d’alertes non résolu à résoudre.", + "alert_details.bulk.ack_title": "Acquitter les groupes d’alertes sélectionnés ?", + "alert_details.bulk.resolve_title": "Résoudre les groupes d’alertes sélectionnés ?", + "alert_details.bulk.ack_confirm": "Acquitter les groupes", + "alert_details.bulk.resolve_confirm": "Résoudre les groupes", + "alert_details.bulk.message": "Cette action va {action} {count} groupe(s) d’alertes sélectionné(s).", + "alert_details.bulk.ack_action": "acquitter", + "alert_details.bulk.resolve_action": "résoudre", + "alert_details.merge.minimum": "Sélectionnez au moins deux groupes d’alertes à fusionner.", + "alert_details.merge.no_target": "Impossible de choisir la cible de fusion.", + "alert_details.merge.target_group": "Les groupes sélectionnés seront fusionnés dans le groupe n° {id}.", + "alert_details.merge.target": "Cible : {title}", + "alert_details.merge.children": "Les alertes enfants des autres groupes seront déplacées vers le groupe cible.", + "alert_details.merge.title": "Fusionner les groupes d’alertes sélectionnés ?", + "alert_details.merge.confirm": "Fusionner les groupes", + "alert_details.merge.reason": "Fusion effectuée depuis l’interface des alertes", + "alert_details.trace.open_failed": "Impossible d’ouvrir la trace d’explication.", + "alert_details.trace.required": "L’ID de trace est requis.", + "alert_details.trace.details_title": "Trace d’explication", + "alert_details.trace.no_group": "Aucun groupe d’alertes n’a été créé", + "alert_details.trace.not_found": "Trace d’explication introuvable.", + "alert_details.manual.failed": "Impossible de créer l’incident.", + "alert_details.manual.no_teams": "Aucune équipe disponible", + "alert_details.manual.select_team": "Sélectionner une équipe", + "alert_details.manual.no_service": "Aucun service", + "alert_details.manual.select_team_error": "Sélectionnez une équipe.", + "alert_details.manual.title_required": "Le titre de l’incident est requis.", + "alert_details.manual.permission": "Vous n’avez pas l’autorisation de créer des incidents pour cette équipe.", + "alert_details.priority.current": "Actuelle : {value}" +} diff --git a/app/static/i18n/fr/alerts.json b/app/static/i18n/fr/alerts.json new file mode 100644 index 0000000..4aa0074 --- /dev/null +++ b/app/static/i18n/fr/alerts.json @@ -0,0 +1,93 @@ +{ + "alerts.inbox.title": "Boîte de réception des alertes", + "alerts.counter.showing": "Affichage", + "alerts.counter.of": "sur", + "alerts.counter.alerts": "alertes", + "alerts.counter.total": "au total", + "alerts.actions.auto_refresh": "Actualisation automatique", + "alerts.actions.reload": "Recharger", + "alerts.actions.create_incident": "Créer un incident", + "alerts.actions.explain_trace": "Expliquer la trace", + "alerts.search.placeholder": "Rechercher par ID, nom d’alerte, équipe, responsable, source...", + "alerts.filters.all_services": "Tous les services", + "alerts.filters.all_statuses": "Tous les statuts", + "alerts.filters.all_severities": "Tous les niveaux de gravité", + "alerts.filters.priority": "Priorité", + "alerts.filters.assigned_to_me": "Qui me sont attribuées", + "alerts.filters.search": "Rechercher", + "alerts.filters.status": "Statut", + "alerts.filters.severity": "Gravité", + "alerts.filters.service": "Service", + "alerts.filters.assignee": "Responsable", + "alerts.filters.me": "Moi", + "alerts.filters.team": "Équipe", + "alerts.status.firing": "Déclenchée", + "alerts.status.acknowledged": "Acquittée", + "alerts.status.silenced": "En silence", + "alerts.status.resolved": "Résolue", + "alerts.severity.critical": "Critique", + "alerts.severity.high": "Élevée", + "alerts.severity.warning": "Avertissement", + "alerts.severity.medium": "Moyenne", + "alerts.severity.low": "Faible", + "alerts.severity.info": "Information", + "alerts.priority.p1": "P1 Critique", + "alerts.priority.p2": "P2 Élevée", + "alerts.priority.p3": "P3 Moyenne", + "alerts.priority.p4": "P4 Faible", + "alerts.priority.p5": "P5 Information", + "alerts.sort.newest_activity": "Activité la plus récente", + "alerts.sort.newest_created": "Création la plus récente", + "alerts.sort.oldest_created": "Création la plus ancienne", + "alerts.sort.severity_first": "Gravité en premier", + "alerts.sort.priority_first": "Priorité en premier", + "alerts.sort.status": "Statut", + "alerts.select_all.page": "Sélectionner tous les groupes d’alertes de cette page", + "alerts.select_all.none": "Aucun groupe d’alertes sélectionnable sur cette page", + "alerts.table.id": "ID", + "alerts.table.status": "Statut", + "alerts.table.alert": "Alerte", + "alerts.table.severity": "Gravité", + "alerts.table.priority": "Priorité", + "alerts.table.team": "Équipe", + "alerts.table.assignee": "Responsable", + "alerts.table.created": "Créée", + "alerts.table.last_seen": "Dernière occurrence", + "alerts.table.escalations": "Escalades", + "alerts.table.rows_per_page": "Lignes par page", + "alerts.table.empty": "Aucune alerte trouvée", + "alerts.table.view_details": "Afficher les détails du groupe d’alertes", + "alerts.table.age": "Âge : {value}", + "alerts.table.no_route": "Aucune route", + "alerts.table.service": "Service : {name}", + "alerts.table.no_service": "Aucun service", + "alerts.table.group": "Groupe", + "alerts.table.routed_alert": "Alerte routée", + "alerts.table.service_number": "Service n° {id}", + "alerts.group_count.total": "{count} au total", + "alerts.group_count.firing": "{count} déclenchées", + "alerts.group_count.resolved": "{count} résolues", + "alerts.group_count.silenced": "{count} en silence", + "alerts.bulk.selected": "{count} sélectionnés", + "alerts.bulk.ack": "Acquitter la sélection", + "alerts.bulk.ack_count": "Acquitter la sélection ({count})", + "alerts.bulk.resolve": "Résoudre la sélection", + "alerts.bulk.resolve_count": "Résoudre la sélection ({count})", + "alerts.bulk.merge": "Fusionner la sélection", + "alerts.bulk.clear": "Effacer", + "alerts.summary.show_firing": "Afficher les alertes déclenchées", + "alerts.summary.firing": "Alertes déclenchées", + "alerts.summary.firing_hint": "Nécessitent une intervention", + "alerts.summary.show_acknowledged": "Afficher les alertes acquittées", + "alerts.summary.acknowledged": "Acquittées", + "alerts.summary.acknowledged_hint": "Une personne intervient", + "alerts.summary.show_resolved": "Afficher les alertes résolues", + "alerts.summary.resolved": "Résolues", + "alerts.summary.resolved_hint": "Incidents fermés", + "alerts.summary.show_reminders": "Afficher les alertes triées par rappels", + "alerts.summary.reminders": "Rappels", + "alerts.summary.reminders_hint": "Notifications répétées", + "alerts.summary.show_all": "Afficher toutes les alertes", + "alerts.summary.total": "Total des alertes", + "alerts.summary.total_hint": "Sélection actuelle" +} diff --git a/app/static/i18n/fr/audit_logs.json b/app/static/i18n/fr/audit_logs.json new file mode 100644 index 0000000..e529282 --- /dev/null +++ b/app/static/i18n/fr/audit_logs.json @@ -0,0 +1,64 @@ +{ + "nav.audit_log": "Journal d’audit", + "pages.audit-log.title": "Journal d’audit", + "pages.audit-log.subtitle": "Historique des actions administratives et des changements de sécurité", + "audit.summary.entries": "Entrées", + "audit.summary.entries_hint": "Entrées correspondant aux filtres actuels", + "audit.summary.actors": "Acteurs", + "audit.summary.actors_hint": "Utilisateurs présents dans les résultats", + "audit.summary.actions": "Actions", + "audit.summary.actions_hint": "Types d’action distincts dans les résultats", + "audit.summary.groups": "Groupes", + "audit.summary.groups_hint": "Groupes visibles par votre compte", + "audit.list.title": "Activité administrative", + "audit.list.subtitle": "Consultez les modifications effectuées depuis l’interface et l’API.", + "audit.list.global_scope": "Les administrateurs globaux peuvent consulter toutes les entrées, y compris les entrées globales sans groupe.", + "audit.list.editor_scope": "Les éditeurs de groupe voient uniquement les entrées des groupes dans lesquels ils possèdent le rôle editor.", + "audit.actions.reload": "Actualiser", + "audit.actions.clear_filters": "Effacer les filtres", + "audit.actions.view": "Afficher", + "audit.actions.close": "Fermer", + "audit.filters.search": "Rechercher", + "audit.filters.search_placeholder": "Action, objet, message ou acteur", + "audit.filters.group": "Groupe", + "audit.filters.all_groups": "Tous les groupes accessibles", + "audit.filters.actor": "Acteur", + "audit.filters.all_actors": "Tous les acteurs", + "audit.filters.action": "Action", + "audit.filters.all_actions": "Toutes les actions", + "audit.filters.object_type": "Type d’objet", + "audit.filters.all_object_types": "Tous les types d’objet", + "audit.filters.date_from": "Du", + "audit.filters.date_to": "Au", + "audit.table.time": "Date et heure", + "audit.table.actor": "Acteur", + "audit.table.action": "Action", + "audit.table.object": "Objet", + "audit.table.scope": "Périmètre", + "audit.table.message": "Message", + "audit.table.details": "Détails", + "audit.row.system": "Système", + "audit.row.api_token": "Jeton API : {name}", + "audit.row.global_scope": "Global", + "audit.row.team": "Équipe : {name}", + "audit.row.not_available": "Non disponible", + "audit.empty": "Aucune entrée d’audit ne correspond aux filtres actuels.", + "audit.pagination.rows": "Lignes", + "audit.pagination.page": "Page", + "audit.pagination.page_value": "Page {page} / {total}", + "audit.pagination.range": "{from}–{to} / {total}", + "audit.pagination.previous": "Précédente", + "audit.pagination.next": "Suivante", + "audit.details.title": "Entrée d’audit", + "audit.details.entry": "Entrée d’audit n° {id}", + "audit.details.time": "Date et heure", + "audit.details.actor": "Acteur", + "audit.details.action": "Action", + "audit.details.object": "Objet", + "audit.details.group": "Groupe", + "audit.details.team": "Équipe", + "audit.details.message": "Message", + "audit.details.payload": "Données enregistrées", + "audit.details.payload_hint": "Les valeurs sensibles sont masquées avant l’enregistrement de l’entrée d’audit.", + "audit.errors.access_denied": "Le rôle d’administrateur global ou d’éditeur de groupe est requis pour consulter le journal d’audit." +} diff --git a/app/static/i18n/fr/business_services.json b/app/static/i18n/fr/business_services.json new file mode 100644 index 0000000..1f1d0ce --- /dev/null +++ b/app/static/i18n/fr/business_services.json @@ -0,0 +1,155 @@ +{ + "business_services.summary.total": "Total", + "business_services.summary.total_hint": "Services métier dans le périmètre", + "business_services.summary.operational": "Opérationnels", + "business_services.summary.operational_hint": "Services destinés aux clients en bon état", + "business_services.summary.affected": "Affectés", + "business_services.summary.affected_hint": "Non opérationnels", + "business_services.summary.public": "Publics", + "business_services.summary.public_hint": "Visibles sur la page de statut", + "business_services.list.title": "Services métier", + "business_services.list.showing": "Affichage", + "business_services.list.of": "sur", + "business_services.list.services": "services métier", + "business_services.actions.new": "Nouveau service métier", + "business_services.actions.reload": "Recharger", + "business_services.actions.details": "Détails", + "business_services.actions.edit": "Modifier", + "business_services.actions.enable": "Activer", + "business_services.actions.disable": "Désactiver", + "business_services.actions.delete": "Supprimer", + "business_services.actions.close": "Fermer", + "business_services.actions.recalculate": "Recalculer", + "business_services.actions.add_component": "Ajouter un composant", + "business_services.actions.clear_override": "Effacer le remplacement", + "business_services.actions.set_manual": "Définir un statut manuel", + "business_services.actions.reset": "Réinitialiser", + "business_services.actions.save": "Enregistrer le service métier", + "business_services.actions.save_component": "Enregistrer le composant", + "business_services.search.placeholder": "Rechercher des services métier...", + "business_services.filters.all_statuses": "Tous les statuts", + "business_services.filters.all_visibility": "Toutes les visibilités", + "business_services.visibility.public": "Public", + "business_services.visibility.private": "Privé", + "business_services.status.unknown": "Inconnu", + "business_services.status.operational": "Opérationnel", + "business_services.status.degraded": "Dégradé", + "business_services.status.partial_outage": "Panne partielle", + "business_services.status.major_outage": "Panne majeure", + "business_services.status.maintenance": "Maintenance", + "business_services.table.name": "Nom", + "business_services.table.group": "Groupe", + "business_services.table.owner_team": "Équipe propriétaire", + "business_services.table.status": "Statut", + "business_services.table.criticality": "Criticité", + "business_services.table.tier": "Niveau", + "business_services.table.components": "Composants", + "business_services.table.visibility": "Visibilité", + "business_services.table.actions": "Actions", + "business_services.table.technical_service": "Service technique", + "business_services.table.team": "Équipe", + "business_services.table.weight": "Poids", + "business_services.table.description": "Description", + "business_services.table.time": "Heure", + "business_services.table.old_status": "Ancien statut", + "business_services.table.new_status": "Nouveau statut", + "business_services.table.score": "Score", + "business_services.table.message": "Message", + "business_services.empty.loaded": "Aucun service métier chargé", + "business_services.empty.services": "Aucun service métier", + "business_services.empty.selected": "Aucun service métier sélectionné.", + "business_services.empty.select_service": "Sélectionnez un service métier", + "business_services.empty.components": "Aucun composant", + "business_services.empty.history": "Aucun historique de statut", + "business_services.details.title": "Détails du service métier", + "business_services.details.select_help": "Sélectionnez un service métier pour afficher ses composants et son historique de statut.", + "business_services.details.fallback": "Service métier", + "business_services.details.impact_title": "Impact métier", + "business_services.details.impact_help": "Statut destiné aux clients, calculé à partir des services techniques.", + "business_services.details.status_message": "Message de statut", + "business_services.details.status_source": "Source du statut", + "business_services.details.manual_override": "Remplacement manuel", + "business_services.details.manual_message": "Message manuel", + "business_services.details.manual_until": "Manuel jusqu’au", + "business_services.details.calculated": "Calculé", + "business_services.details.manual": "Manuel", + "business_services.details.no_override": "Aucun remplacement actif", + "business_services.details.until_cleared": "Jusqu’à suppression manuelle", + "business_services.components.title": "Composants", + "business_services.components.help": "Services techniques utilisés pour calculer l’impact métier.", + "business_services.manual.title": "Remplacement manuel du statut", + "business_services.manual.help": "Remplacez le statut métier calculé pendant un incident ou une dégradation visible par les clients.", + "business_services.manual.set_status": "Définir le statut", + "business_services.manual.priority_help": "Le statut manuel prévaut sur le statut calculé jusqu’à son expiration ou sa suppression.", + "business_services.manual.message": "Message", + "business_services.manual.message_help": "Note facultative de l’opérateur expliquant pourquoi le statut métier est remplacé.", + "business_services.manual.until": "Jusqu’au", + "business_services.manual.until_help": "Heure d’expiration locale facultative. Laissez ce champ vide pour conserver le remplacement jusqu’à sa suppression manuelle.", + "business_services.history.title": "Historique des statuts", + "business_services.history.help": "Modifications récentes du statut métier calculé.", + "business_services.form.create": "Créer un service métier", + "business_services.form.edit": "Modifier le service métier", + "business_services.form.subtitle": "Fonctionnalité métier affichée dans les vues d’impact et sur les pages de statut.", + "business_services.form.identity_title": "Service métier", + "business_services.form.identity_help": "Identité, propriété et classification.", + "business_services.form.group": "Groupe", + "business_services.form.owner_team": "Équipe propriétaire", + "business_services.form.no_owner": "Aucune équipe propriétaire", + "business_services.form.name": "Nom", + "business_services.form.slug": "Slug", + "business_services.form.description": "Description", + "business_services.form.criticality": "Criticité", + "business_services.form.tier": "Niveau", + "business_services.form.public_order": "Ordre public", + "business_services.form.status_page_title": "Page de statut", + "business_services.form.status_page_help": "Libellés et visibilité destinés aux clients.", + "business_services.form.public_name": "Nom public", + "business_services.form.public_description": "Description publique", + "business_services.form.show_public": "Afficher sur la page de statut", + "business_services.form.enabled": "Activé", + "business_services.component.add": "Ajouter un composant", + "business_services.component.edit": "Modifier le composant", + "business_services.component.subtitle": "Associez un service technique au service métier sélectionné.", + "business_services.component.select_service": "Sélectionnez un service technique", + "business_services.component.technical_service": "Service technique", + "business_services.component.service_help": "Service technique participant au calcul du statut de ce service métier.", + "business_services.component.criticality": "Criticité", + "business_services.component.criticality_help": "Les composants requis et critiques ont l’impact le plus fort sur le statut métier. Les composants facultatifs et informatifs ont un impact moindre.", + "business_services.component.weight": "Poids de l’impact", + "business_services.component.weight_help": "Poids compris entre 0 et 100. Plus il est élevé, plus ce composant contribue au score d’impact métier.", + "business_services.component.position": "Position", + "business_services.component.position_help": "Ordre de tri dans le service métier. Les valeurs les plus faibles sont affichées en premier.", + "business_services.component.description": "Description", + "business_services.criticality.required": "Requis", + "business_services.criticality.critical": "Critique", + "business_services.criticality.important": "Important", + "business_services.criticality.optional": "Facultatif", + "business_services.criticality.informational": "Informatif", + "business_services.tier.tier_1": "Niveau 1", + "business_services.tier.tier_2": "Niveau 2", + "business_services.tier.tier_3": "Niveau 3", + "business_services.tier.tier_4": "Niveau 4", + "business_services.validation.group": "Le groupe est requis.", + "business_services.validation.name": "Le nom est requis.", + "business_services.validation.slug": "Le slug est requis.", + "business_services.validation.select_first": "Sélectionnez d’abord un service métier.", + "business_services.validation.technical_service": "Le service technique est requis.", + "business_services.validation.not_selected": "Aucun service métier n’est sélectionné.", + "business_services.validation.expiration_invalid": "L’heure d’expiration du statut manuel n’est pas valide.", + "business_services.validation.expiration_future": "L’expiration du statut manuel doit être définie dans le futur.", + "business_services.errors.details_not_found": "Les détails du service métier sont introuvables.", + "business_services.errors.details_load": "Impossible de charger les détails du service métier.", + "business_services.errors.manual_set": "Impossible de définir le statut manuel.", + "business_services.errors.manual_clear": "Impossible d’effacer le statut manuel.", + "business_services.confirm.delete_title": "Supprimer ce service métier ?", + "business_services.confirm.delete_message": "Supprimer le service métier « {name} » ?", + "business_services.confirm.delete_confirm": "Supprimer le service métier", + "business_services.confirm.component_title": "Supprimer ce composant ?", + "business_services.confirm.component_message": "Retirer le service technique « {name} » de ce service métier ?", + "business_services.confirm.component_confirm": "Supprimer le composant", + "business_services.status_message.no_components": "Statut calculé : aucun composant activé", + "business_services.status_message.affected": "Composants affectés : {components}. Statut calculé : {status}, score d’impact = {score}", + "business_services.status_message.calculated": "Statut calculé : {status}, score d’impact = {score}", + "business_services.fallback.group": "Groupe {id}", + "business_services.fallback.team": "équipe" +} diff --git a/app/static/i18n/fr/calendar.json b/app/static/i18n/fr/calendar.json new file mode 100644 index 0000000..5642e76 --- /dev/null +++ b/app/static/i18n/fr/calendar.json @@ -0,0 +1,77 @@ +{ + "calendar.summary.visible_teams": "Équipes visibles", + "calendar.summary.current_scope": "Portée actuelle du calendrier", + "calendar.summary.people_on_call": "Personnes d’astreinte", + "calendar.summary.unique_users": "Utilisateurs uniques sur la période", + "calendar.summary.assignments": "Affectations", + "calendar.summary.scheduled_shifts": "Astreintes planifiées", + "calendar.summary.overrides": "Remplacements", + "calendar.summary.manual_changes": "Modifications manuelles", + "calendar.title": "Calendrier d’astreinte", + "calendar.current_week": "Semaine en cours", + "calendar.actions.today": "Aujourd’hui", + "calendar.actions.previous": "Précédent", + "calendar.actions.next": "Suivant", + "calendar.actions.export": "Exporter", + "calendar.actions.reload": "Recharger", + "calendar.search.placeholder": "Rechercher un utilisateur, une équipe ou une rotation...", + "calendar.details.title": "Détails de l’astreinte", + "calendar.details.select_assignment": "Sélectionner une affectation", + "calendar.details.empty": "Cliquez sur une astreinte du calendrier pour afficher l’utilisateur, l’équipe, la rotation et la plage horaire.", + "calendar.export.title": "Exporter le calendrier", + "calendar.export.description": "Créez une URL d’abonnement ICS compatible avec Outlook pour le calendrier d’astreinte de cette équipe.", + "calendar.export.url": "URL d’abonnement ICS", + "calendar.export.help": "Utilisez cette URL dans Outlook : Calendrier → Ajouter un calendrier → S’abonner à partir du web. Toute personne disposant de cette URL peut consulter le planning d’astreinte de cette équipe.", + "calendar.export.create_url": "Créer l’URL", + "calendar.export.copy_url": "Copier l’URL", + "calendar.export.regenerate": "Régénérer", + "calendar.export.feed_name": "Calendrier d’astreinte", + "calendar.export.select_team": "Sélectionnez une équipe avant d’exporter le calendrier.", + "calendar.export.not_created": "Aucune URL d’abonnement n’existe encore. Cliquez sur Créer l’URL.", + "calendar.export.hidden_token": "Cette URL a été créée précédemment et ne peut plus être affichée. Cliquez sur Régénérer pour créer une nouvelle URL.", + "calendar.export.created": "URL d’abonnement créée. Copiez-la maintenant.", + "calendar.export.regenerated": "URL d’abonnement régénérée. L’ancienne URL ne fonctionne plus.", + "calendar.export.no_url": "Aucune URL à copier.", + "calendar.export.copied": "URL d’abonnement copiée.", + "calendar.empty.no_teams": "Aucune équipe disponible.", + "calendar.empty.no_calendars": "Aucun calendrier trouvé.", + "calendar.labels.calendar": "Calendrier", + "calendar.labels.team": "Équipe", + "calendar.labels.rotation": "Rotation", + "calendar.labels.layer": "Couche", + "calendar.labels.layer_priority": "Priorité de la couche", + "calendar.labels.timezone": "Fuseau horaire", + "calendar.labels.source_timezone": "Fuseau horaire de la source", + "calendar.labels.type": "Type", + "calendar.labels.start": "Début", + "calendar.labels.end": "Fin", + "calendar.labels.reason": "Motif", + "calendar.labels.selected_shift": "Astreinte sélectionnée", + "calendar.labels.on_call_user": "Utilisateur d’astreinte", + "calendar.labels.override": "Remplacement", + "calendar.labels.override_lower": "remplacement", + "calendar.labels.scheduled_layer": "Couche planifiée", + "calendar.labels.final_schedule": "Planning final", + "calendar.labels.final": "final", + "calendar.actions.create_override": "Créer un remplacement", + "calendar.errors.no_rotation": "Cet élément du calendrier n’est associé à aucune rotation.", + "calendar.fallback.rotation": "rotation n° {id}", + "calendar.fallback.team": "équipe n° {id}", + "calendar.fallback.user": "utilisateur-{id}", + "calendar.weekdays.sun": "Dim", + "calendar.weekdays.mon": "Lun", + "calendar.weekdays.tue": "Mar", + "calendar.weekdays.wed": "Mer", + "calendar.weekdays.thu": "Jeu", + "calendar.weekdays.fri": "Ven", + "calendar.weekdays.sat": "Sam", + "calendar.filters.rotation": "Rotation", + "calendar.filters.start_date": "Date de début", + "calendar.filters.end_date": "Date de fin", + "calendar.empty.no_duty": "Aucune affectation d’astreinte", + "calendar.assignment.tooltip": "{user} ; rotation : {rotation} ; couche : {layer} ; {start}–{end}", + "calendar.export.regenerate_confirm_title": "Régénérer l’URL du calendrier ?", + "calendar.export.regenerate_confirm_message": "L’URL d’abonnement ICS actuelle cessera immédiatement de fonctionner. Les clients de calendrier qui l’utilisent devront être mis à jour avec la nouvelle URL.", + "calendar.export.regenerate_confirm": "Régénérer l’URL", + "calendar.export.copy_failed": "Impossible de copier l’URL. Sélectionnez-la et copiez-la manuellement." +} diff --git a/app/static/i18n/fr/channels.json b/app/static/i18n/fr/channels.json new file mode 100644 index 0000000..4d61bab --- /dev/null +++ b/app/static/i18n/fr/channels.json @@ -0,0 +1,156 @@ +{ + "channels.summary.channels": "Canaux", + "channels.summary.channels_hint": "Cibles de notification", + "channels.summary.enabled": "Activés", + "channels.summary.enabled_hint": "Prêts à envoyer", + "channels.summary.disabled": "Désactivés", + "channels.summary.disabled_hint": "Cibles mises en sourdine", + "channels.summary.webhooks": "Canaux webhook", + "channels.summary.webhooks_hint": "Cibles de livraison externes", + "channels.list.title": "Canaux de notification", + "channels.list.showing": "Affichage", + "channels.list.of": "sur", + "channels.list.channels": "canaux", + "channels.actions.new": "Nouveau canal", + "channels.actions.reload": "Recharger", + "channels.actions.edit": "Modifier", + "channels.actions.test": "Tester", + "channels.actions.enable": "Activer", + "channels.actions.disable": "Désactiver", + "channels.actions.delete": "Supprimer", + "channels.actions.reset": "Réinitialiser", + "channels.actions.save": "Enregistrer le canal", + "channels.actions.format": "Formater", + "channels.actions.edit_channel": "Modifier le canal", + "channels.actions.test_channel": "Tester le canal", + "channels.actions.enable_channel": "Activer le canal", + "channels.actions.disable_channel": "Désactiver le canal", + "channels.actions.delete_channel": "Supprimer le canal", + "channels.actions.close": "Fermer", + "channels.search.placeholder": "Rechercher des canaux...", + "channels.filters.all_types": "Tous les types", + "channels.filters.all_statuses": "Tous les statuts", + "channels.table.channel": "Canal", + "channels.table.group": "Groupe", + "channels.table.team": "Équipe", + "channels.table.type": "Type", + "channels.table.mode": "Mode", + "channels.table.status": "Statut", + "channels.table.actions": "Actions", + "channels.empty.loaded": "Aucun canal chargé", + "channels.empty.channels": "Aucun canal", + "channels.empty.groups": "Aucun groupe disponible", + "channels.empty.teams": "Aucune équipe disponible", + "channels.empty.teams_group": "Aucune équipe dans ce groupe", + "channels.details.title": "Détails du canal", + "channels.details.select": "Sélectionner un canal", + "channels.details.select_help": "Cliquez sur le nom d’un canal pour examiner son type de livraison, son équipe et un résumé sécurisé de sa configuration.", + "channels.details.name": "Nom", + "channels.details.group": "Groupe", + "channels.details.team": "Équipe", + "channels.details.type": "Type", + "channels.details.mode": "Mode", + "channels.details.severity": "Filtre de gravité", + "channels.details.status": "Statut", + "channels.details.config": "Configuration", + "channels.form.create": "Créer un canal", + "channels.form.edit": "Modifier le canal n° {id}", + "channels.form.subtitle": "Configurez les destinations auxquelles IncidentRelay envoie des notifications.", + "channels.form.channel": "Canal", + "channels.form.channel_help": "Portée, nom, type et état d’activation.", + "channels.form.group": "Groupe", + "channels.form.team": "Équipe", + "channels.form.name": "Nom", + "channels.form.name_placeholder": "Telegram principal", + "channels.form.type": "Type", + "channels.form.notify_severities": "Notifier pour les niveaux de gravité", + "channels.form.enabled": "Activé", + "channels.form.enabled_help": "Les canaux activés peuvent être utilisés par les routes et la livraison des notifications.", + "channels.form.delivery": "Configuration de livraison", + "channels.form.delivery_help": "Les champs visibles sont fusionnés dans la configuration JSON avancée.", + "channels.form.advanced": "Configuration JSON avancée", + "channels.form.advanced_help": "Configuration JSON brute facultative. Les valeurs des champs visibles sont fusionnées dans cette configuration lors de l’enregistrement.", + "channels.severity.critical": "Critique", + "channels.severity.high": "Élevée", + "channels.severity.medium": "Moyenne", + "channels.severity.warning": "Avertissement", + "channels.severity.low": "Faible", + "channels.severity.info": "Information", + "channels.severity.all": "Tous les niveaux de gravité", + "channels.telegram.bot_token": "Jeton du bot Telegram", + "channels.telegram.chat_id": "ID de la conversation Telegram", + "channels.mattermost.mode": "Mode Mattermost", + "channels.mattermost.bot_api_option": "API du bot avec boutons et mise à jour des messages", + "channels.mattermost.webhook_option": "Webhook entrant uniquement", + "channels.mattermost.url": "URL Mattermost", + "channels.mattermost.bot_token": "Jeton du bot", + "channels.mattermost.channel_id": "ID du canal", + "channels.mattermost.callback_secret": "Secret de rappel", + "channels.mattermost.webhook_url": "URL du webhook entrant Mattermost", + "channels.mattermost.help": "Le mode API du bot prend en charge les boutons Acquitter et Résoudre. Le mode webhook entrant envoie uniquement des messages simples.", + "channels.slack.connection": "Connexion Slack", + "channels.slack.connection_help": "Utilisez un bot Slack pour mettre à jour les messages d’alerte après acquittement ou résolution, ou utilisez un simple webhook entrant.", + "channels.slack.mode": "Mode de connexion", + "channels.slack.bot_api": "API du bot", + "channels.slack.webhook": "Webhook entrant", + "channels.slack.mode_help": "L’API du bot permet d’envoyer et de mettre à jour des messages. Le webhook entrant permet uniquement l’envoi.", + "channels.slack.bot_token": "Jeton du bot", + "channels.slack.bot_token_help": "Jeton du bot Slack doté de l’autorisation chat:write.", + "channels.slack.channel_id": "ID du canal", + "channels.slack.channel_id_help": "Utilisez l’ID du canal Slack, et non son nom d’affichage.", + "channels.slack.webhook_url": "URL du webhook entrant", + "channels.webhook.url": "URL du webhook", + "channels.webhook.slack": "URL du webhook Slack", + "channels.webhook.discord": "URL du webhook Discord", + "channels.webhook.teams": "URL du webhook Microsoft Teams", + "channels.email.help": "Les notifications par e-mail sont envoyées à l’adresse du profil de l’utilisateur affecté.", + "channels.email.template": "Modèle HTML", + "channels.email.reset": "Rétablir le modèle par défaut", + "channels.email.placeholders": "Espaces réservés pris en charge : {alert_id}, {event_type}, {title}, {message}, {severity}, {status}, {team}, {assignee}, {source}, {alert_url}. Laissez le modèle par défaut inchangé pour utiliser le modèle intégré.", + "channels.status.enabled": "Activé", + "channels.status.disabled": "Désactivé", + "channels.row.id": "Canal n° {id}", + "channels.type.telegram": "Telegram", + "channels.type.mattermost": "Mattermost", + "channels.type.slack": "Slack", + "channels.type.webhook": "Webhook", + "channels.type.discord": "Discord", + "channels.type.teams": "Microsoft Teams", + "channels.type.email": "E-mail", + "channels.mode.bot_api": "API du bot", + "channels.mode.webhook": "Webhook", + "channels.mode.email": "E-mail", + "channels.config.email_custom": "Adresse e-mail du profil de l’utilisateur affecté ; modèle HTML personnalisé", + "channels.config.email_default": "Adresse e-mail du profil de l’utilisateur affecté ; modèle HTML par défaut", + "channels.config.bot_ready": "API du bot configurée", + "channels.config.bot_incomplete": "Configuration de l’API du bot incomplète", + "channels.config.webhook_ready": "Webhook configuré", + "channels.config.webhook_missing": "Webhook manquant", + "channels.config.chat_ready": "Conversation configurée", + "channels.config.chat_missing": "Conversation manquante", + "channels.validation.select_team": "Sélectionnez d’abord une équipe.", + "channels.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier ce canal.", + "channels.permissions.test": "Le rôle de responsable d’équipe est requis pour tester ce canal.", + "channels.permissions.toggle": "Le rôle de responsable d’équipe est requis pour activer ou désactiver ce canal.", + "channels.permissions.delete": "L’autorisation de suppression est requise pour supprimer ce canal.", + "channels.permissions.edit_denied": "Vous n’êtes pas autorisé à modifier ce canal.", + "channels.permissions.create_denied": "Le rôle de responsable d’équipe ou d’éditeur de groupe est requis pour créer des canaux dans cette équipe.", + "channels.permissions.disable_denied": "Vous n’êtes pas autorisé à désactiver ce canal.", + "channels.permissions.enable_denied": "Vous n’êtes pas autorisé à activer ce canal.", + "channels.permissions.delete_denied": "Vous n’êtes pas autorisé à supprimer ce canal.", + "channels.permissions.test_denied": "Vous n’êtes pas autorisé à tester ce canal.", + "channels.confirm.disable_title": "Désactiver ce canal ?", + "channels.confirm.disable_message": "Désactiver le canal « {name} » ?\n\nLe canal cessera de recevoir des notifications, mais restera visible et pourra être réactivé.", + "channels.confirm.delete_title": "Supprimer ce canal ?", + "channels.confirm.delete_message": "Supprimer le canal « {name} » ?\n\nCette opération retirera le canal des listes actives et le dissociera des routes.\nLes alertes historiques seront conservées.", + "channels.slack.action_connection": "Connexion des actions interactives", + "channels.slack.action_connection_help": "Le mode HTTP nécessite une URL de requête publique et un secret de signature. Le mode Socket utilise une WebSocket sortante et ne nécessite aucun point de terminaison public.", + "channels.slack.http_mode": "URL de requête HTTP", + "channels.slack.socket_mode": "Mode Socket", + "channels.slack.signing_secret": "Secret de signature", + "channels.slack.signing_secret_help": "Requis pour les requêtes signées vers /api/integrations/slack/actions. Les valeurs enregistrées sont masquées lors de la modification.", + "channels.slack.app_token": "Jeton de niveau application", + "channels.slack.app_token_help": "Jeton Slack de niveau application commençant par xapp- et disposant de la portée connections:write. Le worker Slack doit être en cours d’exécution.", + "channels.config.bot_http_ready": "API du bot configurée avec des actions HTTP", + "channels.config.bot_socket_ready": "API du bot configurée avec le mode Socket" +} diff --git a/app/static/i18n/fr/common.json b/app/static/i18n/fr/common.json new file mode 100644 index 0000000..a276f5f --- /dev/null +++ b/app/static/i18n/fr/common.json @@ -0,0 +1,89 @@ +{ + "common.language": "Langue", + "common.team": "Équipe", + "common.message": "Message", + "common.cancel": "Annuler", + "common.ok": "OK", + "common.close": "Fermer", + "common.logout": "Se déconnecter", + "common.install_app": "Installer l’application", + "common.collapse_sidebar": "Réduire la barre latérale", + "common.loading_oncall_status": "Chargement du statut d’astreinte...", + "nav.overview": "Vue d’ensemble", + "nav.alerts": "Alertes", + "nav.rotations": "Rotations", + "nav.calendar": "Calendrier", + "nav.routes": "Routes", + "nav.heartbeats": "Signaux de vie", + "nav.services": "Services", + "nav.service_catalog": "Catalogue de services", + "nav.business_services": "Services métier", + "nav.maintenance": "Maintenance", + "nav.notification_policies": "Politiques de notification", + "nav.matcher_presets": "Préréglages de correspondance", + "nav.priority_policies": "Politiques de priorité", + "nav.escalation_policies": "Politiques d’escalade", + "nav.channels": "Canaux", + "nav.silences": "Silences", + "nav.teams": "Équipes", + "nav.administration": "Administration", + "nav.groups": "Groupes", + "nav.users": "Utilisateurs", + "nav.sso": "SSO", + "nav.swagger": "Swagger", + "login.title": "Connexion", + "login.badge": "Accès sécurisé pour les opérateurs", + "login.heading": "Se connecter", + "login.description": "Gérez les plannings, acquittez les alertes et coordonnez la réponse aux incidents depuis un seul endroit.", + "login.username": "Nom d’utilisateur", + "login.username_placeholder": "Saisissez votre nom d’utilisateur", + "login.password": "Mot de passe", + "login.password_placeholder": "Saisissez votre mot de passe", + "login.submit": "Se connecter", + "login.sso": "ou continuer avec le SSO", + "errors.admin_role_required": "Le rôle d’administrateur est requis", + "errors.group_admin_role_required": "Le rôle d’administrateur de groupe est requis", + "errors.page_load_failed": "Échec du chargement de la page : {error}", + "pages.dashboard.title": "Vue d’ensemble", + "pages.dashboard.subtitle": "Résumé en temps réel des incidents actifs et des équipes affectées", + "pages.alerts.title": "Alertes", + "pages.alerts.subtitle": "Recherchez, examinez, acquittez et résolvez les incidents routés", + "pages.rotations.title": "Rotations", + "pages.rotations.subtitle": "Gérez les rotations d’astreinte", + "pages.calendar.title": "Calendrier", + "pages.calendar.subtitle": "Calendrier d’astreinte par équipe", + "pages.routes.title": "Routes", + "pages.routes.subtitle": "Connectez les sources d’alerte, les rotations et les canaux", + "pages.services.title": "Services", + "pages.services.subtitle": "Services techniques, responsabilités et états", + "pages.business-services.title": "Services métier", + "pages.business-services.subtitle": "Capacités visibles par les clients, impact métier et services de page de statut", + "pages.heartbeats.title": "Signaux de vie", + "pages.heartbeats.subtitle": "Contrôles d’absence de signal pour les tâches, les pipelines de supervision et les chemins de livraison des alertes", + "pages.maintenance-windows.title": "Fenêtres de maintenance", + "pages.maintenance-windows.subtitle": "Maintenance planifiée, suppression des notifications et gestion des escalades", + "pages.escalation-policies.title": "Politiques d’escalade", + "pages.escalation-policies.subtitle": "Définissez les chaînes d’escalade des alertes par équipe", + "pages.notification-policies.title": "Politiques de notification", + "pages.notification-policies.subtitle": "Sélectionnez des canaux de notification partagés pour les événements de service", + "pages.matcher-presets.title": "Préréglages de correspondance", + "pages.matcher-presets.subtitle": "Critères d’alerte réutilisables pour les politiques de service", + "pages.priority-policies.title": "Politiques de priorité", + "pages.priority-policies.subtitle": "Règles automatiques de priorité des incidents", + "pages.channels.title": "Canaux", + "pages.channels.subtitle": "Canaux de notification", + "pages.silences.title": "Silences", + "pages.silences.subtitle": "Mettez les alertes en silence à l’aide de critères de correspondance", + "pages.teams.title": "Équipes", + "pages.teams.subtitle": "Équipes d’astreinte indépendantes", + "pages.groups.title": "Groupes", + "pages.groups.subtitle": "Périmètres d’accès et rôles des utilisateurs", + "pages.profile.title": "Profil", + "pages.profile.subtitle": "Profil utilisateur et jeton API personnel", + "pages.admin-users.title": "Utilisateurs administrateurs", + "pages.admin-users.subtitle": "Espace de gestion des utilisateurs réservé aux administrateurs", + "pages.sso.title": "SSO", + "pages.sso.subtitle": "Fournisseurs de connexion OIDC et SAML", + "pages.login.title": "Connexion", + "pages.login.subtitle": "Authentification JWT" +} diff --git a/app/static/i18n/fr/escalations.json b/app/static/i18n/fr/escalations.json new file mode 100644 index 0000000..63134b5 --- /dev/null +++ b/app/static/i18n/fr/escalations.json @@ -0,0 +1,122 @@ +{ + "escalations.summary.policies": "Politiques", + "escalations.summary.policies_hint": "Chaînes d’escalade", + "escalations.summary.enabled": "Activées", + "escalations.summary.enabled_hint": "Prêtes pour les routes", + "escalations.summary.rules": "Règles", + "escalations.summary.rules_hint": "Niveaux configurés", + "escalations.summary.disabled": "Désactivées", + "escalations.summary.disabled_hint": "Non utilisées par les routes", + "escalations.list.title": "Politiques d’escalade", + "escalations.list.showing": "Affichage", + "escalations.list.of": "sur", + "escalations.list.policies": "politiques", + "escalations.actions.new_policy": "Nouvelle politique", + "escalations.actions.reload": "Recharger", + "escalations.actions.edit": "Modifier", + "escalations.actions.rules": "Règles", + "escalations.actions.disable": "Désactiver", + "escalations.actions.enable": "Activer", + "escalations.actions.remove": "Supprimer", + "escalations.actions.edit_policy": "Modifier la politique", + "escalations.actions.manage_rules": "Gérer les règles", + "escalations.actions.disable_policy": "Désactiver la politique", + "escalations.actions.enable_policy": "Activer la politique", + "escalations.actions.remove_policy": "Supprimer la politique", + "escalations.actions.reset": "Réinitialiser", + "escalations.actions.save_policy": "Enregistrer la politique", + "escalations.actions.add_rule": "Ajouter une règle", + "escalations.actions.close": "Fermer", + "escalations.actions.collapse": "Réduire", + "escalations.actions.delete": "Supprimer", + "escalations.actions.save_rule": "Enregistrer la règle", + "escalations.actions.delete_rule": "Supprimer la règle", + "escalations.search.placeholder": "Rechercher des politiques...", + "escalations.filters.all_statuses": "Tous les statuts", + "escalations.status.enabled": "Activée", + "escalations.status.disabled": "Désactivée", + "escalations.table.policy": "Politique", + "escalations.table.team": "Équipe", + "escalations.table.rules": "Règles", + "escalations.table.repeat": "Répétition", + "escalations.table.status": "Statut", + "escalations.table.actions": "Actions", + "escalations.empty.loaded": "Aucune politique chargée", + "escalations.empty.found": "Aucune politique d’escalade", + "escalations.details.title": "Détails de la politique", + "escalations.details.select": "Sélectionner une politique", + "escalations.details.hint": "Cliquez sur le nom d’une politique pour examiner ses règles et ses actions rapides.", + "escalations.details.name": "Nom", + "escalations.details.team": "Équipe", + "escalations.details.description": "Description", + "escalations.details.repeat_count": "Nombre de répétitions", + "escalations.details.status": "Statut", + "escalations.details.rules": "Règles", + "escalations.details.configured": "{count} configurées", + "escalations.details.no_rules": "Aucune règle", + "escalations.details.first_rule": "Première règle", + "escalations.details.first_rule_hint": "Ouvrez les règles et ajoutez la première règle d’escalade.", + "escalations.details.rule": "Règle {position}", + "escalations.policy.number": "Politique n° {id}", + "escalations.form.create_title": "Créer une politique", + "escalations.form.edit_title": "Modifier la politique n° {id}", + "escalations.form.subtitle": "Définissez les paramètres de la chaîne d’escalade d’une équipe.", + "escalations.form.policy_section": "Politique", + "escalations.form.policy_section_hint": "Équipe, nom et statut.", + "escalations.form.team": "Équipe", + "escalations.form.name": "Nom", + "escalations.form.name_placeholder": "Alertes critiques de production", + "escalations.form.enabled": "Activée", + "escalations.form.disabled_help": "Les politiques désactivées restent visibles, mais ne doivent pas être sélectionnées pour de nouvelles routes.", + "escalations.form.behavior": "Comportement", + "escalations.form.behavior_hint": "Nombre de répétitions et description.", + "escalations.form.repeat_count": "Nombre de répétitions", + "escalations.form.description": "Description", + "escalations.form.description_placeholder": "Rotation principale, rotation de secours, responsable d’équipe", + "escalations.form.rules_help": "Les règles se configurent depuis les détails de la politique après son enregistrement.", + "escalations.rules.title": "Règles de la politique", + "escalations.rules.title_named": "Règles de la politique : {name}", + "escalations.rules.select_policy": "Sélectionnez d’abord une politique.", + "escalations.rules.section_title": "Règles", + "escalations.rules.section_hint": "Les règles sont évaluées selon leur position. Chaque règle définit la cible et le délai de la prochaine escalade.", + "escalations.rules.no_policy": "Aucune politique sélectionnée", + "escalations.rules.loading": "Chargement des règles...", + "escalations.rules.empty": "Aucune règle. Ajoutez la première règle pour définir la chaîne d’escalade.", + "escalations.rules.unsaved": "non enregistrée", + "escalations.rules.card_title": "Règle {position}{suffix}", + "escalations.rules.card_summary": "{target} · après {delay} · {status}", + "escalations.rules.target": "Cible", + "escalations.rules.escalate_after": "Escalader après", + "escalations.rules.settings": "Paramètres de la règle", + "escalations.rules.settings_hint": "Configurez la position, le délai et la cible de cette règle d’escalade.", + "escalations.rules.position": "Position", + "escalations.rules.delay_seconds": "Escalader après, en secondes", + "escalations.rules.target_type": "Type de cible", + "escalations.rules.rotation": "Rotation", + "escalations.rules.user": "Utilisateur", + "escalations.rules.no_rotations": "Aucune rotation active", + "escalations.rules.select_user": "Sélectionner un utilisateur...", + "escalations.rules.no_users": "Aucun utilisateur actif", + "escalations.rules.target_summary": "{type} : {name}", + "escalations.rules.formatted": "n° {position} — {target} — après {delay} — {status}", + "escalations.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier cette politique.", + "escalations.permissions.rules": "Le rôle de responsable d’équipe est requis pour gérer les règles de cette politique.", + "escalations.permissions.toggle": "Le rôle de responsable d’équipe est requis pour activer ou désactiver cette politique.", + "escalations.permissions.remove": "L’autorisation de suppression est requise pour supprimer cette politique.", + "escalations.errors.access_denied": "Accès refusé", + "escalations.errors.create": "Un rôle avec droit d’écriture est requis pour créer des politiques.", + "escalations.errors.not_found": "Politique introuvable.", + "escalations.errors.edit": "Vous n’êtes pas autorisé à modifier cette politique.", + "escalations.errors.update": "Vous n’êtes pas autorisé à mettre à jour cette politique.", + "escalations.errors.remove": "Vous n’êtes pas autorisé à supprimer cette politique.", + "escalations.errors.manage_rules": "Vous n’êtes pas autorisé à gérer les règles de cette politique.", + "escalations.errors.select_policy": "Sélectionnez d’abord une politique.", + "escalations.errors.no_targets": "Ajoutez une rotation active ou un utilisateur de l’équipe avant de créer des règles.", + "escalations.errors.select_target": "Sélectionnez la cible de la règle.", + "escalations.confirm.remove_title": "Supprimer cette politique ?", + "escalations.confirm.remove_message": "Supprimer la politique « {name} » ? Les alertes existantes conservent leur état actuel, mais les nouvelles routes ne pourront plus utiliser cette politique.", + "escalations.confirm.remove_button": "Supprimer la politique", + "escalations.confirm.delete_rule_title": "Supprimer cette règle ?", + "escalations.confirm.delete_rule_message": "Supprimer la règle d’escalade n° {id} ?", + "escalations.target.user_number": "Utilisateur n° {id}" +} diff --git a/app/static/i18n/fr/final_polish.json b/app/static/i18n/fr/final_polish.json new file mode 100644 index 0000000..08eb6e7 --- /dev/null +++ b/app/static/i18n/fr/final_polish.json @@ -0,0 +1,68 @@ +{ + "common.expand_sidebar": "Développer la barre latérale", + "login.credentials_required": "Veuillez saisir le nom d’utilisateur et le mot de passe", + "login.signing_in": "Connexion en cours...", + "login.logged_in": "Connecté en tant que {username}\nExpiration : {expires_at}", + "login.invalid_credentials": "Nom d’utilisateur ou mot de passe incorrect", + "login.token_stored": "Le jeton JWT est stocké dans ce navigateur.", + "login.sign_in_with": "Se connecter avec {provider}", + "login.identity_provider": "Continuer avec votre fournisseur d’identité", + "pwa.manifest.description": "Planification d’astreinte, routage des alertes et notifications d’incident auto-hébergés.", + "pwa.shortcut.alerts.name": "Alertes", + "pwa.shortcut.alerts.short_name": "Alertes", + "pwa.shortcut.alerts.description": "Ouvrir les alertes actives", + "pwa.shortcut.calendar.name": "Calendrier d’astreinte", + "pwa.shortcut.calendar.short_name": "Calendrier", + "pwa.shortcut.calendar.description": "Ouvrir le calendrier d’astreinte", + "pwa.shortcut.services.name": "Services", + "pwa.shortcut.services.short_name": "Services", + "pwa.shortcut.services.description": "Ouvrir l’impact sur les services", + "pwa.screenshot.desktop": "Tableau de bord des alertes IncidentRelay", + "pwa.screenshot.mobile": "Alertes IncidentRelay sur mobile", + "swagger.title": "Documentation de l’API IncidentRelay", + "final.api.authentication_required": "Une authentification est requise", + "final.api.valid_jwt_required": "Un jeton JWT valide est requis", + "final.api.jwt_or_api_required": "Une authentification par jeton JWT ou API est requise", + "final.api.access_denied": "Accès refusé.", + "final.api.group_access_denied": "L’accès à ce groupe est refusé", + "final.api.team_access_denied": "L’accès à cette équipe est refusé", + "final.api.team_resource_denied": "L’accès à cette ressource d’équipe est refusé", + "final.api.team_oncall_denied": "L’accès au planning d’astreinte de cette équipe est refusé", + "final.api.admin_required": "Le rôle d’administrateur est requis", + "final.api.group_admin_required": "Le rôle d’administrateur de groupe est requis", + "final.api.team_manager_required": "Le rôle de responsable d’équipe est requis pour cette équipe", + "final.api.team_responder_required": "Le rôle d’intervenant d’équipe est requis pour cette équipe", + "final.api.group_editor_required": "Le rôle d’éditeur ou d’administrateur de groupe est requis pour ce groupe", + "final.api.group_user_admin_required": "Le rôle d’administrateur des utilisateurs du groupe est requis pour ce groupe", + "final.api.team_or_group_editor_required": "Le rôle de responsable d’équipe ou d’éditeur de groupe est requis pour cette équipe", + "final.api.team_or_group_admin_required": "Le rôle de responsable d’équipe ou d’administrateur de groupe est requis pour cette équipe", + "final.api.request_body_required": "Le corps de la requête est requis", + "final.api.valid_json_required": "Le corps de la requête doit contenir un JSON valide", + "final.api.invalid_request": "Requête non valide.", + "final.api.validation_failed": "Échec de la validation de la requête", + "final.api.invalid_value": "Valeur non valide", + "final.api.requested_not_found": "La ressource demandée est introuvable.", + "final.api.resource_not_found": "Ressource introuvable", + "final.api.user_not_found": "Utilisateur introuvable.", + "final.api.team_not_found": "Équipe introuvable.", + "final.api.rotation_not_found": "Rotation introuvable.", + "final.api.alert_group_not_found": "Groupe d’alertes introuvable.", + "final.api.maintenance_not_found": "Fenêtre de maintenance introuvable.", + "final.api.business_service_not_found": "Service métier introuvable", + "final.api.notification_rule_not_found": "Règle de notification introuvable.", + "final.api.calendar_feed_not_found": "Flux de calendrier introuvable.", + "final.api.old_password_invalid": "L’ancien mot de passe est incorrect", + "final.api.self_disable_denied": "Vous ne pouvez pas désactiver votre propre compte utilisateur", + "final.api.self_remove_denied": "Vous ne pouvez pas supprimer votre propre compte utilisateur.", + "final.api.selected_group_not_found": "Le groupe sélectionné est introuvable", + "final.api.selected_group_inactive": "Le groupe sélectionné est inactif", + "final.api.internal_server_error": "Erreur interne du serveur", + "final.api.unexpected_server_error": "Erreur inattendue du serveur. Consultez le journal JSON à l’aide de error_id.", + "final.api.database_constraint": "Violation d’une contrainte de base de données", + "final.api.user_conflict": "Un utilisateur avec ce nom ou ce champ unique existe déjà", + "final.api.channel_conflict": "Un canal portant ce nom existe déjà dans cette équipe", + "final.api.channel_unique": "Le nom du canal doit être unique au sein d’une équipe", + "final.api.channel_test_failed": "Échec du test du canal.", + "final.api.manual_incident_target_not_found": "Cible de l’incident manuel introuvable.", + "final.api.missing_scope": "Portée du jeton API manquante" +} diff --git a/app/static/i18n/fr/groups.json b/app/static/i18n/fr/groups.json new file mode 100644 index 0000000..91611dd --- /dev/null +++ b/app/static/i18n/fr/groups.json @@ -0,0 +1,95 @@ +{ + "groups.summary.groups": "Groupes", + "groups.summary.groups_hint": "Groupes d’accès visibles", + "groups.summary.active": "Actifs", + "groups.summary.active_hint": "Groupes activés", + "groups.summary.inactive": "Inactifs", + "groups.summary.inactive_hint": "Groupes désactivés", + "groups.list.title": "Groupes", + "groups.list.subtitle": "Périmètres d’accès pour les équipes, les utilisateurs et les routes.", + "groups.actions.reload": "Recharger", + "groups.actions.new": "Nouveau groupe", + "groups.actions.edit": "Modifier", + "groups.actions.details": "Détails", + "groups.actions.members": "Membres", + "groups.actions.enable": "Activer", + "groups.actions.disable": "Désactiver", + "groups.actions.delete": "Supprimer", + "groups.actions.save": "Enregistrer le groupe", + "groups.actions.clear": "Effacer", + "groups.actions.save_membership": "Enregistrer l’appartenance", + "groups.actions.reset": "Réinitialiser", + "groups.actions.reload_members": "Recharger les membres", + "groups.actions.close": "Fermer", + "groups.table.id": "ID", + "groups.table.group": "Groupe", + "groups.table.description": "Description", + "groups.table.status": "Statut", + "groups.table.actions": "Actions", + "groups.table.user_id": "ID utilisateur", + "groups.table.user": "Utilisateur", + "groups.table.name": "Nom", + "groups.table.role": "Rôle", + "groups.empty.groups_loaded": "Aucun groupe chargé", + "groups.empty.groups": "Aucun groupe", + "groups.empty.members_select": "Enregistrez ou sélectionnez un groupe pour gérer ses membres", + "groups.empty.members_save": "Enregistrez d’abord le groupe, puis ajoutez des membres", + "groups.empty.members": "Aucun membre", + "groups.status.active": "Actif", + "groups.status.inactive": "Inactif", + "groups.row.fallback": "Groupe n° {id}", + "groups.modal.details": "Détails du groupe", + "groups.modal.details_subtitle": "Modifiez les paramètres du groupe et ses membres.", + "groups.modal.new": "Nouveau groupe", + "groups.modal.new_subtitle": "Créez un nouveau groupe d’accès.", + "groups.settings.title": "Paramètres du groupe", + "groups.settings.subtitle": "Slug, nom d’affichage et statut.", + "groups.form.slug": "Slug", + "groups.form.slug_placeholder": "infra", + "groups.form.name": "Nom", + "groups.form.name_placeholder": "Infrastructure", + "groups.form.description": "Description", + "groups.form.description_placeholder": "Objectif du groupe ou périmètre d’accès", + "groups.form.active": "Actif", + "groups.form.help": "Seuls les administrateurs peuvent créer des groupes. Les utilisateurs disposant du rôle RW peuvent modifier les groupes auxquels ils appartiennent.", + "groups.members.add_title": "Ajouter un utilisateur au groupe", + "groups.members.add_existing_title": "Ajouter un utilisateur existant au groupe", + "groups.members.edit_title": "Modifier l’appartenance au groupe n° {id}", + "groups.members.form_subtitle": "Ajoutez un utilisateur ou modifiez l’appartenance sélectionnée.", + "groups.members.user": "Utilisateur", + "groups.members.user_placeholder": "Sélectionner un utilisateur...", + "groups.members.role": "Rôle", + "groups.members.active": "Actif", + "groups.members.help_saved": "Ajoutez un utilisateur existant à ce groupe ou mettez à jour son appartenance.", + "groups.members.help_unsaved": "Enregistrez ou sélectionnez un groupe avant d’ajouter des membres.", + "groups.members.title": "Membres du groupe", + "groups.members.title_named": "Membres du groupe : {name}", + "groups.members.subtitle": "Utilisateurs affectés à ce groupe.", + "groups.validation.slug_name": "Le slug et le nom sont requis", + "groups.validation.group_first": "Enregistrez ou sélectionnez d’abord un groupe", + "groups.validation.user": "L’utilisateur est requis", + "groups.validation.username": "Le nom d’utilisateur est requis", + "groups.validation.password": "Le mot de passe est requis", + "groups.permissions.edit": "Le rôle d’éditeur ou d’administrateur de groupe est requis pour ce groupe.", + "groups.permissions.member_edit": "Le rôle d’administrateur de groupe est requis pour modifier les membres.", + "groups.permissions.member_toggle": "Le rôle d’administrateur de groupe est requis pour activer ou désactiver les membres.", + "groups.permissions.member_delete": "Le rôle d’administrateur de groupe est requis pour supprimer les appartenances au groupe.", + "groups.confirm.title": "Êtes-vous sûr ?", + "groups.confirm.enable_action": "activer", + "groups.confirm.disable_action": "désactiver", + "groups.confirm.toggle_group": "Voulez-vous vraiment {action} ce groupe ?", + "groups.confirm.toggle_membership": "Voulez-vous vraiment {action} cette appartenance au groupe ?", + "groups.confirm.delete_membership_title": "Supprimer l’appartenance au groupe ?", + "groups.confirm.delete_membership_message": "L’utilisateur « {user} » sera retiré de ce groupe. Il sera également retiré des équipes et des rotations appartenant à ce groupe.", + "groups.confirm.delete_group_title": "Supprimer le groupe ?", + "groups.confirm.delete_group_message": "Le groupe « {group} » sera supprimé. Les équipes, routes, rotations, canaux, silences et jetons de ce groupe seront désactivés.", + "groups.create_user.selected": "groupe sélectionné", + "groups.create_user.enabled_help": "Le nouvel utilisateur sera créé dans le groupe : {group}.", + "groups.create_user.disabled_help": "Enregistrez ou sélectionnez un groupe avant de créer des utilisateurs.", + "rbac.group.viewer": "Lecteur du groupe", + "rbac.group.editor": "Éditeur du groupe", + "rbac.group.admin": "Administrateur du groupe", + "rbac.team.viewer": "Lecteur de l’équipe", + "rbac.team.responder": "Intervenant de l’équipe", + "rbac.team.manager": "Responsable de l’équipe" +} diff --git a/app/static/i18n/fr/heartbeats.json b/app/static/i18n/fr/heartbeats.json new file mode 100644 index 0000000..88d4723 --- /dev/null +++ b/app/static/i18n/fr/heartbeats.json @@ -0,0 +1,145 @@ +{ + "heartbeats.summary.title": "Signaux de vie", + "heartbeats.summary.hint": "Contrôles d’absence de signal", + "heartbeats.summary.ok": "OK", + "heartbeats.summary.ok_hint": "Les signaux sont à jour", + "heartbeats.summary.overdue": "En retard", + "heartbeats.summary.overdue_hint": "Déclenche une alerte ou nécessite une intervention", + "heartbeats.summary.paused": "En pause", + "heartbeats.summary.paused_hint": "Contrôles de retard désactivés", + "heartbeats.list.title": "Signaux de vie", + "heartbeats.list.showing": "Affichage", + "heartbeats.list.of": "sur", + "heartbeats.list.checks": "contrôles", + "heartbeats.actions.new": "Nouveau signal de vie", + "heartbeats.actions.check_overdue": "Vérifier les retards", + "heartbeats.actions.reload": "Recharger", + "heartbeats.actions.details": "Détails", + "heartbeats.actions.edit": "Modifier", + "heartbeats.actions.pause": "Mettre en pause", + "heartbeats.actions.resume": "Reprendre", + "heartbeats.actions.regenerate": "Régénérer le jeton", + "heartbeats.actions.disable_instance": "Désactiver l’instance", + "heartbeats.actions.delete": "Supprimer", + "heartbeats.actions.cancel": "Annuler", + "heartbeats.actions.save": "Enregistrer", + "heartbeats.actions.format": "Formater", + "heartbeats.actions.copy_url": "Copier l’URL", + "heartbeats.actions.copy_curl": "Copier la commande cURL", + "heartbeats.search.placeholder": "Rechercher des signaux de vie...", + "heartbeats.filters.all_statuses": "Tous les statuts", + "heartbeats.status.ok": "OK", + "heartbeats.status.new": "Nouveau", + "heartbeats.status.overdue": "En retard", + "heartbeats.status.paused": "En pause", + "heartbeats.status.unknown": "Inconnu", + "heartbeats.table.name": "Nom", + "heartbeats.table.team": "Équipe", + "heartbeats.table.service": "Service", + "heartbeats.table.status": "Statut", + "heartbeats.table.instances": "Instances", + "heartbeats.table.schedule": "Planning", + "heartbeats.table.last_ping": "Dernier signal", + "heartbeats.table.deadline": "Échéance", + "heartbeats.table.priority": "Priorité", + "heartbeats.table.actions": "Actions", + "heartbeats.empty.loaded": "Aucun signal de vie chargé", + "heartbeats.empty.found": "Aucun signal de vie trouvé", + "heartbeats.details.title": "Détails du signal de vie", + "heartbeats.details.subtitle": "URL de signal, signaux récents et état de l’alerte.", + "heartbeats.details.none": "Aucun signal de vie sélectionné.", + "heartbeats.instances.title": "Instances", + "heartbeats.instances.subtitle": "État du signal par hôte ou par producteur.", + "heartbeats.instances.instance": "Instance", + "heartbeats.instances.mode": "Mode", + "heartbeats.instances.enabled": "Activée", + "heartbeats.instances.none": "Aucune instance", + "heartbeats.instances.none_configured": "Aucune instance découverte ou configurée pour le moment", + "heartbeats.events.title": "Événements récents", + "heartbeats.events.subtitle": "Signaux reçus, passages en retard et rétablissements.", + "heartbeats.events.time": "Heure", + "heartbeats.events.event": "Événement", + "heartbeats.events.state": "État", + "heartbeats.events.message": "Message", + "heartbeats.events.none": "Aucun événement", + "heartbeats.form.new": "Nouveau signal de vie", + "heartbeats.form.edit": "Modifier le signal de vie", + "heartbeats.form.subtitle": "Créez un contrôle d’absence de signal qui déclenche une alerte lorsque les signaux attendus cessent.", + "heartbeats.form.team": "Équipe", + "heartbeats.form.route": "Route du signal de vie", + "heartbeats.form.route_help": "La source de la route doit être « heartbeat ». Elle contrôle l’escalade et la livraison des notifications.", + "heartbeats.form.name": "Nom", + "heartbeats.form.slug": "Slug", + "heartbeats.form.service": "Service", + "heartbeats.form.no_service": "Aucun service", + "heartbeats.form.mode": "Mode", + "heartbeats.form.interval_ping": "Signal à intervalle régulier", + "heartbeats.form.scheduled_completion": "Achèvement planifié", + "heartbeats.form.expected_interval": "Intervalle attendu, en secondes", + "heartbeats.form.grace": "Période de grâce, en secondes", + "heartbeats.form.schedule": "Planning", + "heartbeats.form.daily": "Quotidien", + "heartbeats.form.weekly": "Hebdomadaire", + "heartbeats.form.monthly": "Mensuel", + "heartbeats.form.expected_by": "Attendu avant", + "heartbeats.form.weekday": "Jour de la semaine", + "heartbeats.form.month_day": "Jour du mois", + "heartbeats.form.timezone": "Fuseau horaire", + "heartbeats.form.severity": "Gravité", + "heartbeats.form.priority": "Priorité", + "heartbeats.form.description": "Description", + "heartbeats.form.description_placeholder": "Ce que prouve ce signal de vie et les vérifications à effectuer lorsqu’il est en retard.", + "heartbeats.form.enabled": "Activé", + "heartbeats.form.auto_resolve": "Résoudre automatiquement l’alerte de retard au retour du signal", + "heartbeats.form.track_instances": "Suivre les instances / découverte automatique", + "heartbeats.form.instance_field": "Champ de l’instance", + "heartbeats.form.instance_help": "Champ JSON du signal utilisé comme clé du producteur, par exemple instance=$(hostname -f).", + "heartbeats.form.expected_instances": "Instances attendues", + "heartbeats.form.auto_discovery": "Découverte automatique", + "heartbeats.form.static_list": "Liste statique", + "heartbeats.form.instances_help": "La découverte automatique ajoute les nouveaux producteurs lors de leur premier signal. Le mode statique n’accepte que les producteurs répertoriés.", + "heartbeats.form.ttl": "Durée de vie de la découverte automatique, en jours", + "heartbeats.form.ttl_help": "Les instances découvertes automatiquement qui sont saines mais n’émettent plus de signal après cette durée sont désactivées.", + "heartbeats.form.static_instances": "Instances statiques", + "heartbeats.form.static_help": "Une instance par ligne ou séparées par des virgules.", + "heartbeats.form.labels_json": "Libellés au format JSON", + "heartbeats.token.title": "Jeton du signal de vie créé", + "heartbeats.token.subtitle": "Copiez cette URL maintenant. Le jeton n’est affiché qu’une seule fois.", + "heartbeats.token.ping_url": "URL du signal", + "heartbeats.token.url_help": "Utilisez directement cette URL. Le jeton fait partie du chemin ; n’ajoutez pas d’en-tête Authorization: Bearer.", + "heartbeats.token.example_curl": "Exemple cURL", + "heartbeats.confirm.delete": "Supprimer ce signal de vie ?", + "heartbeats.confirm.regenerate_title": "Régénérer le jeton du signal de vie ?", + "heartbeats.confirm.regenerate_subtitle": "Les URL de signal existantes cesseront immédiatement de fonctionner.", + "heartbeats.confirm.regenerate_message": "Tous les producteurs qui utilisent l’URL actuelle devront être mis à jour avec le nouveau jeton. Continuez uniquement si vous êtes prêt à remplacer l’ancienne URL.", + "heartbeats.messages.labels_invalid": "Le JSON des libellés n’est pas valide : {error}", + "heartbeats.messages.token_hidden": "Le jeton de l’URL est masqué. Régénérez-le pour afficher une nouvelle URL.", + "heartbeats.messages.url_copied": "URL du signal copiée", + "heartbeats.messages.curl_copied": "Commande cURL copiée", + "heartbeats.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier ce signal de vie.", + "heartbeats.permissions.pause": "Le rôle de responsable d’équipe est requis pour mettre en pause ou reprendre ce signal de vie.", + "heartbeats.permissions.token": "Le rôle de responsable d’équipe est requis pour régénérer les jetons des signaux de vie.", + "heartbeats.mode.every": "Toutes les {interval}s + {grace}s de grâce", + "heartbeats.mode.scheduled": "{kind} à {time}", + "heartbeats.mode.day": "jour {day}", + "heartbeats.instance.zero": "0 instance", + "heartbeats.instance.summary": "{total} au total, {ok} OK, {overdue} en retard", + "heartbeats.values.yes": "oui", + "heartbeats.values.no": "non", + "heartbeats.values.auto": "automatique", + "heartbeats.values.static": "statique", + "heartbeats.values.disabled": "désactivé", + "heartbeats.details.route": "Route", + "heartbeats.details.mode": "Mode", + "heartbeats.details.next_expected": "Prochain signal attendu", + "heartbeats.details.current_alert_group": "Groupe d’alertes actuel", + "heartbeats.details.instance_tracking": "Suivi des instances", + "heartbeats.details.curl": "cURL", + "heartbeats.weekday.mon": "Lun", + "heartbeats.weekday.tue": "Mar", + "heartbeats.weekday.wed": "Mer", + "heartbeats.weekday.thu": "Jeu", + "heartbeats.weekday.fri": "Ven", + "heartbeats.weekday.sat": "Sam", + "heartbeats.weekday.sun": "Dim" +} diff --git a/app/static/i18n/fr/maintenance.json b/app/static/i18n/fr/maintenance.json new file mode 100644 index 0000000..5e4e0c4 --- /dev/null +++ b/app/static/i18n/fr/maintenance.json @@ -0,0 +1,146 @@ +{ + "maintenance.summary.title": "Maintenance", + "maintenance.summary.current_scope": "Portée actuelle", + "maintenance.summary.active": "Actives", + "maintenance.summary.active_hint": "Affectent actuellement les alertes", + "maintenance.summary.scheduled": "Planifiées", + "maintenance.summary.scheduled_hint": "Fenêtres prévues", + "maintenance.summary.cancelled": "Annulées", + "maintenance.summary.cancelled_hint": "Fenêtres annulées", + "maintenance.list.title": "Fenêtres de maintenance", + "maintenance.list.showing": "Affichage", + "maintenance.list.of": "sur", + "maintenance.list.windows": "fenêtres", + "maintenance.actions.new": "Nouvelle fenêtre", + "maintenance.actions.reload": "Recharger", + "maintenance.actions.edit": "Modifier", + "maintenance.actions.extend": "Prolonger d’1 h", + "maintenance.actions.duplicate": "Dupliquer", + "maintenance.actions.cancel": "Annuler", + "maintenance.actions.delete": "Supprimer", + "maintenance.actions.edit_window": "Modifier la fenêtre", + "maintenance.actions.cancel_window": "Annuler la fenêtre", + "maintenance.actions.reset": "Réinitialiser", + "maintenance.actions.save": "Enregistrer la fenêtre", + "maintenance.search.placeholder": "Rechercher des fenêtres de maintenance...", + "maintenance.filters.all_statuses": "Tous les statuts", + "maintenance.filters.all_behaviors": "Tous les comportements", + "maintenance.status.scheduled": "Planifiée", + "maintenance.status.active": "Active", + "maintenance.status.finished": "Terminée", + "maintenance.status.cancelled": "Annulée", + "maintenance.behavior.suppress_notifications": "Supprimer les notifications", + "maintenance.behavior.suppress_incident": "Supprimer l’incident", + "maintenance.behavior.create_maintenance_incident": "Créer un incident de maintenance", + "maintenance.behavior.maintenance_incident": "Incident de maintenance", + "maintenance.behavior.pause_escalation_only": "Mettre uniquement l’escalade en pause", + "maintenance.behavior.pause_escalation": "Mettre l’escalade en pause", + "maintenance.table.window": "Fenêtre", + "maintenance.table.scope": "Portée", + "maintenance.table.status": "Statut", + "maintenance.table.behavior": "Comportement", + "maintenance.table.repeat": "Répétition", + "maintenance.table.starts": "Début", + "maintenance.table.ends": "Fin", + "maintenance.table.actions": "Actions", + "maintenance.empty.loaded": "Aucune fenêtre de maintenance chargée", + "maintenance.empty.found": "Aucune fenêtre de maintenance", + "maintenance.details.title": "Détails de la fenêtre", + "maintenance.details.select": "Sélectionner une fenêtre", + "maintenance.details.help": "Cliquez sur le nom d’une fenêtre de maintenance pour examiner son planning, sa portée et son comportement.", + "maintenance.details.name": "Nom", + "maintenance.details.description": "Description", + "maintenance.details.status": "Statut", + "maintenance.details.behavior": "Comportement", + "maintenance.details.repeat": "Répétition", + "maintenance.details.scope": "Portée", + "maintenance.details.starts": "Début", + "maintenance.details.ends": "Fin", + "maintenance.details.timezone": "Fuseau horaire", + "maintenance.details.rrule": "RRULE", + "maintenance.details.enabled": "Activée", + "maintenance.form.create": "Créer une fenêtre de maintenance", + "maintenance.form.edit": "Modifier la fenêtre de maintenance", + "maintenance.form.subtitle": "Configurez la maintenance planifiée et le comportement des alertes correspondantes.", + "maintenance.form.window": "Fenêtre", + "maintenance.form.window_hint": "Nom, description et comportement.", + "maintenance.form.name": "Nom", + "maintenance.form.name_placeholder": "Déploiement des paiements", + "maintenance.form.description": "Description", + "maintenance.form.behavior": "Comportement", + "maintenance.form.timezone": "Fuseau horaire", + "maintenance.form.repeat": "Répétition", + "maintenance.form.no_repeat": "Ne se répète pas", + "maintenance.form.daily": "Quotidienne", + "maintenance.form.weekly": "Hebdomadaire", + "maintenance.form.monthly": "Mensuelle", + "maintenance.form.custom_rrule": "RRULE personnalisée", + "maintenance.form.repeat_count": "Nombre de répétitions", + "maintenance.form.repeat_help": "Nombre d’occurrences à créer.", + "maintenance.form.rrule_help": "Utilisez une RRULE RFC5545 sans le préfixe RRULE:.", + "maintenance.form.schedule_scope": "Planning et portée", + "maintenance.form.schedule_scope_hint": "Plage horaire et objet affecté.", + "maintenance.form.starts_at": "Commence le", + "maintenance.form.ends_at": "Se termine le", + "maintenance.form.scope_type": "Type de portée", + "maintenance.form.scope_target": "Cible de la portée", + "maintenance.form.select_target": "Sélectionner une cible", + "maintenance.form.service": "Service", + "maintenance.form.team": "Équipe", + "maintenance.form.route": "Route", + "maintenance.form.group": "Groupe", + "maintenance.form.enabled": "Activée", + "maintenance.repeat.no": "Ne se répète pas", + "maintenance.repeat.daily": "Quotidienne", + "maintenance.repeat.weekly": "Hebdomadaire", + "maintenance.repeat.monthly": "Mensuelle", + "maintenance.repeat.count": "{value} fois", + "maintenance.repeat.with_count": "{period} · {count} fois", + "maintenance.values.yes": "Oui", + "maintenance.values.no": "Non", + "maintenance.values.select_type": "Sélectionner {type}", + "maintenance.values.group_number": "Groupe n° {id}", + "maintenance.values.team_number": "Équipe n° {id}", + "maintenance.values.service_number": "Service n° {id}", + "maintenance.values.route_number": "Route n° {id}", + "maintenance.values.window_number": "Fenêtre n° {id}", + "maintenance.values.copy_suffix": "Copie de {name}", + "maintenance.values.default_name": "Fenêtre de maintenance", + "maintenance.confirm.cancel_title": "Annuler la fenêtre de maintenance", + "maintenance.confirm.cancel_message": "Annuler la fenêtre de maintenance {name} ?", + "maintenance.confirm.delete_title": "Supprimer la fenêtre de maintenance", + "maintenance.confirm.delete_message": "Supprimer la fenêtre de maintenance {name} ?", + "maintenance.confirm.cancel_reason": "Annulée depuis l’interface", + "maintenance.errors.not_found": "Fenêtre de maintenance introuvable.", + "maintenance.errors.name_required": "Le nom est requis.", + "maintenance.errors.times_required": "Les heures de début et de fin sont requises.", + "maintenance.errors.start_required": "L’heure de début est requise.", + "maintenance.errors.end_required": "L’heure de fin est requise.", + "maintenance.errors.end_after_start": "L’heure de fin doit être postérieure à l’heure de début.", + "maintenance.errors.scope_type_required": "Le type de portée est requis.", + "maintenance.errors.scope_required": "La cible de la portée est requise.", + "maintenance.errors.group_required": "Le groupe est requis.", + "maintenance.errors.team_required": "L’équipe est requise.", + "maintenance.errors.service_required": "Le service est requis.", + "maintenance.errors.route_required": "La route est requise.", + "maintenance.errors.rrule_required": "Une RRULE personnalisée est requise lorsque la répétition est définie sur RRULE personnalisée.", + "maintenance.errors.fix_fields": "Veuillez corriger les champs en surbrillance.", + "maintenance.errors.start_past": "L’heure de début est dans le passé.", + "maintenance.errors.end_past": "L’heure de fin est dans le passé.", + "maintenance.errors.extend_failed": "Échec de la prolongation de la fenêtre de maintenance", + "maintenance.errors.duplicate_failed": "Échec de la duplication de la fenêtre de maintenance", + "maintenance.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier cette fenêtre de maintenance.", + "maintenance.permissions.extend": "Le rôle de responsable d’équipe est requis pour prolonger cette fenêtre de maintenance.", + "maintenance.permissions.duplicate": "Le rôle de responsable d’équipe est requis pour dupliquer cette fenêtre de maintenance.", + "maintenance.permissions.cancel": "Le rôle de responsable d’équipe est requis pour annuler cette fenêtre de maintenance.", + "maintenance.permissions.delete": "L’autorisation de suppression est requise pour supprimer cette fenêtre de maintenance.", + "maintenance.badge.default": "Maintenance", + "maintenance.form.apply_to_existing": "Appliquer aux alertes non résolues existantes", + "maintenance.form.apply_to_existing_help": "Facultatif. Les groupes d’alertes non résolus déjà présents au début de la fenêtre sont affectés immédiatement.", + "maintenance.form.apply_to_existing_unavailable": "La suppression de l’incident empêche la création d’un nouveau groupe et ne peut pas être appliquée rétroactivement.", + "maintenance.form.reactivate_on_end": "Réactiver les alertes concernées à la fin de la maintenance", + "maintenance.form.reactivate_on_end_help": "Activé par défaut. Les effets sont retirés après la dernière fenêtre de maintenance applicable.", + "maintenance.form.reactivate_on_end_warning": "Les alertes concernées conserveront l’effet de maintenance après la fin de cette fenêtre. Activez ensuite cette option et enregistrez la fenêtre pour les libérer.", + "maintenance.details.apply_to_existing": "Alertes existantes", + "maintenance.details.reactivate_on_end": "Réactiver après la fin" +} diff --git a/app/static/i18n/fr/matcher_presets.json b/app/static/i18n/fr/matcher_presets.json new file mode 100644 index 0000000..a857c77 --- /dev/null +++ b/app/static/i18n/fr/matcher_presets.json @@ -0,0 +1,91 @@ +{ + "matcher_presets.summary.presets": "Préréglages", + "matcher_presets.summary.presets_hint": "Définitions de correspondance réutilisables", + "matcher_presets.summary.enabled": "Activés", + "matcher_presets.summary.enabled_hint": "Disponibles pour les nouvelles règles", + "matcher_presets.summary.used": "Préréglages utilisés", + "matcher_presets.summary.used_hint": "Référencés par des règles de politique", + "matcher_presets.summary.usages": "Utilisations dans les règles", + "matcher_presets.summary.usages_hint": "Nombre total de références dans les règles de politique", + "matcher_presets.list.title": "Préréglages de correspondance", + "matcher_presets.list.showing": "Affichage", + "matcher_presets.list.of": "sur", + "matcher_presets.list.presets": "préréglages", + "matcher_presets.actions.new": "Nouveau préréglage", + "matcher_presets.actions.reload": "Recharger", + "matcher_presets.actions.edit": "Modifier", + "matcher_presets.actions.enable": "Activer", + "matcher_presets.actions.disable": "Désactiver", + "matcher_presets.actions.remove": "Supprimer", + "matcher_presets.actions.edit_preset": "Modifier le préréglage", + "matcher_presets.actions.enable_preset": "Activer le préréglage", + "matcher_presets.actions.disable_preset": "Désactiver le préréglage", + "matcher_presets.actions.remove_preset": "Supprimer le préréglage", + "matcher_presets.actions.reset": "Réinitialiser", + "matcher_presets.actions.save": "Enregistrer le préréglage", + "matcher_presets.search.placeholder": "Rechercher des préréglages...", + "matcher_presets.filters.all_statuses": "Tous les statuts", + "matcher_presets.filters.enabled": "Activés", + "matcher_presets.filters.disabled": "Désactivés", + "matcher_presets.filters.used": "Utilisés par des règles", + "matcher_presets.filters.unused": "Non utilisés", + "matcher_presets.table.preset": "Préréglage", + "matcher_presets.table.team": "Équipe", + "matcher_presets.table.version": "Version", + "matcher_presets.table.usages": "Utilisations", + "matcher_presets.table.status": "Statut", + "matcher_presets.table.actions": "Actions", + "matcher_presets.empty.loaded": "Aucun préréglage chargé", + "matcher_presets.empty.found": "Aucun préréglage de correspondance", + "matcher_presets.details.title": "Détails du préréglage", + "matcher_presets.details.select": "Sélectionner un préréglage de correspondance", + "matcher_presets.details.select_help": "Cliquez sur le nom d’un préréglage pour examiner sa configuration.", + "matcher_presets.details.name": "Nom", + "matcher_presets.details.team": "Équipe", + "matcher_presets.details.description": "Description", + "matcher_presets.details.version": "Version", + "matcher_presets.details.status": "Statut", + "matcher_presets.details.total_usages": "Nombre total d’utilisations", + "matcher_presets.details.matchers": "Critères de correspondance", + "matcher_presets.details.resource": "Ressource n° {id}", + "matcher_presets.details.no_usages": "Aucune utilisation", + "matcher_presets.usages.notification": "Règles des politiques de notification", + "matcher_presets.usages.priority": "Règles des politiques de priorité", + "matcher_presets.usages.routes": "Routes", + "matcher_presets.usages.service_match": "Règles de correspondance des services", + "matcher_presets.usages.runbooks": "Procédures de service", + "matcher_presets.usages.silences": "Silences", + "matcher_presets.form.create": "Créer un préréglage de correspondance", + "matcher_presets.form.edit": "Modifier le préréglage n° {id}", + "matcher_presets.form.subtitle": "Définissez une condition réutilisable pour les règles de notification et de priorité.", + "matcher_presets.form.preset": "Préréglage", + "matcher_presets.form.preset_help": "Équipe, nom et statut.", + "matcher_presets.form.team": "Équipe", + "matcher_presets.form.name": "Nom", + "matcher_presets.form.name_placeholder": "Services de production", + "matcher_presets.form.description": "Description", + "matcher_presets.form.description_placeholder": "Correspond aux services de production.", + "matcher_presets.form.enabled": "Activé", + "matcher_presets.form.disabled_help": "Les préréglages désactivés ne peuvent pas être attribués à de nouvelles règles. Les règles existantes cessent de correspondre.", + "matcher_presets.form.matchers": "Critères de correspondance", + "matcher_presets.form.matchers_help": "La condition du préréglage est combinée avec les critères de la règle à l’aide de ET.", + "matcher_presets.form.matchers_json": "Critères au format JSON", + "matcher_presets.form.catch_all_help": "Utilisez {} comme préréglage général. Le format des critères est partagé avec les règles de politique.", + "matcher_presets.status.enabled": "Activé", + "matcher_presets.status.disabled": "Désactivé", + "matcher_presets.row.fallback": "Préréglage n° {id}", + "matcher_presets.validation.name_required": "Le nom du préréglage est requis.", + "matcher_presets.permissions.manager_edit": "Le rôle de responsable d’équipe est requis pour modifier ce préréglage.", + "matcher_presets.permissions.manager_update": "Le rôle de responsable d’équipe est requis pour mettre à jour ce préréglage.", + "matcher_presets.permissions.delete": "L’autorisation de suppression est requise pour supprimer ce préréglage.", + "matcher_presets.permissions.create": "Un rôle avec droit d’écriture est requis pour créer des préréglages.", + "matcher_presets.permissions.edit": "Vous n’êtes pas autorisé à modifier ce préréglage.", + "matcher_presets.permissions.update": "Vous n’êtes pas autorisé à mettre à jour ce préréglage.", + "matcher_presets.permissions.remove": "Vous n’êtes pas autorisé à supprimer ce préréglage.", + "matcher_presets.errors.not_found": "Préréglage de correspondance introuvable.", + "matcher_presets.errors.access_denied": "Accès refusé", + "matcher_presets.confirm.title": "Supprimer ce préréglage ?", + "matcher_presets.confirm.message": "Supprimer le préréglage « {name} » ? Les préréglages utilisés par des règles actives ne peuvent pas être supprimés.", + "matcher_presets.confirm.remove": "Supprimer le préréglage", + "common.close": "Fermer" +} diff --git a/app/static/i18n/fr/notification_center.json b/app/static/i18n/fr/notification_center.json new file mode 100644 index 0000000..abe83dd --- /dev/null +++ b/app/static/i18n/fr/notification_center.json @@ -0,0 +1,21 @@ +{ + "notification_center.notifications": "Notifications", + "notification_center.title": "Demandes d’intervention", + "notification_center.empty": "Aucune demande en attente", + "notification_center.notification": "Notification", + "notification_center.responder_requested": "Intervenant demandé", + "notification_center.help_requested": "{requester} a demandé votre aide pour cet incident.", + "notification_center.accept": "Accepter", + "notification_center.decline": "Refuser", + "notification_center.open_incident": "Ouvrir l’incident", + "notification_center.unknown_user": "Utilisateur inconnu", + "notification_center.user_id": "Utilisateur n° {id}", + "notification_center.no_team": "Aucune équipe", + "notification_center.no_service": "Aucun service", + "notification_center.status.firing": "Déclenché", + "notification_center.status.acknowledged": "Acquitté", + "notification_center.status.resolved": "Résolu", + "notification_center.status.maintenance": "Maintenance", + "notification_center.status.open": "Ouvert", + "notification_center.status.closed": "Fermé" +} diff --git a/app/static/i18n/fr/notification_policies.json b/app/static/i18n/fr/notification_policies.json new file mode 100644 index 0000000..02755d5 --- /dev/null +++ b/app/static/i18n/fr/notification_policies.json @@ -0,0 +1,124 @@ +{ + "notification_policies.summary.policies": "Politiques", + "notification_policies.summary.policies_hint": "Politiques de sélection des canaux", + "notification_policies.summary.enabled": "Activées", + "notification_policies.summary.enabled_hint": "Disponibles pour les services", + "notification_policies.summary.rules": "Règles", + "notification_policies.summary.rules_hint": "Règles de livraison configurées", + "notification_policies.summary.services": "Services", + "notification_policies.summary.services_hint": "Services utilisant des politiques", + "notification_policies.list.title": "Politiques de notification", + "notification_policies.list.showing": "Affichage", + "notification_policies.list.of": "sur", + "notification_policies.list.policies": "politiques", + "notification_policies.actions.new": "Nouvelle politique", + "notification_policies.actions.reload": "Recharger", + "notification_policies.actions.edit": "Modifier", + "notification_policies.actions.rules": "Règles", + "notification_policies.actions.disable": "Désactiver", + "notification_policies.actions.enable": "Activer", + "notification_policies.actions.remove": "Supprimer", + "notification_policies.actions.edit_policy": "Modifier la politique", + "notification_policies.actions.manage_rules": "Gérer les règles", + "notification_policies.actions.disable_policy": "Désactiver la politique", + "notification_policies.actions.enable_policy": "Activer la politique", + "notification_policies.actions.remove_policy": "Supprimer la politique", + "notification_policies.actions.reset": "Réinitialiser", + "notification_policies.actions.save_policy": "Enregistrer la politique", + "notification_policies.actions.add_rule": "Ajouter une règle", + "notification_policies.actions.close": "Fermer", + "notification_policies.actions.collapse": "Réduire", + "notification_policies.actions.delete": "Supprimer", + "notification_policies.actions.save_rule": "Enregistrer la règle", + "notification_policies.actions.delete_rule": "Supprimer la règle", + "notification_policies.search.placeholder": "Rechercher des politiques...", + "notification_policies.filters.all_statuses": "Tous les statuts", + "notification_policies.status.enabled": "Activée", + "notification_policies.status.disabled": "Désactivée", + "notification_policies.table.policy": "Politique", + "notification_policies.table.team": "Équipe", + "notification_policies.table.rules": "Règles", + "notification_policies.table.services": "Services", + "notification_policies.table.status": "Statut", + "notification_policies.table.actions": "Actions", + "notification_policies.empty.loaded": "Aucune politique de notification chargée", + "notification_policies.empty.found": "Aucune politique de notification", + "notification_policies.details.title": "Détails de la politique", + "notification_policies.details.select": "Sélectionner une politique", + "notification_policies.details.help": "Cliquez sur le nom d’une politique pour examiner sa configuration.", + "notification_policies.details.name": "Nom", + "notification_policies.details.team": "Équipe", + "notification_policies.details.description": "Description", + "notification_policies.details.rules": "Règles", + "notification_policies.details.services": "Services", + "notification_policies.details.status": "Statut", + "notification_policies.form.create_title": "Créer une politique de notification", + "notification_policies.form.edit_title": "Modifier la politique de notification n° {id}", + "notification_policies.form.subtitle": "Définissez un comportement de notification réutilisable pour les services.", + "notification_policies.form.policy": "Politique", + "notification_policies.form.policy_hint": "Équipe, nom et statut.", + "notification_policies.form.team": "Équipe", + "notification_policies.form.name": "Nom", + "notification_policies.form.name_placeholder": "Notifications de production", + "notification_policies.form.enabled": "Activée", + "notification_policies.form.disabled_help": "Les politiques désactivées restent visibles mais ne peuvent pas être attribuées à de nouveaux services.", + "notification_policies.form.description": "Description", + "notification_policies.form.description_hint": "Expliquez quels services doivent utiliser cette politique.", + "notification_policies.form.description_placeholder": "Règles de livraison partagées pour les services de production", + "notification_policies.form.rules_help": "Les règles et les canaux se configurent après l’enregistrement de la politique.", + "notification_policies.rules.title": "Règles de la politique de notification", + "notification_policies.rules.title_named": "Règles de la politique de notification : {name}", + "notification_policies.rules.subtitle": "Sélectionnez les canaux utilisés pour les événements de notification.", + "notification_policies.rules.evaluation_help": "Les règles sont évaluées selon leur position. Le traitement s’arrête après la première règle correspondante, sauf si « Continuer la correspondance » est activé.", + "notification_policies.rules.no_policy": "Aucune politique sélectionnée", + "notification_policies.rules.loading": "Chargement des règles...", + "notification_policies.rules.default_name": "Règle {position}", + "notification_policies.rules.no_rules": "Aucune règle. Ajoutez la première règle pour sélectionner les canaux de notification.", + "notification_policies.rules.unsaved": "non enregistrée", + "notification_policies.rules.events": "Événements", + "notification_policies.rules.matchers": "Critères de correspondance", + "notification_policies.rules.status": "Statut", + "notification_policies.rules.channels": "Canaux", + "notification_policies.rules.channels_help": "Les règles actives nécessitent au moins un canal.", + "notification_policies.rules.matcher_preset": "Préréglage de correspondance", + "notification_policies.rules.no_preset": "Aucun préréglage", + "notification_policies.rules.no_preset_help": "Aucun préréglage sélectionné. Seuls les critères locaux ci-dessous seront évalués.", + "notification_policies.rules.preset_disabled_help": "Ce préréglage est désactivé. La règle ne correspondra pas tant qu’il ne sera pas activé.", + "notification_policies.rules.preset_match_help": "Le préréglage « {name} » v{version} et les critères locaux doivent tous correspondre.", + "notification_policies.rules.settings": "Paramètres de la règle", + "notification_policies.rules.settings_help": "Configurez les événements, les critères et les canaux de notification partagés.", + "notification_policies.rules.name": "Nom", + "notification_policies.rules.position": "Position", + "notification_policies.rules.enabled": "Activée", + "notification_policies.rules.event_types": "Types d’événement", + "notification_policies.rules.notification": "Notification", + "notification_policies.rules.reminder": "Rappel", + "notification_policies.rules.escalation": "Escalade", + "notification_policies.rules.continue_matching": "Continuer la correspondance après cette règle", + "notification_policies.rules.description": "Description", + "notification_policies.rules.additional_matchers": "Critères supplémentaires", + "notification_policies.rules.additional_matchers_help": "Utilisez {} pour vous appuyer uniquement sur le préréglage sélectionné. Le préréglage et les critères supplémentaires utilisent ET.", + "notification_policies.rules.no_events": "Aucun événement", + "notification_policies.rules.no_channels": "Aucun canal", + "notification_policies.rules.all_alerts": "Toutes les alertes", + "notification_policies.rules.channel_number": "Canal n° {id}", + "notification_policies.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier cette politique.", + "notification_policies.permissions.rules": "Le rôle de responsable d’équipe est requis pour gérer les règles de cette politique.", + "notification_policies.permissions.update": "Le rôle de responsable d’équipe est requis pour mettre à jour cette politique.", + "notification_policies.permissions.remove": "L’autorisation de suppression est requise pour supprimer cette politique.", + "notification_policies.permissions.create": "Un rôle avec droit d’écriture est requis pour créer des politiques de notification.", + "notification_policies.permissions.no_manage": "Vous n’êtes pas autorisé à gérer les règles de cette politique.", + "notification_policies.permissions.no_edit": "Vous n’êtes pas autorisé à modifier cette politique.", + "notification_policies.permissions.no_update": "Vous n’êtes pas autorisé à mettre à jour cette politique.", + "notification_policies.permissions.no_remove": "Vous n’êtes pas autorisé à supprimer cette politique.", + "notification_policies.errors.access_denied": "Accès refusé", + "notification_policies.errors.not_found": "Politique de notification introuvable.", + "notification_policies.errors.select_policy": "Sélectionnez d’abord une politique de notification.", + "notification_policies.errors.rule_name_required": "Le nom de la règle est requis.", + "notification_policies.errors.event_required": "Sélectionnez au moins un type d’événement.", + "notification_policies.errors.channel_required": "Une règle active nécessite au moins un canal.", + "notification_policies.confirm.delete_rule_title": "Supprimer cette règle de notification ?", + "notification_policies.confirm.delete_rule_message": "Supprimer la règle n° {id} de la politique de notification ?", + "notification_policies.confirm.remove_title": "Supprimer cette politique de notification ?", + "notification_policies.confirm.remove_message": "Supprimer la politique « {name} » ? Une politique attribuée à un service ne peut pas être supprimée." +} diff --git a/app/static/i18n/fr/oncall_health.json b/app/static/i18n/fr/oncall_health.json new file mode 100644 index 0000000..3d8559b --- /dev/null +++ b/app/static/i18n/fr/oncall_health.json @@ -0,0 +1,105 @@ +{ + "oncall_health.title.rotation": "Santé de l’astreinte", + "oncall_health.title.rotation_named": "Santé de l’astreinte : {name}", + "oncall_health.title.team": "Santé de l’astreinte de l’équipe", + "oncall_health.title.team_named": "Santé de l’astreinte de l’équipe : {name}", + "oncall_health.close": "Fermer", + "oncall_health.table.health": "Santé", + "oncall_health.indicator.loading": "Chargement de l’état de santé. Cliquez pour ouvrir le diagnostic.", + "oncall_health.indicator.unknown": "L’état de santé est inconnu. Cliquez pour ouvrir le diagnostic.", + "oncall_health.indicator.counts": "Critiques : {critical} · Avertissements : {warning} · Infos : {info}", + "oncall_health.indicator.open_rotation": "Ouvrir les détails de santé de l’astreinte. {label}", + "oncall_health.indicator.open_team": "Ouvrir les détails de santé de l’astreinte de l’équipe. {label}", + "oncall_health.errors.summary_failed": "Échec du chargement du résumé de santé.", + "oncall_health.errors.summary_missing_rotation": "Aucun résumé de santé n’a été renvoyé pour cette rotation.", + "oncall_health.errors.summary_missing_team": "Aucun résumé de santé n’a été renvoyé pour l’équipe.", + "oncall_health.errors.team_summary_failed": "Échec du chargement du résumé de santé de l’équipe.", + "oncall_health.errors.rotation_id_missing": "L’identifiant de la rotation est introuvable pour cet indicateur de santé.", + "oncall_health.errors.team_id_missing": "L’identifiant de l’équipe est introuvable pour cet indicateur de santé.", + "oncall_health.errors.rotation_details_failed": "Échec du chargement des détails de santé.", + "oncall_health.errors.team_details_failed": "Échec du chargement des détails de santé de l’équipe.", + "oncall_health.loading": "Chargement...", + "oncall_health.loading.rotation": "Chargement des détails de santé...", + "oncall_health.loading.team": "Chargement des détails de santé de l’équipe...", + "oncall_health.team_label": "Équipe : {name}", + "oncall_health.window": "Fenêtre vérifiée : {start} — {end}", + "oncall_health.summary.status": "Statut", + "oncall_health.summary.critical": "Critiques", + "oncall_health.summary.warning": "Avertissements", + "oncall_health.summary.info": "Infos", + "oncall_health.status.critical": "Critique", + "oncall_health.status.warning": "Avertissement", + "oncall_health.status.info": "Info", + "oncall_health.status.ok": "OK", + "oncall_health.status.unknown": "Inconnu", + "oncall_health.issue.default": "Problème de santé", + "oncall_health.issue.rotation": "Rotation : {name}", + "oncall_health.issue.layer": "Couche : {name}", + "oncall_health.issue.user": "Utilisateur : {name}", + "oncall_health.empty.rotation": "Aucun problème de santé d’astreinte n’a été détecté pour cette rotation.", + "oncall_health.empty.team": "Aucun problème de santé n’a été détecté pour l’équipe.", + "oncall_health.issues.rotation_disabled.title": "La rotation est désactivée", + "oncall_health.issues.rotation_disabled.message": "Cette rotation ne participe pas à la planification des astreintes tant qu’elle est désactivée.", + "oncall_health.issues.rotation_disabled.hint": "Activez la rotation lorsqu’elle doit traiter des alertes.", + "oncall_health.issues.rotation_disabled_but_used.title": "La rotation désactivée est utilisée par une route active", + "oncall_health.issues.rotation_disabled_but_used.message": "Au moins une route d’alerte active pointe encore vers cette rotation désactivée.", + "oncall_health.issues.rotation_disabled_but_used.hint": "Activez la rotation, modifiez la rotation de la route ou désactivez la route.", + "oncall_health.issues.rotation_has_no_layers.title": "La rotation ne contient aucune couche", + "oncall_health.issues.rotation_has_no_layers.message": "Ajoutez au moins une couche active avec des utilisateurs, une cadence et des fenêtres de couverture.", + "oncall_health.issues.rotation_has_no_layers.hint": "Ouvrez les couches de la rotation et créez la première couche.", + "oncall_health.issues.rotation_has_no_enabled_layers.title": "La rotation ne contient aucune couche active", + "oncall_health.issues.rotation_has_no_enabled_layers.message": "Toutes les couches sont désactivées ; la rotation ne peut donc désigner aucun utilisateur d’astreinte.", + "oncall_health.issues.rotation_has_no_enabled_layers.hint": "Activez une couche ou créez une nouvelle couche active.", + "oncall_health.issues.enabled_layer_has_no_members.title": "Une couche active ne contient aucun membre actif", + "oncall_health.issues.enabled_layer_has_no_members.message": "Une couche active ne contient aucun utilisateur actif pendant la fenêtre vérifiée.", + "oncall_health.issues.enabled_layer_has_no_members.hint": "Ajoutez des utilisateurs actifs de l’équipe à cette couche ou désactivez-la.", + "oncall_health.issues.rotation_has_no_active_members.title": "La rotation ne contient aucun membre actif", + "oncall_health.issues.rotation_has_no_active_members.message": "La rotation active ne contient aucun utilisateur actif dans ses couches actives.", + "oncall_health.issues.rotation_has_no_active_members.hint": "Ajoutez des utilisateurs actifs à une couche active.", + "oncall_health.issues.rotation_has_single_active_member.title": "La rotation ne contient qu’un seul membre actif", + "oncall_health.issues.rotation_has_single_active_member.message": "Un seul utilisateur actif peut être sélectionné parmi les couches actives.", + "oncall_health.issues.rotation_has_single_active_member.hint": "Ajoutez au moins un utilisateur afin de réduire le risque lié à une astreinte reposant sur une seule personne.", + "oncall_health.issues.layer_has_inactive_members.title": "La couche contient des utilisateurs inactifs", + "oncall_health.issues.layer_has_inactive_members.message": "Des utilisateurs inactifs sont encore présents dans des couches actives.", + "oncall_health.issues.layer_has_inactive_members.hint": "Retirez les utilisateurs inactifs de la couche ou réactivez-les.", + "oncall_health.issues.no_current_oncall.title": "Aucun utilisateur d’astreinte actuellement", + "oncall_health.issues.no_current_oncall.message": "La rotation ne peut pas déterminer de responsable pour le moment.", + "oncall_health.issues.no_current_oncall.hint": "Vérifiez les couches actives, les restrictions, la date de début et les membres.", + "oncall_health.issues.current_oncall_user_inactive.title": "L’utilisateur d’astreinte actuel est inactif", + "oncall_health.issues.current_oncall_user_inactive.message": "L’utilisateur actuellement sélectionné est inactif ou supprimé.", + "oncall_health.issues.current_oncall_user_inactive.hint": "Retirez les utilisateurs inactifs des couches actives ou réactivez l’utilisateur.", + "oncall_health.issues.current_oncall_user_has_no_deliverable_notification.title": "L’utilisateur d’astreinte actuel ne possède aucun contact direct de notification", + "oncall_health.issues.current_oncall_user_has_no_deliverable_notification.message": "L’utilisateur d’astreinte actuel ne possède ni e-mail, ni téléphone, ni identifiant Telegram, Slack ou Mattermost.", + "oncall_health.issues.current_oncall_user_has_no_deliverable_notification.hint": "Demandez à l’utilisateur de compléter les coordonnées de son profil ou d’activer les règles de notification du profil.", + "oncall_health.issues.member_has_no_deliverable_notification.title": "Un membre de la couche ne possède aucun contact direct de notification", + "oncall_health.issues.member_has_no_deliverable_notification.message": "Un membre de la couche ne possède ni e-mail, ni téléphone, ni identifiant Telegram, Slack ou Mattermost.", + "oncall_health.issues.member_has_no_deliverable_notification.hint": "Demandez à l’utilisateur de compléter les coordonnées de son profil ou configurez les règles de notification du profil.", + "oncall_health.issues.schedule_gap.title": "Interruption détectée dans le planning", + "oncall_health.issues.schedule_gap.message": "Aucun utilisateur d’astreinte actif ne peut être déterminé pendant une partie de la fenêtre vérifiée.", + "oncall_health.issues.schedule_gap.hint": "Vérifiez les restrictions des couches, les utilisateurs et les fenêtres de remplacement.", + "oncall_health.issues.schedule_gap_output_truncated.title": "D’autres interruptions du planning ont été détectées", + "oncall_health.issues.schedule_gap_output_truncated.message": "Seules les premières interruptions sont affichées. Corrigez-les, puis relancez le diagnostic.", + "oncall_health.issues.schedule_gap_output_truncated.hint": "Réduisez les interruptions du planning ou exécutez le diagnostic sur une fenêtre plus courte.", + "oncall_health.issues.team_disabled.title": "L’équipe est désactivée", + "oncall_health.issues.team_disabled.message": "Les équipes désactivées ne peuvent pas recevoir de routage d’alerte actif.", + "oncall_health.issues.team_disabled.hint": "Activez l’équipe lorsqu’elle doit recevoir des alertes.", + "oncall_health.issues.team_has_no_active_rotation.title": "L’équipe ne possède aucune rotation active", + "oncall_health.issues.team_has_no_active_rotation.message": "Les alertes routées vers cette équipe ne peuvent pas déterminer un responsable de rotation, sauf si une autre cible explicite est configurée.", + "oncall_health.issues.team_has_no_active_rotation.hint": "Créez et activez au moins une rotation pour cette équipe.", + "oncall_health.issues.team_has_no_enabled_channels.title": "L’équipe ne possède aucun canal de notification actif", + "oncall_health.issues.team_has_no_enabled_channels.message": "Les routes actives peuvent recevoir des alertes, mais aucun canal de notification de l’équipe n’est activé.", + "oncall_health.issues.team_has_no_enabled_channels.hint": "Activez au moins un canal ou choisissez volontairement de vous appuyer sur les notifications du profil.", + "oncall_health.issues.route_has_no_assignment_target.title": "Une route active ne possède aucune cible d’affectation", + "oncall_health.issues.route_has_no_assignment_target.message": "Une route active n’est associée ni à une rotation ni à une politique d’escalade.", + "oncall_health.issues.route_has_no_assignment_target.hint": "Associez une rotation ou une politique d’escalade capable de déterminer un responsable.", + "oncall_health.issues.route_has_no_channels.title": "Une route active ne possède aucun canal associé", + "oncall_health.issues.route_has_no_channels.message": "Aucun canal n’est associé à une route active.", + "oncall_health.issues.route_has_no_channels.hint": "Associez au moins un canal à la route ou choisissez volontairement de vous appuyer sur les notifications du profil.", + "oncall_health.issues.team_rotation_has_critical_health.title": "La rotation présente des problèmes de santé critiques", + "oncall_health.issues.team_rotation_has_critical_health.message": "Une rotation de l’équipe présente des problèmes de santé critiques.", + "oncall_health.issues.team_rotation_has_critical_health.hint": "Ouvrez le diagnostic de santé de la rotation pour plus de détails.", + "oncall_health.issues.team_rotation_has_warnings.title": "La rotation présente des avertissements de santé", + "oncall_health.issues.team_rotation_has_warnings.message": "Une rotation de l’équipe présente des avertissements de santé.", + "oncall_health.issues.team_rotation_has_warnings.hint": "Ouvrez le diagnostic de santé de la rotation pour plus de détails.", + "oncall_health.indicator.ok": "OK · cliquez pour ouvrir le diagnostic" +} diff --git a/app/static/i18n/fr/orchestrations.json b/app/static/i18n/fr/orchestrations.json new file mode 100644 index 0000000..d0c2362 --- /dev/null +++ b/app/static/i18n/fr/orchestrations.json @@ -0,0 +1,137 @@ +{ + "nav.event_orchestration": "Orchestration des événements", + "pages.orchestrations.title": "Orchestration des événements", + "pages.orchestrations.subtitle": "Acheminez, enrichissez, supprimez et automatisez les événements entrants", + "orchestrations.summary.total": "Orchestrations", + "orchestrations.summary.total_hint": "Définitions du groupe sélectionné", + "orchestrations.summary.active": "Actives", + "orchestrations.summary.active_hint": "Appliquées aux événements de production", + "orchestrations.summary.shadow": "Mode simulation", + "orchestrations.summary.shadow_hint": "Évaluées sans modifier le comportement", + "orchestrations.summary.drafts": "Brouillons", + "orchestrations.summary.drafts_hint": "Versions de travail non publiées", + "orchestrations.list.title": "Orchestrations d’événements", + "orchestrations.list.items": "orchestrations", + "orchestrations.actions.create": "Nouvelle orchestration", + "orchestrations.actions.reload": "Recharger", + "orchestrations.actions.open": "Ouvrir", + "orchestrations.actions.back": "Retour", + "orchestrations.actions.save_draft": "Enregistrer le brouillon", + "orchestrations.actions.validate": "Valider", + "orchestrations.actions.publish": "Publier", + "orchestrations.actions.add_rule": "Ajouter une règle", + "orchestrations.actions.json_view": "Vue JSON", + "orchestrations.actions.builder_view": "Éditeur visuel", + "orchestrations.actions.apply_json": "Appliquer le JSON", + "orchestrations.actions.format_json": "Formater le JSON", + "orchestrations.actions.run_simulation": "Lancer la simulation", + "orchestrations.actions.view": "Afficher", + "orchestrations.actions.rollback": "Restaurer", + "orchestrations.actions.trace": "Trace", + "orchestrations.actions.add_webhook": "Ajouter un webhook", + "orchestrations.actions.save": "Enregistrer", + "orchestrations.actions.save_runtime": "Enregistrer le mode d’exécution", + "orchestrations.actions.delete": "Supprimer", + "orchestrations.actions.edit": "Modifier", + "orchestrations.actions.duplicate": "Dupliquer", + "orchestrations.actions.save_rule": "Enregistrer la règle", + "orchestrations.search.placeholder": "Rechercher des orchestrations", + "orchestrations.filters.all_modes": "Tous les modes", + "orchestrations.filters.all_scopes": "Toutes les portées", + "orchestrations.mode.active": "Actif", + "orchestrations.mode.shadow": "Simulation", + "orchestrations.mode.disabled": "Désactivé", + "orchestrations.scope.global": "Globale", + "orchestrations.scope.service": "Service", + "orchestrations.table.name": "Nom", + "orchestrations.table.scope": "Portée", + "orchestrations.table.mode": "Mode", + "orchestrations.table.compatibility": "Compatibilité", + "orchestrations.table.version": "Version", + "orchestrations.table.updated": "Mise à jour", + "orchestrations.table.actions": "Actions", + "orchestrations.empty.loading": "Chargement des orchestrations...", + "orchestrations.empty.none": "Aucune orchestration trouvée", + "orchestrations.tabs.rules": "Règles", + "orchestrations.tabs.simulator": "Simulateur", + "orchestrations.tabs.versions": "Versions", + "orchestrations.tabs.executions": "Exécutions", + "orchestrations.tabs.webhooks": "Actions webhook", + "orchestrations.tabs.settings": "Paramètres", + "orchestrations.rules.title": "Règles ordonnées", + "orchestrations.rules.help": "Les règles sont exécutées de haut en bas. Utilisez des groupes imbriqués pour les logiques ET, OU et NON.", + "orchestrations.rules.definition_json": "Définition JSON", + "orchestrations.rules.empty": "Ce brouillon ne contient encore aucune règle.", + "orchestrations.rules.catch_all": "Correspond à tous les événements", + "orchestrations.rules.no_actions": "Aucune action", + "orchestrations.rules.no_draft": "Aucun brouillon", + "orchestrations.simulator.input": "Événement d’entrée", + "orchestrations.simulator.help": "Testez un brouillon sans créer d’alertes ni exécuter de webhooks.", + "orchestrations.simulator.source": "Format d’entrée", + "orchestrations.simulator.normalized": "Événement normalisé", + "orchestrations.simulator.payload": "JSON de l’événement", + "orchestrations.simulator.compare_active": "Comparer avec la version active", + "orchestrations.simulator.result": "Résultat de la simulation", + "orchestrations.simulator.result_help": "Trace déterministe complète des règles et des actions.", + "orchestrations.simulator.no_result": "Lancez une simulation pour afficher le résultat.", + "orchestrations.versions.title": "Historique des versions", + "orchestrations.versions.status": "Statut", + "orchestrations.versions.comment": "Commentaire", + "orchestrations.versions.hash": "Empreinte de la définition", + "orchestrations.versions.published": "Publiée", + "orchestrations.versions.selected_definition": "Définition sélectionnée", + "orchestrations.versions.active_diff": "Différences avec la version active", + "orchestrations.versions.no_diff": "Aucune différence", + "orchestrations.executions.title": "Historique des exécutions", + "orchestrations.executions.source": "Source", + "orchestrations.executions.disposition": "Résultat", + "orchestrations.executions.matches": "Règles correspondantes", + "orchestrations.executions.duration": "Durée", + "orchestrations.executions.created": "Créée", + "orchestrations.webhooks.title": "Actions webhook", + "orchestrations.webhooks.help": "Actions sortantes réutilisables et chiffrées, exécutées de manière asynchrone.", + "orchestrations.webhooks.name": "Nom", + "orchestrations.webhooks.method": "Méthode", + "orchestrations.webhooks.retry": "Nouvelles tentatives", + "orchestrations.webhooks.status": "Statut", + "orchestrations.webhooks.editor_title": "Action webhook", + "orchestrations.webhooks.secret_help": "Les en-têtes sont chiffrés et ne sont jamais renvoyés par l’API.", + "orchestrations.webhooks.headers": "En-têtes secrets au format JSON", + "orchestrations.webhooks.body": "Modèle du corps", + "orchestrations.webhooks.timeout": "Délai d’expiration, en secondes", + "orchestrations.webhooks.delete_title": "Supprimer l’action webhook", + "orchestrations.webhooks.delete_message": "Cette action ne sera plus disponible pour les règles d’orchestration.", + "orchestrations.settings.metadata": "Métadonnées", + "orchestrations.settings.runtime": "Exécution", + "orchestrations.settings.runtime_help": "Une version publiée est nécessaire avant de pouvoir activer le mode actif ou simulation.", + "orchestrations.form.name": "Nom", + "orchestrations.form.description": "Description", + "orchestrations.form.scope": "Portée", + "orchestrations.form.service": "Service", + "orchestrations.form.compatibility": "Mode de compatibilité", + "orchestrations.form.mode": "Mode d’exécution", + "orchestrations.create.title": "Créer une orchestration d’événements", + "orchestrations.create.help": "Créez une orchestration désactivée avec un premier brouillon.", + "orchestrations.rule_editor.title": "Éditeur de règle", + "orchestrations.rule_editor.help": "Construisez les conditions et les actions sans écrire de JSON.", + "orchestrations.rule_editor.processing_mode": "Après correspondance", + "orchestrations.rule_editor.enabled": "Activée", + "orchestrations.rule_editor.disabled": "Désactivée", + "orchestrations.rule_editor.conditions": "Conditions", + "orchestrations.rule_editor.condition": "Condition", + "orchestrations.rule_editor.group": "Groupe", + "orchestrations.rule_editor.actions": "Actions", + "orchestrations.rule_editor.action": "Action", + "orchestrations.validation.valid": "Le brouillon est valide.", + "orchestrations.publish.title": "Publier le brouillon", + "orchestrations.publish.message": "Publier cette version immuable et l’activer ? Les règles générales qui abandonnent les événements nécessitent une confirmation explicite.", + "orchestrations.rollback.title": "Restaurer une version", + "orchestrations.rollback.message": "Publier une nouvelle copie immuable de cette version historique ?", + "orchestrations.delete.title": "Supprimer l’orchestration", + "orchestrations.delete.message": "Désactiver et archiver cette orchestration ?", + "orchestrations.errors.invalid_json": "JSON non valide", + "orchestrations.errors.rules_required": "La définition doit contenir un tableau de règles", + "orchestrations.webhooks.private_network_policy": "Politique d’accès au réseau privé", + "orchestrations.edit.title": "Modifier l’orchestration d’événements", + "orchestrations.edit.help": "Mettez à jour les métadonnées, la portée et les paramètres d’exécution." +} diff --git a/app/static/i18n/fr/overview.json b/app/static/i18n/fr/overview.json new file mode 100644 index 0000000..fc848ef --- /dev/null +++ b/app/static/i18n/fr/overview.json @@ -0,0 +1,62 @@ +{ + "overview.active.title": "Incidents actifs", + "overview.active.subtitle": "Dernières alertes déclenchées et acquittées", + "overview.actions.view_all": "Tout afficher", + "overview.actions.reload": "Recharger", + "overview.actions.ack": "Acquitter", + "overview.actions.resolve": "Résoudre", + "overview.table.id": "ID", + "overview.table.alert": "Alerte", + "overview.table.severity": "Gravité", + "overview.table.priority": "Priorité", + "overview.table.status": "Statut", + "overview.table.team": "Équipe", + "overview.table.duration": "Durée", + "overview.table.updated": "Mise à jour", + "overview.table.actions": "Actions", + "overview.system.title": "État du système", + "overview.system.subtitle": "Résumé opérationnel actuel", + "overview.system.waiting": "En attente de données...", + "overview.system.no_alerts": "Aucune alerte dans la sélection actuelle.", + "overview.system.firing": "Alertes déclenchées nécessitant une intervention : {count}", + "overview.system.acknowledged": "Alertes actives acquittées : {count}", + "overview.system.resolved": "Toutes les alertes suivies sont résolues.", + "overview.impact.title": "Services affectés", + "overview.impact.subtitle": "Impact sur les services dû aux alertes et aux dépendances", + "overview.impact.none": "Aucun service affecté", + "overview.impact.more": "Autres services affectés : {count}", + "overview.impact.service_number": "Service n° {id}", + "overview.impact.open": "Ouvertes : {count}", + "overview.impact.critical": "Critiques : {count}", + "overview.impact.root_cause": "Cause racine : {root} · {path}", + "overview.impact.caused_by_alerts": "Causé par des alertes ouvertes", + "overview.impact.status.major_outage": "Panne majeure", + "overview.impact.status.partial_outage": "Panne partielle", + "overview.impact.status.degraded": "Dégradé", + "overview.impact.status.maintenance": "Maintenance", + "overview.impact.status.operational": "Opérationnel", + "overview.impact.status.disabled": "Désactivé", + "overview.impact.status.unknown": "Inconnu", + "overview.recent.title": "Alertes récentes", + "overview.recent.subtitle": "Dernière activité", + "overview.teams.title": "Équipes actuellement impliquées", + "overview.teams.subtitle": "D’après les alertes actives", + "overview.teams.active_alerts": "Alertes actives", + "overview.charts.severity_title": "Répartition par gravité", + "overview.charts.severity_subtitle": "Répartition actuelle des alertes", + "overview.charts.priority_title": "Répartition par priorité", + "overview.charts.priority_subtitle": "Répartition actuelle des priorités d’incident", + "overview.charts.team_title": "Résumé par équipe", + "overview.charts.team_subtitle": "Alertes par équipe", + "overview.empty.no_active_incidents": "Aucun incident actif", + "overview.empty.no_alerts": "Aucune alerte pour le moment", + "overview.empty.no_teams": "Aucune équipe avec des incidents actifs", + "overview.empty.no_data": "Aucune donnée", + "overview.labels.unknown_team": "Équipe inconnue", + "overview.labels.unknown": "Inconnu", + "overview.alert.show_details": "Afficher les détails de l’alerte", + "overview.alert.fallback": "Alerte", + "overview.escalation.rule": " · règle n° {position}", + "overview.escalation.policy": "Politique : {name}{rule}", + "overview.escalation.rotation": "Rotation : {name}" +} diff --git a/app/static/i18n/fr/priority_policies.json b/app/static/i18n/fr/priority_policies.json new file mode 100644 index 0000000..b6d02fb --- /dev/null +++ b/app/static/i18n/fr/priority_policies.json @@ -0,0 +1,160 @@ +{ + "priority_policies.summary.policies": "Politiques", + "priority_policies.summary.policies_hint": "Politiques automatiques de priorité des incidents", + "priority_policies.summary.enabled": "Activées", + "priority_policies.summary.enabled_hint": "Disponibles pour les services et les équipes", + "priority_policies.summary.defaults": "Valeurs par défaut des équipes", + "priority_policies.summary.defaults_hint": "Politiques par défaut des équipes", + "priority_policies.summary.services": "Services", + "priority_policies.summary.services_hint": "Services utilisant des politiques de remplacement", + "priority_policies.list.title": "Politiques de priorité", + "priority_policies.list.showing": "Affichage", + "priority_policies.list.of": "sur", + "priority_policies.list.policies": "politiques", + "priority_policies.actions.new": "Nouvelle politique", + "priority_policies.actions.reload": "Recharger", + "priority_policies.actions.edit": "Modifier", + "priority_policies.actions.rules": "Règles", + "priority_policies.actions.enable": "Activer", + "priority_policies.actions.disable": "Désactiver", + "priority_policies.actions.remove": "Supprimer", + "priority_policies.actions.edit_policy": "Modifier la politique", + "priority_policies.actions.manage_rules": "Gérer les règles", + "priority_policies.actions.enable_policy": "Activer la politique", + "priority_policies.actions.disable_policy": "Désactiver la politique", + "priority_policies.actions.remove_policy": "Supprimer la politique", + "priority_policies.actions.reset": "Réinitialiser", + "priority_policies.actions.save_policy": "Enregistrer la politique", + "priority_policies.actions.add_rule": "Ajouter une règle", + "priority_policies.actions.close": "Fermer", + "priority_policies.actions.collapse": "Réduire", + "priority_policies.actions.delete": "Supprimer", + "priority_policies.actions.save_rule": "Enregistrer la règle", + "priority_policies.actions.delete_rule": "Supprimer la règle", + "priority_policies.search.placeholder": "Rechercher des politiques de priorité...", + "priority_policies.filters.all_statuses": "Tous les statuts", + "priority_policies.filters.enabled": "Activées", + "priority_policies.filters.disabled": "Désactivées", + "priority_policies.filters.defaults": "Valeurs par défaut des équipes", + "priority_policies.filters.assigned": "Attribuées à des services", + "priority_policies.table.policy": "Politique", + "priority_policies.table.team": "Équipe", + "priority_policies.table.mode": "Mode", + "priority_policies.table.rules": "Règles", + "priority_policies.table.services": "Services", + "priority_policies.table.status": "Statut", + "priority_policies.table.actions": "Actions", + "priority_policies.empty.loaded": "Aucune politique de priorité chargée", + "priority_policies.empty.found": "Aucune politique de priorité", + "priority_policies.details.title": "Détails de la politique", + "priority_policies.details.select": "Sélectionner une politique", + "priority_policies.details.select_help": "Cliquez sur le nom d’une politique pour examiner sa configuration.", + "priority_policies.details.name": "Nom", + "priority_policies.details.team": "Équipe", + "priority_policies.details.description": "Description", + "priority_policies.details.team_default": "Politique par défaut de l’équipe", + "priority_policies.details.update_mode": "Mode de mise à jour", + "priority_policies.details.source_priority": "Priorité de la source", + "priority_policies.details.fallback": "Valeur de repli", + "priority_policies.details.rules": "Règles", + "priority_policies.details.services": "Services", + "priority_policies.details.status": "Statut", + "priority_policies.form.create": "Créer une politique de priorité", + "priority_policies.form.edit": "Modifier la politique de priorité n° {id}", + "priority_policies.form.subtitle": "Définissez comment les alertes entrantes sélectionnent et mettent à jour la priorité de l’incident.", + "priority_policies.form.policy": "Politique", + "priority_policies.form.policy_help": "Équipe, nom, statut et attribution.", + "priority_policies.form.team": "Équipe", + "priority_policies.form.name": "Nom", + "priority_policies.form.name_placeholder": "Priorité de production", + "priority_policies.form.description": "Description", + "priority_policies.form.description_placeholder": "Règles automatiques de priorité pour les incidents de production", + "priority_policies.form.enabled": "Activée", + "priority_policies.form.default_for_team": "Politique par défaut de l’équipe", + "priority_policies.form.default_help": "Une équipe peut posséder une seule politique par défaut active. Les services peuvent la remplacer par une autre politique.", + "priority_policies.form.resolution": "Comportement de résolution", + "priority_policies.form.resolution_help": "Priorité de la source, mises à jour de l’incident et valeur de repli.", + "priority_policies.form.update_mode": "Mode de mise à jour", + "priority_policies.form.source_priority": "Priorité de la source", + "priority_policies.form.fallback": "Valeur de repli", + "priority_policies.form.fallback_priority": "Priorité de repli", + "priority_policies.form.select_priority": "Sélectionner une priorité", + "priority_policies.mode.raise_only": "Augmenter uniquement", + "priority_policies.mode.recalculate": "Recalculer à partir des alertes actives", + "priority_policies.mode.recalculate_short": "Recalculer", + "priority_policies.mode.initial_only": "Priorité initiale uniquement", + "priority_policies.mode.initial_only_short": "Initiale uniquement", + "priority_policies.source.ignore": "Ignorer la priorité de la source", + "priority_policies.source.prefer": "Privilégier la priorité explicite de la source", + "priority_policies.source.prefer_short": "Privilégier la source", + "priority_policies.fallback.severity_mapping": "Associer la gravité de l’alerte", + "priority_policies.fallback.severity_mapping_short": "Association de gravité", + "priority_policies.fallback.fixed_priority": "Utiliser une priorité fixe", + "priority_policies.fallback.fixed_priority_short": "Priorité fixe", + "priority_policies.hints.raise_only": "Les nouvelles alertes peuvent augmenter la priorité de l’incident, mais le traitement automatique ne la réduira pas.", + "priority_policies.hints.recalculate": "La priorité de l’incident est recalculée à partir de toutes les alertes enfants actives et peut augmenter ou diminuer.", + "priority_policies.hints.initial_only": "La politique sélectionne la priorité uniquement lors de la création de l’incident.", + "priority_policies.hints.source_ignore": "Les règles de la politique sont évaluées avant la valeur de repli. La priorité explicite fournie par la source est ignorée.", + "priority_policies.hints.source_prefer": "Une priorité explicite et valide fournie par la source est sélectionnée avant les règles de la politique.", + "priority_policies.hints.fallback_severity": "Lorsqu’aucune règle ne correspond, la gravité de l’alerte est associée aux priorités d’incident configurées.", + "priority_policies.hints.fallback_fixed": "Lorsqu’aucune règle ne correspond, la priorité fixe sélectionnée est utilisée.", + "priority_policies.status.enabled": "Activée", + "priority_policies.status.disabled": "Désactivée", + "priority_policies.status.team_default": "Par défaut pour l’équipe", + "priority_policies.values.yes": "Oui", + "priority_policies.values.no": "Non", + "priority_policies.row.fallback": "Politique n° {id}", + "priority_policies.rules.title": "Règles de la politique de priorité", + "priority_policies.rules.title_named": "Règles de la politique de priorité : {name}", + "priority_policies.rules.subtitle": "Les règles sont évaluées à partir de la position la plus basse.", + "priority_policies.rules.help": "Les règles sont évaluées selon leur position. La première règle active qui correspond sélectionne la priorité de l’incident.", + "priority_policies.rules.none_selected": "Aucune politique sélectionnée", + "priority_policies.rules.loading": "Chargement des règles...", + "priority_policies.rules.default_name": "Règle {position}", + "priority_policies.rules.no_priority": "Aucune priorité", + "priority_policies.rules.all_alerts": "Toutes les alertes", + "priority_policies.rules.none": "Aucune règle. Ajoutez la première règle pour attribuer une priorité à l’incident.", + "priority_policies.rules.unsaved": "non enregistrée", + "priority_policies.rules.priority": "Priorité", + "priority_policies.rules.matchers": "Critères de correspondance", + "priority_policies.rules.status": "Statut", + "priority_policies.rules.incident_priority": "Priorité de l’incident", + "priority_policies.rules.matcher_preset": "Préréglage de correspondance", + "priority_policies.rules.no_preset": "Aucun préréglage", + "priority_policies.rules.disabled_suffix": "Désactivée", + "priority_policies.rules.additional_matchers": "Critères supplémentaires", + "priority_policies.rules.additional_help": "Utilisez {} pour vous appuyer uniquement sur le préréglage sélectionné. Le préréglage et les critères supplémentaires utilisent ET.", + "priority_policies.rules.settings": "Paramètres de la règle", + "priority_policies.rules.settings_help": "La première règle active qui correspond sélectionne sa priorité d’incident.", + "priority_policies.rules.name": "Nom", + "priority_policies.rules.position": "Position", + "priority_policies.rules.enabled": "Activée", + "priority_policies.rules.description": "Description", + "priority_policies.preset.none_hint": "Aucun préréglage sélectionné. Seuls les critères supplémentaires ci-dessous seront évalués.", + "priority_policies.preset.disabled_hint": "Ce préréglage est désactivé. La règle ne correspondra pas tant qu’il ne sera pas activé.", + "priority_policies.preset.active_hint": "Le préréglage « {name} » v{version} et les critères supplémentaires doivent tous correspondre.", + "priority_policies.validation.name_required": "Le nom de la politique de priorité est requis.", + "priority_policies.validation.default_enabled": "La politique de priorité par défaut doit être activée.", + "priority_policies.validation.fallback_required": "Sélectionnez une priorité de repli.", + "priority_policies.validation.rule_name_required": "Le nom de la règle est requis.", + "priority_policies.validation.priority_required": "Sélectionnez une priorité d’incident.", + "priority_policies.validation.policy_first": "Sélectionnez d’abord une politique de priorité.", + "priority_policies.permissions.create": "Un rôle avec droit d’écriture est requis pour créer des politiques de priorité.", + "priority_policies.permissions.edit": "Vous n’êtes pas autorisé à modifier cette politique.", + "priority_policies.permissions.update": "Vous n’êtes pas autorisé à mettre à jour cette politique.", + "priority_policies.permissions.remove": "Vous n’êtes pas autorisé à supprimer cette politique.", + "priority_policies.permissions.manage_rules": "Vous n’êtes pas autorisé à gérer les règles de cette politique.", + "priority_policies.permissions.manager_edit": "Le rôle de responsable d’équipe est requis pour modifier cette politique.", + "priority_policies.permissions.manager_rules": "Le rôle de responsable d’équipe est requis pour gérer les règles de cette politique.", + "priority_policies.permissions.manager_update": "Le rôle de responsable d’équipe est requis pour mettre à jour cette politique.", + "priority_policies.permissions.delete": "L’autorisation de suppression est requise pour supprimer cette politique.", + "priority_policies.errors.not_found": "Politique de priorité introuvable.", + "priority_policies.errors.access_denied": "Accès refusé", + "priority_policies.confirm.remove_title": "Supprimer cette politique de priorité ?", + "priority_policies.confirm.remove_message": "Supprimer la politique « {name} » ? Une politique attribuée à un service ne peut pas être supprimée.", + "priority_policies.confirm.remove": "Supprimer la politique", + "priority_policies.confirm.delete_rule_title": "Supprimer cette règle de priorité ?", + "priority_policies.confirm.delete_rule_message": "Supprimer la règle n° {id} de la politique de priorité ?", + "priority_policies.confirm.delete_rule": "Supprimer la règle", + "common.close": "Fermer" +} diff --git a/app/static/i18n/fr/profile.json b/app/static/i18n/fr/profile.json new file mode 100644 index 0000000..14369cd --- /dev/null +++ b/app/static/i18n/fr/profile.json @@ -0,0 +1,230 @@ +{ + "profile.title": "Profil", + "profile.loading": "Chargement du profil...", + "profile.actions.save": "Enregistrer le profil", + "profile.tabs.oncall": "Astreinte", + "profile.tabs.notifications": "Notifications", + "profile.tabs.tokens": "Jetons API", + "profile.personal.title": "Informations personnelles", + "profile.personal.subtitle": "Coordonnées utilisées par les canaux de notification.", + "profile.interface.title": "Interface", + "profile.interface.subtitle": "Langue, apparence et paramètres d’heure locale.", + "profile.fields.language": "Langue", + "profile.fields.language_help": "La langue de l’interface change après l’enregistrement du profil.", + "profile.fields.theme": "Thème", + "profile.fields.theme_help": "Le réglage système suit le système d’exploitation ou le navigateur.", + "profile.theme.system": "Réglage système", + "profile.theme.light": "Clair", + "profile.theme.dark": "Sombre", + "profile.fields.username": "Nom d’utilisateur", + "profile.fields.display_name": "Nom d’affichage", + "profile.fields.email": "E-mail", + "profile.fields.phone": "Téléphone", + "profile.fields.timezone": "Fuseau horaire", + "profile.fields.timezone_help": "Utilisé pour l’affichage du calendrier. Laissez vide pour utiliser le fuseau horaire du navigateur.", + "profile.messengers.title": "Identifiants de messagerie", + "profile.messengers.subtitle": "Utilisés par les notifications Telegram, Slack et Mattermost.", + "profile.fields.telegram": "ID utilisateur Telegram", + "profile.fields.slack": "ID utilisateur Slack", + "profile.fields.mattermost": "ID utilisateur Mattermost", + "profile.access.title": "Contexte d’accès", + "profile.access.subtitle": "Portée de groupe actuelle pour les vues en liste.", + "profile.access.active_group": "Groupe actif", + "profile.access.set_group": "Définir le groupe actif", + "profile.access.behavior": "Comportement de la portée", + "profile.access.behavior_help": "Sélectionnez « Tous mes groupes » pour afficher tout ce à quoi vous avez accès.", + "profile.password.label": "Mot de passe", + "profile.password.subtitle": "Modifiez le mot de passe de votre compte local.", + "profile.password.change": "Modifier le mot de passe", + "profile.oncall.title": "Statut d’astreinte", + "profile.oncall.subtitle": "Astreintes principales des rotations et participation comme relais d’escalade.", + "profile.shift_notifications.title": "Notifications d’astreinte", + "profile.shift_notifications.subtitle": "Choisissez les notifications d’astreinte à envoyer par e-mail ou Mattermost.", + "profile.shift_notifications.email_start": "M’envoyer un e-mail au début de mon astreinte", + "profile.shift_notifications.email_start_help": "Envoyer un e-mail lorsque je deviens l’astreinte principale.", + "profile.shift_notifications.email_end": "M’envoyer un e-mail à la fin de mon astreinte", + "profile.shift_notifications.email_end_help": "Envoyer un e-mail à la fin de mon astreinte principale.", + "profile.shift_notifications.mm_start": "M’envoyer un message direct Mattermost au début de mon astreinte", + "profile.shift_notifications.mm_start_help": "Nécessite un ID utilisateur Mattermost dans votre profil et un bot Mattermost configuré.", + "profile.shift_notifications.backup": "Relais d’escalade", + "profile.shift_notifications.backup_help": "La participation comme relais d’escalade est affichée dans l’interface, mais n’envoie pas d’e-mails de début ou de fin d’astreinte.", + "profile.push.title": "Notifications push du navigateur", + "profile.push.subtitle": "Recevez les notifications d’alerte directement dans ce navigateur, avec les actions Acquitter et Résoudre.", + "profile.push.device_name": "Nom de l’appareil", + "profile.push.enable": "Activer les notifications push sur cet appareil", + "profile.push.test": "Envoyer une notification push de test", + "profile.push.reload": "Recharger les appareils", + "profile.push.device": "Appareil", + "profile.common.status": "Statut", + "profile.push.last_seen": "Dernière activité", + "profile.common.created": "Créé", + "profile.common.actions": "Actions", + "profile.push.loading": "Chargement des appareils...", + "profile.rules.title": "Règles de notification", + "profile.rules.subtitle": "Configurez la façon dont IncidentRelay doit vous notifier lorsqu’une alerte vous est attribuée.", + "profile.rules.new": "Nouvelle règle", + "profile.actions.reload": "Recharger", + "profile.rules.order": "Ordre", + "profile.rules.method": "Méthode", + "profile.rules.delay": "Délai", + "profile.rules.severities": "Niveaux de gravité", + "profile.rules.event_types": "Types d’événement", + "profile.rules.loading": "Chargement des règles de notification...", + "profile.rules.create": "Créer une règle de notification", + "profile.rules.modal_subtitle": "Règle de notification personnelle pour les alertes attribuées.", + "profile.rules.rule": "Règle", + "profile.rules.rule_help": "Méthode de livraison, délai et état d’activation.", + "profile.rules.browser_push": "Notification push du navigateur", + "profile.rules.voice_call": "Appel vocal", + "profile.rules.immediately": "Immédiatement", + "profile.rules.after_1m": "Après 1 minute", + "profile.rules.after_2m": "Après 2 minutes", + "profile.rules.after_5m": "Après 5 minutes", + "profile.rules.after_10m": "Après 10 minutes", + "profile.rules.after_15m": "Après 15 minutes", + "profile.rules.after_30m": "Après 30 minutes", + "profile.common.enabled": "Activé", + "profile.rules.disabled_help": "Les règles désactivées restent visibles, mais ne sont pas utilisées pour la livraison des alertes.", + "profile.rules.filters": "Filtres", + "profile.rules.filters_help": "Filtres facultatifs de gravité et d’événement.", + "profile.severity.critical": "Critique", + "profile.severity.warning": "Avertissement", + "profile.severity.info": "Information", + "profile.common.unknown": "Inconnu", + "profile.rules.all_severities_help": "Laissez vide pour faire correspondre tous les niveaux de gravité.", + "profile.events.notification": "Alerte initiale", + "profile.events.reminder": "Rappel", + "profile.events.escalation": "Escalade", + "profile.events.acknowledged": "Acquittée", + "profile.events.resolved": "Résolue", + "profile.rules.default_events_help": "Laissez vide pour inclure l’alerte initiale, les rappels et les escalades.", + "profile.actions.cancel": "Annuler", + "profile.rules.save": "Enregistrer la règle", + "profile.tokens.title": "Jetons API personnels", + "profile.tokens.subtitle": "Jetons destinés aux scripts, aux intégrations et à l’accès API.", + "profile.tokens.generate": "Générer un jeton", + "profile.common.name": "Nom", + "profile.tokens.prefix": "Préfixe", + "profile.common.group": "Groupe", + "profile.tokens.scopes": "Portées", + "profile.tokens.expires": "Expiration", + "profile.tokens.last_used": "Dernière utilisation", + "profile.tokens.loading": "Chargement des jetons...", + "profile.caldav.title": "Synchronisation du calendrier CalDAV", + "profile.caldav.subtitle": "Connectez votre calendrier d’astreinte à Apple Calendar, Thunderbird, DAVx5 ou à un autre client compatible CalDAV.", + "profile.caldav.url": "URL CalDAV", + "profile.caldav.url_help": "Utilisez cette URL comme adresse du serveur dans votre client de calendrier.", + "profile.caldav.username_help": "Utilisez votre nom d’utilisateur ou votre adresse e-mail IncidentRelay.", + "profile.caldav.password_prefix": "Utilisez un jeton API personnel doté de la portée", + "profile.caldav.password_suffix": ". N’utilisez pas votre mot de passe de connexion.", + "profile.caldav.copy_url": "Copier l’URL", + "profile.caldav.copy_username": "Copier le nom d’utilisateur", + "profile.caldav.generate_token": "Générer un jeton de calendrier", + "profile.tokens.modal_title": "Générer un jeton API personnel", + "profile.tokens.once": "Le jeton complet n’est affiché qu’une seule fois.", + "profile.tokens.name": "Nom du jeton", + "profile.tokens.days": "Expiration dans, en jours", + "profile.tokens.group_help": "Laissez vide pour créer un jeton sans limite de groupe.", + "profile.tokens.generated": "Jeton généré", + "profile.tokens.token": "Jeton", + "profile.tokens.copy": "Copier le jeton", + "profile.tokens.none_generated": "Aucun jeton généré pour le moment.", + "profile.tokens.copy_now": "Copiez-le maintenant. Vous ne pourrez plus l’afficher.", + "profile.actions.close": "Fermer", + "profile.password.modal_subtitle": "Mettez à jour le mot de passe de votre compte local.", + "profile.password.old": "Ancien mot de passe", + "profile.password.new": "Nouveau mot de passe", + "profile.header.no_contact": "Aucune coordonnée", + "profile.groups.none": "Aucun groupe", + "profile.groups.fallback": "Groupe n° {id}", + "profile.groups.no_limit": "Aucune limite de groupe", + "profile.groups.all": "Tous mes groupes", + "profile.status.saving": "Enregistrement...", + "profile.status.saved": "Enregistré", + "profile.status.save_notifications_failed": "Échec de l’enregistrement des préférences de notification.", + "profile.tokens.none": "Aucun jeton API personnel", + "profile.tokens.never": "Jamais", + "profile.tokens.expired": "Expiré", + "profile.tokens.active": "Actif", + "profile.tokens.revoked": "Révoqué", + "profile.tokens.revoke": "Révoquer", + "profile.tokens.revoke_title": "Révoquer ce jeton ?", + "profile.tokens.revoke_message": "Révoquer le jeton « {name} » ?", + "profile.tokens.days_negative": "Le nombre de jours avant expiration ne peut pas être négatif.", + "profile.tokens.generated_status": "Jeton généré", + "profile.tokens.copied": "Jeton copié", + "profile.password.required": "L’ancien et le nouveau mot de passe sont requis.", + "profile.password.changed": "Mot de passe modifié", + "profile.access.updating": "Mise à jour...", + "profile.access.updated": "Groupe actif mis à jour", + "profile.oncall.team": "Équipe", + "profile.oncall.rotation": "Rotation n° {id}", + "profile.oncall.override": "Remplacement", + "profile.oncall.layer": "Couche", + "profile.oncall.shown_in": "Affiché dans le fuseau {timezone}", + "profile.oncall.source": "Source : {timezone}", + "profile.oncall.now": "Vous êtes actuellement d’astreinte", + "profile.oncall.not_now": "Vous n’êtes pas d’astreinte actuellement", + "profile.oncall.no_upcoming": "Aucune astreinte prévue dans les {days} prochains jours.", + "profile.oncall.loading": "Chargement du statut d’astreinte...", + "profile.copy.nothing": "Rien à copier.", + "profile.caldav.scope_missing": "La portée calendar:read n’est pas disponible. Ajoutez-la d’abord à la liste des portées du jeton.", + "profile.caldav.token_info": "Le jeton de calendrier sera créé avec la portée calendar:read.", + "profile.caldav.url_copied": "URL CalDAV copiée.", + "profile.caldav.username_copied": "Nom d’utilisateur copié.", + "profile.push.enabling": "Activation...", + "profile.push.sending": "Envoi...", + "profile.push.reloading": "Rechargement...", + "profile.push.action_failed": "Échec de l’action de notification push du navigateur", + "profile.push.not_configured": "Les notifications push du navigateur sont désactivées ou la clé publique VAPID n’est pas configurée.", + "profile.push.permission_denied": "L’autorisation d’afficher des notifications n’a pas été accordée.", + "profile.push.save_failed": "Échec de l’enregistrement de l’abonnement push du navigateur.", + "profile.push.enabled_status": "Notifications push du navigateur activées pour cet appareil.", + "profile.push.test_failed": "Échec de l’envoi de la notification push de test.", + "profile.push.no_devices": "Aucun appareil actif avec notifications push trouvé.", + "profile.push.test_sent": "Notification push de test envoyée à {count} appareil(s).", + "profile.push.load_failed": "Échec du chargement des appareils avec notifications push.", + "profile.push.no_devices_yet": "Aucun appareil avec notifications push pour le moment.", + "profile.push.disabling": "Désactivation...", + "profile.push.disabled_status": "Notifications push désactivées sur l’appareil.", + "profile.push.disable_failed": "Échec de la désactivation des notifications push sur l’appareil.", + "profile.push.config_failed": "Échec du chargement de la configuration push du navigateur.", + "profile.push.no_service_worker": "Les service workers ne sont pas pris en charge par ce navigateur.", + "profile.push.no_push": "Les notifications push ne sont pas prises en charge par ce navigateur.", + "profile.push.no_notifications": "Les notifications ne sont pas prises en charge par ce navigateur.", + "profile.push.browser": "Navigateur", + "profile.push.android": "Appareil Android", + "profile.push.mac": "Navigateur Mac", + "profile.push.windows": "Navigateur Windows", + "profile.push.linux": "Navigateur Linux", + "profile.rules.none": "Aucune règle de notification personnalisée pour le moment. Les notifications push du navigateur continuent de fonctionner automatiquement si elles sont activées dans votre profil.", + "profile.rules.all_severities": "Tous les niveaux de gravité", + "profile.common.disabled": "Désactivé", + "profile.actions.edit": "Modifier", + "profile.actions.enable": "Activer", + "profile.actions.disable": "Désactiver", + "profile.actions.delete": "Supprimer", + "profile.rules.edit": "Modifier la règle de notification n° {id}", + "profile.rules.delete_title": "Supprimer cette règle de notification ?", + "profile.rules.delete_message": "Supprimer la règle de notification n° {id} ?", + "profile.rules.seconds": "{count} s", + "profile.rules.after_minutes": "Après {count} minutes", + "profile.rules.default_events": "Événements par défaut", + "profile.oncall.primary_now": "Astreinte principale actuellement", + "profile.oncall.backup_now": "Relais d’escalade actuellement", + "profile.oncall.not_oncall": "Pas d’astreinte actuellement", + "profile.oncall.policy": "Politique n° {id}", + "profile.oncall.level": "niveau {level}", + "profile.oncall.after_minutes": "après {count} min", + "profile.oncall.direct_target": "Cible utilisateur directe de l’escalade", + "profile.actions.refresh": "Actualiser", + "profile.oncall.current_primary": "Astreintes principales actuelles", + "profile.oncall.no_current_primary": "Vous n’êtes pas l’astreinte principale actuellement.", + "profile.oncall.current_backup": "Relais d’escalade actuel", + "profile.oncall.no_current_backup": "Vous n’êtes pas un relais d’escalade actif actuellement.", + "profile.oncall.next_primary": "Prochaines astreintes principales", + "profile.oncall.no_next_primary": "Aucune astreinte principale à venir dans la fenêtre sélectionnée.", + "profile.oncall.next_backup": "Prochains relais d’escalade", + "profile.oncall.no_next_backup": "Aucun relais d’escalade à venir dans la fenêtre sélectionnée.", + "profile.push.device_placeholder": "Mon ordinateur portable, mon téléphone professionnel..." +} diff --git a/app/static/i18n/fr/rotations.json b/app/static/i18n/fr/rotations.json new file mode 100644 index 0000000..d1ce5db --- /dev/null +++ b/app/static/i18n/fr/rotations.json @@ -0,0 +1,201 @@ +{ + "rotations.summary.total": "Rotations", + "rotations.summary.current_scope": "Portée actuelle", + "rotations.summary.active": "Actives", + "rotations.summary.active_hint": "Rotations activées", + "rotations.summary.oncall": "D’astreinte actuellement", + "rotations.summary.oncall_hint": "Utilisateurs affectés", + "rotations.summary.reminders": "Avec rappels", + "rotations.summary.reminders_hint": "Rappel configuré", + "rotations.management.title": "Gestion des rotations", + "rotations.counter.showing": "Affichage", + "rotations.counter.of": "sur", + "rotations.counter.rotations": "rotations", + "rotations.actions.new": "Nouvelle rotation", + "rotations.actions.reload": "Recharger", + "rotations.actions.edit": "Modifier", + "rotations.actions.layers": "Couches", + "rotations.actions.overrides": "Remplacements", + "rotations.actions.enable": "Activer", + "rotations.actions.disable": "Désactiver", + "rotations.actions.delete": "Supprimer", + "rotations.actions.close": "Fermer", + "rotations.actions.reset": "Réinitialiser", + "rotations.actions.save": "Enregistrer la rotation", + "rotations.search.placeholder": "Rechercher des rotations...", + "rotations.filters.all_statuses": "Tous les statuts", + "rotations.status.active": "Active", + "rotations.status.inactive": "Inactive", + "rotations.status.disabled": "Désactivée", + "rotations.status.scheduled": "Planifiée", + "rotations.status.active_now": "Active actuellement", + "rotations.status.no_active_layer": "Aucune couche active", + "rotations.table.rotation": "Rotation", + "rotations.table.team": "Équipe", + "rotations.table.cadence": "Cadence", + "rotations.table.current_oncall": "Astreinte actuelle", + "rotations.table.handoff": "Passation", + "rotations.table.reminder": "Rappel", + "rotations.table.health": "Santé", + "rotations.table.status": "Statut", + "rotations.table.actions": "Actions", + "rotations.empty.not_loaded": "Aucune rotation chargée", + "rotations.empty.none": "Aucune rotation", + "rotations.empty.none_selected": "Aucune rotation sélectionnée", + "rotations.details.title": "Détails de la rotation", + "rotations.details.select": "Sélectionner une rotation", + "rotations.details.help": "Cliquez sur le nom d’une rotation pour examiner l’utilisateur d’astreinte actuel, la cadence, les rappels et les actions rapides.", + "rotations.details.name": "Nom", + "rotations.details.team": "Équipe", + "rotations.details.current_oncall": "Astreinte actuelle", + "rotations.details.cadence": "Cadence", + "rotations.details.handoff_time": "Heure de passation", + "rotations.details.reminder": "Rappel", + "rotations.details.timezone": "Fuseau horaire", + "rotations.details.description": "Description", + "rotations.details.open_calendar": "Ouvrir le calendrier", + "rotations.form.create_title": "Créer une rotation", + "rotations.form.edit_title": "Modifier la rotation n° {id}", + "rotations.form.subtitle": "Créez ou modifiez un planning de rotation d’astreinte.", + "rotations.form.section_rotation": "Rotation", + "rotations.form.section_rotation_hint": "Équipe, nom et date de début.", + "rotations.form.name": "Nom", + "rotations.form.name_placeholder": "Astreinte infrastructure", + "rotations.form.description": "Description", + "rotations.form.description_placeholder": "Objectif de la rotation ou remarques", + "rotations.form.starts_at": "Début du planning", + "rotations.form.cadence_section": "Cadence", + "rotations.form.cadence_hint": "Planning des passations, intervalle de rappel et fuseau horaire.", + "rotations.form.cadence": "Cadence de la rotation", + "rotations.cadence.daily_handoff": "Passation quotidienne", + "rotations.cadence.weekly_handoff": "Passation hebdomadaire", + "rotations.cadence.custom_interval": "Intervalle personnalisé", + "rotations.cadence.daily": "Quotidienne", + "rotations.cadence.weekly": "Hebdomadaire", + "rotations.cadence.every": "Toutes les {value} {unit}, {timezone}", + "rotations.cadence.weekly_format": "Chaque semaine, {weekday} à {time}, {timezone}", + "rotations.cadence.daily_format": "Chaque jour à {time}, {timezone}", + "rotations.form.weekday": "Jour de passation hebdomadaire", + "rotations.form.handoff_time": "Heure de passation", + "rotations.form.every": "Toutes les", + "rotations.form.unit": "Unité", + "rotations.units.minutes": "Minutes", + "rotations.units.hours": "Heures", + "rotations.units.days": "Jours", + "rotations.units.weeks": "Semaines", + "rotations.units.minutes_lower": "minutes", + "rotations.units.hours_lower": "heures", + "rotations.units.days_lower": "jours", + "rotations.units.weeks_lower": "semaines", + "rotations.form.reminder_interval": "Intervalle de rappel", + "rotations.form.reminder_help": "0 désactive les rappels. Sinon, utilisez au moins 1 minute.", + "rotations.form.reminder_unit": "Unité du rappel", + "rotations.form.timezone": "Fuseau horaire", + "rotations.form.timezone_help": "Utilisé pour l’heure de passation et les restrictions des couches.", + "rotations.form.select_timezone": "Sélectionner un fuseau horaire", + "rotations.weekday.monday": "Lundi", + "rotations.weekday.tuesday": "Mardi", + "rotations.weekday.wednesday": "Mercredi", + "rotations.weekday.thursday": "Jeudi", + "rotations.weekday.friday": "Vendredi", + "rotations.weekday.saturday": "Samedi", + "rotations.weekday.sunday": "Dimanche", + "rotations.weekday.every_day": "Tous les jours", + "rotations.layers.title": "Couches de la rotation", + "rotations.layers.title_named": "Couches de la rotation : {name}", + "rotations.layers.subtitle": "Les couches sont vérifiées de haut en bas. Les couches inférieures ont une priorité plus élevée.", + "rotations.layers.schedule_layers": "Couches du planning", + "rotations.layers.schedule_layers_hint": "Chaque couche possède ses propres utilisateurs, sa cadence et ses fenêtres d’activité.", + "rotations.layers.add": "Ajouter une couche", + "rotations.layers.empty": "Aucune rotation sélectionnée", + "rotations.layers.override_hint": "Les remplacements remplacent toujours le résultat final calculé.", + "rotations.layers.loading": "Chargement des couches...", + "rotations.layers.none": "Aucune couche. Ajoutez la première couche pour définir la couverture d’astreinte.", + "rotations.layers.unnamed": "Couche sans nom", + "rotations.layers.priority": "Priorité {value}", + "rotations.layers.enabled": "Activée", + "rotations.layers.disabled": "Désactivée", + "rotations.layers.collapse": "Réduire", + "rotations.layers.who_rotates": "Personnes en rotation", + "rotations.layers.rotation": "Rotation", + "rotations.layers.active": "Active", + "rotations.layers.no_users": "Aucun utilisateur", + "rotations.layers.more": "+{count} de plus", + "rotations.layers.when_title": "1. Quand la rotation a-t-elle lieu ?", + "rotations.layers.when_hint": "Configurez l’heure de passation, le fuseau horaire et la priorité de cette couche.", + "rotations.layers.description": "Description", + "rotations.layers.starts_at": "Début du planning", + "rotations.layers.cadence": "Cadence", + "rotations.layers.enabled_hint": "La couche participe au planning final", + "rotations.layers.save": "Enregistrer la couche", + "rotations.layers.who_title": "2. Qui participe à la rotation ?", + "rotations.layers.who_hint": "Les utilisateurs tournent selon leur position. Retirer un utilisateur n’affecte que les futures astreintes ; les astreintes passées restent visibles.", + "rotations.layers.select_user": "Sélectionner un utilisateur...", + "rotations.layers.all_users_added": "Tous les utilisateurs actifs sont déjà présents dans cette couche", + "rotations.layers.no_team_members": "Aucun membre actif dans l’équipe", + "rotations.layers.user": "Utilisateur", + "rotations.layers.position": "Position", + "rotations.layers.add_user": "Ajouter un utilisateur", + "rotations.layers.no_active_users": "Aucun utilisateur actif dans cette couche pour le moment.", + "rotations.layers.user_number": "utilisateur n° {id}", + "rotations.layers.member_meta": "ID utilisateur {user_id} · membre n° {member_id}{starts}", + "rotations.layers.starts_suffix": " · commence le {value}", + "rotations.layers.save_position": "Enregistrer la position", + "rotations.layers.remove": "Retirer", + "rotations.layers.active_title": "3. Quand cette couche est-elle active ?", + "rotations.layers.active_hint": "Sans restriction, cette couche est active 24 h/24 et 7 j/7.", + "rotations.layers.business_hours": "Heures ouvrées", + "rotations.layers.nights": "Nuits", + "rotations.layers.weekend": "Week-end", + "rotations.layers.add_window": "+ Ajouter une fenêtre", + "rotations.layers.active_24x7": "Active 24 h/24 et 7 j/7. Ajoutez des fenêtres si cette couche doit fonctionner uniquement certains jours ou à certaines heures.", + "rotations.layers.save_restrictions": "Enregistrer les restrictions", + "rotations.layers.new": "Nouvelle couche", + "rotations.layers.new_number": "Nouvelle couche {number}", + "rotations.layers.delete_title": "Supprimer cette couche ?", + "rotations.layers.delete_confirm": "Supprimer la couche", + "rotations.layers.select_first": "Sélectionnez d’abord une rotation.", + "rotations.layers.select_user_error": "Sélectionnez un utilisateur à ajouter.", + "rotations.layers.remove_user_title": "Retirer cet utilisateur des futures astreintes ?", + "rotations.layers.remove_user_message": "Les astreintes passées resteront visibles. Cet utilisateur ne sera plus affecté aux futures astreintes de cette couche.", + "rotations.layers.remove_user_confirm": "Retirer des futures astreintes", + "rotations.overrides.title": "Remplacements", + "rotations.overrides.title_named": "Remplacements : {name}", + "rotations.overrides.subtitle": "Remplacez temporairement l’utilisateur d’astreinte calculé.", + "rotations.overrides.create_title": "Créer un remplacement", + "rotations.overrides.create_hint": "Choisissez un utilisateur et une fenêtre de remplacement.", + "rotations.overrides.rotation": "Rotation", + "rotations.overrides.user": "Utilisateur", + "rotations.overrides.starts": "Commence le", + "rotations.overrides.ends": "Se termine le", + "rotations.overrides.reason": "Motif", + "rotations.overrides.reason_placeholder": "Congés, arrêt maladie, remplacement manuel...", + "rotations.overrides.create": "Créer le remplacement", + "rotations.overrides.existing": "Remplacements existants", + "rotations.overrides.existing_hint": "Remplacements manuels actifs et planifiés.", + "rotations.overrides.table.starts": "Début", + "rotations.overrides.table.ends": "Fin", + "rotations.overrides.none": "Aucun remplacement", + "rotations.overrides.delete_title": "Supprimer ce remplacement ?", + "rotations.overrides.delete_confirm": "Supprimer le remplacement", + "rotations.errors.not_found": "Rotation introuvable.", + "rotations.confirm.enable_title": "Activer cette rotation ?", + "rotations.confirm.disable_title": "Désactiver cette rotation ?", + "rotations.confirm.enable_message": "Activer cette rotation et lui permettre de participer à la planification des astreintes ?", + "rotations.confirm.disable_message": "Désactiver cette rotation sans supprimer ses couches, ses membres, ses remplacements ni ses associations aux routes ?", + "rotations.confirm.enable": "Activer la rotation", + "rotations.confirm.disable": "Désactiver la rotation", + "rotations.confirm.delete_title": "Supprimer cette rotation ?", + "rotations.confirm.delete_message": "Cette opération supprimera la rotation, ses couches, ses membres et ses remplacements, puis la dissociera des routes d’alerte.", + "rotations.confirm.delete": "Supprimer la rotation", + "rotations.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier cette rotation.", + "rotations.permissions.layers": "Le rôle de responsable d’équipe est requis pour gérer les couches de la rotation.", + "rotations.permissions.overrides": "Le rôle de responsable d’équipe est requis pour gérer les remplacements de la rotation.", + "rotations.permissions.toggle": "Le rôle de responsable d’équipe est requis pour activer ou désactiver cette rotation.", + "rotations.permissions.delete": "L’autorisation de suppression est requise pour supprimer cette rotation.", + "rotations.labels.currently_oncall": "D’astreinte actuellement", + "rotations.labels.rotation_number": "Rotation n° {id}", + "rotations.labels.select_rotation_first": "Sélectionnez d’abord une rotation", + "rotations.labels.user": "utilisateur", + "rotations.form.priority": "Priorité" +} diff --git a/app/static/i18n/fr/routes.json b/app/static/i18n/fr/routes.json new file mode 100644 index 0000000..fa644b0 --- /dev/null +++ b/app/static/i18n/fr/routes.json @@ -0,0 +1,208 @@ +{ + "routes.summary.routes": "Routes", + "routes.summary.routes_hint": "Règles de réception des alertes", + "routes.summary.enabled": "Activées", + "routes.summary.enabled_hint": "Acceptent les alertes", + "routes.summary.disabled": "Désactivées", + "routes.summary.disabled_hint": "N’acceptent pas les alertes", + "routes.summary.escalation": "Avec escalade", + "routes.summary.escalation_hint": "Rotation ou politique", + "routes.list.title": "Routes d’alerte", + "routes.list.showing": "Affichage", + "routes.list.of": "sur", + "routes.list.routes": "routes", + "routes.actions.new": "Nouvelle route", + "routes.actions.reload": "Recharger", + "routes.search.placeholder": "Rechercher des routes...", + "routes.filters.all_sources": "Toutes les sources", + "routes.filters.all_statuses": "Tous les statuts", + "routes.table.route": "Route", + "routes.table.team": "Équipe", + "routes.table.source": "Source", + "routes.table.escalation": "Escalade", + "routes.table.channels": "Canaux", + "routes.table.token": "Jeton", + "routes.table.status": "Statut", + "routes.table.actions": "Actions", + "routes.table.empty_loaded": "Aucune route chargée", + "routes.table.empty": "Aucune route", + "routes.details.title": "Détails de la route", + "routes.details.select": "Sélectionner une route", + "routes.details.empty": "Cliquez sur le nom d’une route pour examiner ses critères, son regroupement, ses canaux et le préfixe de son jeton de réception.", + "routes.form.create": "Créer une route", + "routes.form.edit": "Modifier la route n° {id}", + "routes.form.subtitle": "Faites correspondre les alertes entrantes et transmettez-les aux rotations et aux canaux.", + "routes.form.route_section": "Route", + "routes.form.route_section_hint": "Équipe, source, rotation et canaux de notification.", + "routes.form.team": "Équipe", + "routes.form.name": "Nom", + "routes.form.name_placeholder": "Alertes critiques d’infrastructure", + "routes.form.source": "Source", + "routes.form.sentry_secret": "Secret du webhook Sentry", + "routes.form.sentry_secret_placeholder": "Collez le secret client Sentry", + "routes.form.sentry_secret_new_help": "Créez d’abord la route pour obtenir l’URL du webhook Sentry. Créez ensuite une intégration interne Sentry et collez ici son secret client.", + "routes.form.sentry_secret_configured_help": "Le secret du webhook Sentry est configuré. Laissez le champ vide pour conserver le secret existant.", + "routes.form.sentry_secret_missing_help": "Collez ici le secret client Sentry. Tant qu’il n’est pas configuré, les webhooks Sentry seront rejetés.", + "routes.form.sentry_base_url": "URL de base Sentry", + "routes.form.sentry_base_url_help": "Utilisée comme solution de repli lorsque le payload du webhook ne contient pas d’URL d’événement. Pour une instance Sentry auto-hébergée, utilisez son URL racine.", + "routes.form.sentry_org": "Slug de l’organisation Sentry", + "routes.form.sentry_org_help": "Utilisé avec issue_id pour construire un lien de repli vers l’incident Sentry.", + "routes.form.sns_topic": "ARN du topic AWS SNS", + "routes.form.sns_topic_help": "Seuls les messages SNS signés provenant exactement de ce topic seront acceptés.", + "routes.form.sns_webhook": "URL du webhook AWS SNS", + "routes.form.sns_webhook_help": "Ajoutez ce point de terminaison HTTPS comme abonnement HTTP/S du topic SNS. Aucun jeton de réception de route n’est requis, car les requêtes SNS sont vérifiées à l’aide de leur signature et de l’ARN du topic.", + "routes.form.default_service": "Service par défaut", + "routes.form.no_service": "Aucun service", + "routes.form.no_default_service": "Aucun service par défaut", + "routes.form.escalation_mode": "Mode d’escalade", + "routes.form.simple_rotation": "Rotation simple", + "routes.form.escalation_policy": "Politique d’escalade", + "routes.form.no_policy": "Aucune politique", + "routes.form.rotation": "Rotation", + "routes.form.no_rotation": "Aucune rotation", + "routes.form.channels": "Canaux", + "routes.form.notification_source": "Source des canaux de notification", + "routes.notification.route_only": "Canaux de la route uniquement", + "routes.notification.service_policy": "Politique de notification du service", + "routes.notification.combined": "Politique du service + canaux de la route", + "routes.notification.route_only_help": "Seuls les canaux configurés directement sur cette route sont utilisés.", + "routes.notification.service_policy_help": "Seuls les canaux sélectionnés par la politique de notification du service correspondant reçoivent les notifications partagées.", + "routes.notification.combined_help": "Les canaux sélectionnés par la politique du service sont combinés avec ceux configurés sur cette route.", + "routes.form.enabled": "Activée", + "routes.form.disabled_help": "Les routes désactivées restent visibles, mais cessent d’accepter les alertes entrantes.", + "routes.form.matching": "Correspondance", + "routes.form.matching_hint": "Filtres d’alerte et configuration du regroupement.", + "routes.form.matcher_preset": "Préréglage de correspondance", + "routes.form.no_preset": "Aucun préréglage", + "routes.form.no_preset_hint": "Aucun préréglage sélectionné. Seuls les critères supplémentaires ci-dessous seront évalués.", + "routes.form.additional_matchers_json": "Critères supplémentaires au format JSON", + "routes.form.additional_matchers": "Critères supplémentaires", + "routes.form.matchers": "Critères de correspondance", + "routes.form.group_by_json": "Regroupement au format JSON", + "routes.form.group_by": "Regrouper par", + "routes.form.matchers_help": "Les critères sont des filtres d’objets JSON. Le regroupement est un tableau JSON de noms de libellés utilisés pour agréger les alertes. Laissez-le vide afin que chaque clé de déduplication reste dans son propre groupe.", + "routes.actions.reset": "Réinitialiser", + "routes.actions.save_route": "Enregistrer la route", + "routes.intake.title": "Informations de réception de la route", + "routes.intake.copy_now": "Copiez ces valeurs maintenant. Le jeton pourrait ne plus être affiché.", + "routes.intake.webhook_url": "URL du webhook", + "routes.intake.send_url": "Envoyez les alertes entrantes à cette URL.", + "routes.intake.token": "Jeton", + "routes.intake.token_help": "Utilisez ce jeton comme jeton Bearer dans l’en-tête Authorization.", + "routes.intake.example_curl": "Exemple cURL", + "routes.intake.copy_url": "Copier l’URL", + "routes.intake.copy_token": "Copier le jeton", + "routes.intake.copy_curl": "Copier la commande cURL", + "routes.actions.close": "Fermer", + "routes.rules.title": "Règles de service", + "routes.rules.subtitle": "Associez les alertes de cette route aux services affectés.", + "routes.rules.rules": "Règles", + "routes.rules.rules_hint": "Règles de classification des services propres à la route.", + "routes.rules.position": "Position", + "routes.rules.name": "Nom", + "routes.rules.service": "Service", + "routes.rules.matchers": "Critères de correspondance", + "routes.rules.status": "Statut", + "routes.rules.empty": "Aucune règle de service", + "routes.rules.create": "Créer une règle", + "routes.rules.edit": "Modifier la règle", + "routes.rules.form_hint": "Définissez les critères et le service cible.", + "routes.rules.name_placeholder": "Alertes PostgreSQL", + "routes.rules.select_service": "Sélectionner un service", + "routes.rules.matcher_preset": "Préréglage de correspondance", + "routes.rules.additional_matchers": "Critères supplémentaires", + "routes.actions.save_rule": "Enregistrer la règle", + "routes.status.enabled": "Activée", + "routes.status.disabled": "Désactivée", + "routes.escalation.policy": "Politique : {name}", + "routes.escalation.rotation": "Rotation : {name}", + "routes.escalation.ignored_policy": "Ignoré en mode politique", + "routes.escalation.after_reminders": "Après les rappels : {count}", + "routes.actions.edit": "Modifier", + "routes.actions.regenerate_token": "Régénérer le jeton", + "routes.actions.service_rules": "Règles de service", + "routes.actions.disable": "Désactiver", + "routes.actions.enable": "Activer", + "routes.actions.delete": "Supprimer", + "routes.permissions.edit": "Le rôle de responsable d’équipe ou d’éditeur/administrateur de groupe est requis pour modifier cette route.", + "routes.permissions.regenerate": "Le rôle de responsable d’équipe ou d’éditeur/administrateur de groupe est requis pour régénérer les jetons de cette route.", + "routes.permissions.rules": "Le rôle de responsable d’équipe ou d’éditeur/administrateur de groupe est requis pour gérer les règles de service.", + "routes.permissions.toggle": "Le rôle de responsable d’équipe ou d’éditeur/administrateur de groupe est requis pour activer ou désactiver cette route.", + "routes.permissions.delete": "L’autorisation de suppression est requise pour supprimer cette route.", + "routes.permissions.rules_denied": "Vous n’êtes pas autorisé à gérer les règles de service de cette route.", + "routes.permissions.edit_denied": "Vous n’êtes pas autorisé à modifier cette route.", + "routes.permissions.disable_denied": "Vous n’êtes pas autorisé à désactiver cette route.", + "routes.permissions.enable_denied": "Vous n’êtes pas autorisé à activer cette route.", + "routes.permissions.delete_denied": "Vous n’êtes pas autorisé à supprimer cette route.", + "routes.permissions.regenerate_denied": "Vous n’êtes pas autorisé à régénérer le jeton de cette route.", + "routes.rules.title_named": "Règles de service / {name}", + "routes.row.number": "Route n° {id}", + "routes.rules.preset": "Préréglage : {name}", + "routes.rules.service_required": "Le service est requis.", + "routes.rules.matcher_required": "Sélectionnez un préréglage ou définissez des critères supplémentaires.", + "routes.rules.delete_title": "Supprimer cette règle de service ?", + "routes.rules.delete_message": "Supprimer la règle de service « {name} » ?", + "routes.details.name": "Nom", + "routes.details.team": "Équipe", + "routes.details.source": "Source", + "routes.details.sentry_webhook": "URL du webhook Sentry", + "routes.details.sentry_secret": "Secret Sentry", + "routes.details.configured": "Configuré", + "routes.details.not_configured": "Non configuré", + "routes.details.sentry_base_url": "URL de base Sentry", + "routes.details.sentry_org": "Organisation Sentry", + "routes.details.sns_webhook": "URL du webhook SNS", + "routes.details.webhook": "URL du webhook", + "routes.details.maintenance": "Maintenance", + "routes.details.escalation": "Escalade", + "routes.details.team_escalation": "Escalade de l’équipe", + "routes.details.notification_source": "Source des canaux de notification", + "routes.details.channels": "Canaux", + "routes.details.token_prefix": "Préfixe du jeton", + "routes.details.status": "Statut", + "routes.details.matcher_preset": "Préréglage de correspondance", + "routes.details.additional_matchers": "Critères supplémentaires", + "routes.details.group_by": "Regrouper par", + "routes.details.service": "Service", + "routes.details.sns_topic": "ARN du topic SNS", + "routes.actions.edit_route": "Modifier la route", + "routes.actions.regenerate_route_token": "Régénérer le jeton de la route", + "routes.actions.disable_route": "Désactiver la route", + "routes.actions.enable_route": "Activer la route", + "routes.actions.delete_route": "Supprimer la route", + "routes.validation.group_by": "Le regroupement de la route doit être un tableau JSON, par exemple [\"alertname\", \"instance\"]. Utilisez [] pour désactiver le regroupement entre alertes.", + "routes.success.sns_created": "Route AWS SNS créée. Copiez l’URL du webhook depuis les détails de la route.", + "routes.confirm.disable_title": "Désactiver cette route ?", + "routes.confirm.disable_message": "Désactiver la route « {name} » ?\n\nLa route cessera d’accepter les alertes entrantes, mais restera visible et pourra être réactivée.", + "routes.confirm.delete_title": "Supprimer cette route ?", + "routes.confirm.delete_message": "Supprimer la route « {name} » ?\n\nCette opération retirera la route des listes actives et arrêtera la réception des alertes sur cette route. Les alertes historiques seront conservées.", + "routes.intake.sentry_title": "URL du webhook Sentry", + "routes.intake.heartbeat_title": "Modèle d’URL du signal de vie", + "routes.intake.sentry_subtitle": "Copiez cette URL dans l’intégration interne Sentry. Collez ensuite le secret client Sentry dans les paramètres de cette route.", + "routes.intake.heartbeat_subtitle": "Les signaux de vie utilisent un jeton propre à chaque contrôle dans l’URL. Créez ou ouvrez un signal de vie pour copier son URL réelle.", + "routes.intake.route_subtitle": "Copiez ce jeton maintenant. Il pourrait ne plus être affiché.", + "routes.intake.sentry_help": "Utilisez cette URL comme URL de webhook dans l’intégration interne Sentry.", + "routes.intake.heartbeat_help": "Remplacez par le jeton d’un signal de vie. N’envoyez pas d’en-tête Authorization: Bearer pour les signaux de vie.", + "routes.intake.route_help": "Envoyez les alertes à cette URL et transmettez le jeton dans l’en-tête Authorization: Bearer.", + "routes.confirm.regenerate_title": "Régénérer le jeton de réception de la route ?", + "routes.confirm.regenerate_message": "Régénérer le jeton de réception de la route ? Le jeton existant cessera de fonctionner.", + "routes.actions.regenerate": "Régénérer", + "matcher.preset.none": "Aucun préréglage", + "matcher.preset.disabled_suffix": "Désactivé", + "matcher.preset.none_hint": "Aucun préréglage sélectionné. Seuls les critères supplémentaires ci-dessous seront évalués.", + "matcher.preset.disabled_hint": "Ce préréglage est désactivé. La correspondance ne pourra pas réussir tant qu’il ne sera pas activé.", + "matcher.preset.match_both": "Le préréglage « {name} » v{version} et les critères supplémentaires doivent tous correspondre.", + "routes.form.webhook_pd_help": "Les routes webhook acceptent les payloads génériques IncidentRelay ainsi que les événements trigger, acknowledge et resolve compatibles avec PagerDuty Events API v2. Utilisez le jeton de réception de la route comme routing_key.", + "routes.intake.generic_example_comment": "Format de webhook générique IncidentRelay", + "routes.intake.pagerduty_example_comment": "Format compatible avec PagerDuty Events API v2", + "routes.intake.webhook_subtitle": "Utilisez ce jeton dans l’en-tête Authorization: Bearer pour les payloads génériques, ou comme routing_key pour les payloads compatibles avec PagerDuty Events API v2.", + "routes.intake.webhook_help": "Les payloads génériques utilisent Authorization: Bearer. Les payloads compatibles avec PagerDuty Events API v2 placent le même jeton dans routing_key.", + "routes.form.datadog_help": "Créez une intégration Datadog Webhooks qui envoie le payload personnalisé recommandé vers cette route. Ajoutez le jeton de réception comme en-tête personnalisé Authorization: Bearer.", + "routes.form.uptime_kuma_help": "Uptime Kuma envoie son payload JSON Webhook standard vers cette route. Ajoutez le jeton de réception comme en-tête Authorization: Bearer. DOWN ouvre une alerte ; UP ou maintenance la résout.", + "routes.intake.datadog_example_comment": "Exemple de payload personnalisé Datadog Webhooks", + "routes.intake.datadog_subtitle": "Utilisez ce point de terminaison dans l’intégration Datadog Webhooks et ajoutez le jeton de la route comme en-tête personnalisé Authorization: Bearer.", + "routes.intake.datadog_help": "Configurez un payload JSON personnalisé contenant ALERT_CYCLE_KEY et ALERT_TRANSITION afin que le rétablissement mette à jour la même alerte IncidentRelay.", + "routes.intake.uptime_kuma_example_comment": "Exemple de payload Webhook standard Uptime Kuma", + "routes.intake.uptime_kuma_subtitle": "Utilisez ce point de terminaison et le jeton de la route dans une notification Webhook Uptime Kuma.", + "routes.intake.uptime_kuma_help": "Dans Uptime Kuma, créez une notification Webhook avec la méthode POST, le type application/json, l’URL de réception de la route et un en-tête Authorization supplémentaire. Conservez le corps de requête par défaut." +} diff --git a/app/static/i18n/fr/service_standards.json b/app/static/i18n/fr/service_standards.json new file mode 100644 index 0000000..1d8b0ac --- /dev/null +++ b/app/static/i18n/fr/service_standards.json @@ -0,0 +1,191 @@ +{ + "service_standards.tab": "Standards", + "service_standards.title": "Standards de service", + "service_standards.subtitle": "Exigences de préparation appliquées aux services du groupe sélectionné.", + "service_standards.actions.restore_default": "Restaurer le standard par défaut", + "service_standards.actions.new_standard": "Nouveau standard", + "service_standards.actions.new_check": "Nouveau contrôle", + "service_standards.actions.edit": "Modifier", + "service_standards.actions.enable": "Activer", + "service_standards.actions.disable": "Désactiver", + "service_standards.actions.delete": "Supprimer", + "service_standards.actions.cancel": "Annuler", + "service_standards.actions.save_standard": "Enregistrer le standard", + "service_standards.actions.save_check": "Enregistrer le contrôle", + "service_standards.actions.evaluate": "Évaluer", + "service_standards.list.title": "Standards", + "service_standards.list.subtitle": "Politiques déterminant le score de préparation.", + "service_standards.table.standard": "Standard", + "service_standards.table.applies_to": "S’applique à", + "service_standards.table.checks": "Contrôles", + "service_standards.table.status": "Statut", + "service_standards.table.actions": "Actions", + "service_standards.table.check": "Contrôle", + "service_standards.table.type": "Type", + "service_standards.table.weight": "Poids", + "service_standards.table.severity": "Gravité", + "service_standards.empty.loaded": "Aucun standard de service chargé", + "service_standards.empty.none": "Aucun standard de service configuré pour ce groupe.", + "service_standards.empty.select": "Sélectionnez un standard pour gérer ses contrôles.", + "service_standards.empty.no_checks": "Ce standard ne contient aucun contrôle.", + "service_standards.loading.standards": "Chargement des standards de service...", + "service_standards.loading.checks": "Chargement des contrôles...", + "service_standards.errors.load_standards": "Impossible de charger les standards de service.", + "service_standards.errors.load_checks": "Impossible de charger les contrôles du standard de service.", + "service_standards.errors.restore_group": "Un groupe est requis pour restaurer le standard de service par défaut.", + "service_standards.errors.restore": "Impossible de restaurer le standard de service par défaut.", + "service_standards.errors.update_standard": "Impossible de mettre à jour le standard de service.", + "service_standards.errors.delete_standard": "Impossible de supprimer le standard de service.", + "service_standards.errors.update_check": "Impossible de mettre à jour le contrôle de préparation.", + "service_standards.errors.delete_check": "Impossible de supprimer le contrôle de préparation.", + "service_standards.errors.create_group": "Un groupe est requis pour créer un standard de service.", + "service_standards.errors.save_standard": "Impossible d’enregistrer le standard de service.", + "service_standards.errors.save_check": "Impossible d’enregistrer le contrôle de préparation.", + "service_standards.validation.name_slug": "Le nom et le slug sont requis.", + "service_standards.validation.config_json": "La configuration doit être un JSON valide.", + "service_standards.validation.check_required": "Le nom, le slug et le type de contrôle sont requis.", + "service_standards.status.enabled": "Activé", + "service_standards.status.disabled": "Désactivé", + "service_standards.standard_number": "Standard n° {id}", + "service_standards.check_number": "Contrôle n° {id}", + "service_standards.selected_standard": "Standard sélectionné", + "service_standards.confirm.default": "Êtes-vous sûr ?", + "service_standards.confirm.delete_standard_title": "Supprimer ce standard de service ?", + "service_standards.confirm.delete_standard_message": "Supprimer le standard de service « {name} » ?", + "service_standards.confirm.delete_check_title": "Supprimer ce contrôle de préparation ?", + "service_standards.confirm.delete_check_message": "Supprimer le contrôle de préparation « {name} » ?", + "service_standards.severity.info": "Information", + "service_standards.severity.warning": "Avertissement", + "service_standards.severity.critical": "Critique", + "service_standards.severity.required": "{severity} / requis", + "service_standards.check_type.field_present": "Champ présent", + "service_standards.check_type.field_equals": "Champ égal à", + "service_standards.check_type.owner_exists": "Propriétaire présent", + "service_standards.check_type.active_rotation_exists": "Rotation active présente", + "service_standards.check_type.escalation_policy_exists": "Politique d’escalade présente", + "service_standards.check_type.notification_policy_exists": "Politique de notification présente", + "service_standards.check_type.service_channel_exists": "Canal de service présent", + "service_standards.check_type.route_exists": "Route présente", + "service_standards.check_type.match_rule_exists": "Règle de correspondance présente", + "service_standards.check_type.runbook_exists": "Procédure présente", + "service_standards.check_type.link_type_exists": "Type de lien présent", + "service_standards.check_type.dependency_exists": "Dépendance présente", + "service_standards.check_type.dependency_cycle_absent": "Aucun cycle de dépendances", + "service_standards.check_type.metadata_value": "Valeur de métadonnée", + "service_standards.kind.technical": "Technique", + "service_standards.kind.business": "Métier", + "service_standards.lifecycle.experimental": "Expérimental", + "service_standards.lifecycle.development": "Développement", + "service_standards.lifecycle.production": "Production", + "service_standards.lifecycle.deprecated": "Obsolète", + "service_standards.lifecycle.retired": "Retiré", + "service_standards.tier.tier_1": "Niveau 1", + "service_standards.tier.tier_2": "Niveau 2", + "service_standards.tier.tier_3": "Niveau 3", + "service_standards.tier.tier_4": "Niveau 4", + "service_standards.criticality.critical": "Critique", + "service_standards.criticality.high": "Élevée", + "service_standards.criticality.medium": "Moyenne", + "service_standards.criticality.low": "Faible", + "service_standards.environment.production": "Production", + "service_standards.environment.staging": "Préproduction", + "service_standards.environment.development": "Développement", + "service_standards.environment.testing": "Test", + "service_standards.environment.shared": "Partagé", + "service_standards.service_type.api": "API", + "service_standards.service_type.web": "Web", + "service_standards.service_type.database": "Base de données", + "service_standards.service_type.queue": "File d’attente", + "service_standards.service_type.cache": "Cache", + "service_standards.service_type.worker": "Worker", + "service_standards.service_type.cron": "Cron", + "service_standards.service_type.network": "Réseau", + "service_standards.service_type.storage": "Stockage", + "service_standards.service_type.infrastructure": "Infrastructure", + "service_standards.service_type.external": "Externe", + "service_standards.service_type.other": "Autre", + "service_standards.applies.kind": "type : {values}", + "service_standards.applies.lifecycle": "cycle de vie : {values}", + "service_standards.applies.environment": "environnement : {values}", + "service_standards.applies.tier": "niveau : {values}", + "service_standards.applies.criticality": "criticité : {values}", + "service_standards.applies.type": "catégorie : {values}", + "service_standards.applies.all": "tous les services", + "service_standards.form.edit_standard": "Modifier le standard de service", + "service_standards.form.new_standard": "Nouveau standard de service", + "service_standards.form.standard_subtitle": "Exigences de préparation pour les services correspondants.", + "service_standards.form.name": "Nom", + "service_standards.form.standard_name_placeholder": "Préparation opérationnelle de base", + "service_standards.form.slug": "Slug", + "service_standards.form.description": "Description", + "service_standards.form.kinds": "Types", + "service_standards.form.select_kinds": "Sélectionner les types", + "service_standards.form.lifecycles": "Cycles de vie", + "service_standards.form.select_lifecycles": "Sélectionner les cycles de vie", + "service_standards.form.tiers": "Niveaux", + "service_standards.form.select_tiers": "Sélectionner les niveaux", + "service_standards.form.criticalities": "Criticités", + "service_standards.form.select_criticalities": "Sélectionner les criticités", + "service_standards.form.environments": "Environnements", + "service_standards.form.select_environments": "Sélectionner les environnements", + "service_standards.form.service_types": "Catégories de service", + "service_standards.form.select_service_types": "Sélectionner les catégories de service", + "service_standards.form.enabled": "Activé", + "service_standards.form.select_values": "Sélectionner les valeurs", + "service_standards.form.edit_check": "Modifier le contrôle de préparation", + "service_standards.form.new_check": "Nouveau contrôle de préparation", + "service_standards.form.check_name_placeholder": "Propriétaire configuré", + "service_standards.form.check_type": "Type de contrôle", + "service_standards.form.configuration": "Configuration JSON", + "service_standards.form.weight": "Poids", + "service_standards.form.position": "Position", + "service_standards.form.severity": "Gravité", + "service_standards.form.required": "Requis", + "service_standards.readiness.title": "Préparation", + "service_standards.readiness.not_evaluated": "La préparation n’a pas été évaluée.", + "service_standards.readiness.no_standards": "Aucun standard ne s’applique à ce service.", + "service_standards.readiness.score": "Score", + "service_standards.readiness.standards": "Standards", + "service_standards.readiness.checks": "Contrôles", + "service_standards.readiness.failed": "Échecs", + "service_standards.readiness.failed_breakdown": "{total} au total / {required} requis / {critical} critiques", + "service_standards.readiness.failed_checks": "Contrôles en échec", + "service_standards.readiness.standard_fallback": "Standard", + "service_standards.readiness.check_fallback": "Contrôle", + "service_standards.readiness.failed_fallback": "échec", + "service_standards.readiness.points": "{weight} pt", + "service_standards.readiness.more": "+{count} autre(s)", + "service_standards.result.owner_passed": "Le service possède {count} propriétaire(s) actif(s)", + "service_standards.result.owner_failed": "Le service requiert au moins {minimum} propriétaire(s) actif(s)", + "service_standards.result.rotation_missing": "Le service n’a pas de rotation par défaut", + "service_standards.result.rotation_passed": "Le service possède une rotation par défaut active", + "service_standards.result.rotation_invalid": "La rotation par défaut du service est désactivée ou supprimée", + "service_standards.result.escalation_missing": "Le service n’a pas de politique d’escalade par défaut", + "service_standards.result.escalation_invalid": "La politique d’escalade du service est désactivée ou supprimée", + "service_standards.result.escalation_no_rules": "La politique d’escalade du service ne contient aucune règle active", + "service_standards.result.escalation_passed": "Le service possède une politique d’escalade active", + "service_standards.result.notification_missing": "Le service n’a pas de politique de notification", + "service_standards.result.notification_invalid": "La politique de notification du service est désactivée ou supprimée", + "service_standards.result.notification_no_rules": "La politique de notification du service ne contient aucune règle active", + "service_standards.result.notification_no_channels": "La politique de notification du service ne contient aucun canal actif", + "service_standards.result.notification_passed": "Le service possède une politique de notification active", + "service_standards.result.route_direct": "Le service possède une route d’alerte directe active", + "service_standards.result.route_match": "Le service possède une route active via une règle de correspondance", + "service_standards.result.route_missing": "Le service ne possède aucune route d’alerte active", + "service_standards.result.runbook_passed": "Le service possède {count} procédure(s) active(s)", + "service_standards.result.runbook_failed": "Le service requiert au moins {minimum} procédure(s) active(s)", + "service_standards.result.cycle_absent": "Le service ne fait partie d’aucun cycle de dépendances", + "service_standards.result.cycle_present": "Le service fait partie d’un cycle de dépendances", + "service_standards.result.evaluation_error": "Le contrôle de préparation n’a pas pu être évalué", + "service_standards.builtin.standard.basic": "Préparation opérationnelle de base", + "service_standards.builtin.check.owner": "Propriétaire configuré", + "service_standards.builtin.check.escalation_policy": "Politique d’escalade configurée", + "service_standards.builtin.check.notification_policy": "Politique de notification configurée", + "service_standards.builtin.check.alert_route": "Route d’alerte configurée", + "service_standards.builtin.check.runbook": "Procédure configurée", + "service_standards.builtin.check.dependency_cycle": "Aucun cycle de dépendances", + "service_standards.readiness.status.ready": "Prêt", + "service_standards.readiness.status.warning": "Avertissement", + "service_standards.readiness.status.not_ready": "Non prêt", + "service_standards.readiness.status.not_applicable": "Non applicable" +} diff --git a/app/static/i18n/fr/services.json b/app/static/i18n/fr/services.json new file mode 100644 index 0000000..be706d5 --- /dev/null +++ b/app/static/i18n/fr/services.json @@ -0,0 +1,135 @@ +{ + "services.summary.total": "Total", + "services.summary.operational": "Opérationnels", + "services.summary.affected": "Affectés", + "services.summary.major_outage": "Panne majeure", + "services.summary.services_in_scope": "Services dans le périmètre", + "services.summary.current_scope": "Services dans le périmètre actuel", + "services.summary.effective_status": "Statut effectif", + "services.summary.own_status": "Statut propre", + "services.summary.not_operational": "Non opérationnels", + "services.summary.own_status_not_operational": "Statut propre non opérationnel", + "services.tabs.services": "Services", + "services.tabs.links": "Liens", + "services.tabs.runbooks": "Procédures", + "services.tabs.dependencies": "Dépendances", + "services.tabs.impact": "Impact", + "services.tabs.standards": "Standards", + "services.tabs.analytics": "Analytique", + "services.tabs.reliability": "Fiabilité", + "services.section.services": "Services", + "services.section.links": "Liens du service", + "services.section.links_subtitle": "Tableaux de bord, journaux, documentation et dépôts.", + "services.section.runbooks": "Procédures", + "services.section.runbooks_subtitle": "Instructions d’intervention associées aux services.", + "services.section.dependencies": "Dépendances", + "services.section.dependencies_subtitle": "Dépendances entre services et entre équipes.", + "services.actions.new_service": "Nouveau service", + "services.actions.new_link": "Nouveau lien", + "services.actions.new_runbook": "Nouvelle procédure", + "services.actions.new_dependency": "Nouvelle dépendance", + "services.actions.reload": "Recharger", + "services.actions.details": "Détails", + "services.actions.edit": "Modifier", + "services.actions.enable": "Activer", + "services.actions.disable": "Désactiver", + "services.actions.delete": "Supprimer", + "services.search.services": "Rechercher des services...", + "services.search.links": "Rechercher des liens...", + "services.search.runbooks": "Rechercher des procédures...", + "services.search.dependencies": "Rechercher des dépendances...", + "services.search.graph": "Rechercher dans le graphe...", + "services.filters.all_statuses": "Tous les statuts", + "services.filters.all_criticalities": "Toutes les criticités", + "services.filters.all_readiness": "Tous les niveaux de préparation", + "services.filters.all_services": "Tous les services", + "services.status.operational": "Opérationnel", + "services.status.degraded": "Dégradé", + "services.status.partial_outage": "Panne partielle", + "services.status.major_outage": "Panne majeure", + "services.status.maintenance": "Maintenance", + "services.status.disabled": "Désactivé", + "services.status.unknown": "Inconnu", + "services.status.enabled": "Activé", + "services.readiness.ready": "Prêt", + "services.readiness.warning": "Avertissement", + "services.readiness.not_ready": "Non prêt", + "services.readiness.not_applicable": "Non applicable", + "services.readiness.not_evaluated": "Non évalué", + "services.criticality.critical": "Critique", + "services.criticality.high": "Élevée", + "services.criticality.medium": "Moyenne", + "services.criticality.low": "Faible", + "services.environment.production": "Production", + "services.environment.staging": "Préproduction", + "services.environment.development": "Développement", + "services.environment.testing": "Test", + "services.environment.shared": "Partagé", + "services.table.service": "Service", + "services.table.team": "Équipe", + "services.table.status": "Statut", + "services.table.readiness": "Préparation", + "services.table.criticality": "Criticité", + "services.table.environment": "Environnement", + "services.table.defaults": "Valeurs par défaut", + "services.table.actions": "Actions", + "services.table.link": "Lien", + "services.table.type": "Type", + "services.table.priority": "Priorité", + "services.table.runbook": "Procédure", + "services.table.severity": "Gravité", + "services.table.depends_on": "Dépend de", + "services.table.target_status": "Statut cible", + "services.table.description": "Description", + "services.empty.services": "Aucun service", + "services.empty.services_loaded": "Aucun service chargé", + "services.empty.links": "Aucun lien", + "services.empty.runbooks": "Aucune procédure", + "services.empty.dependencies": "Aucune dépendance", + "services.service_number": "Service n° {id}", + "services.defaults.rotation": "Rotation : {name}", + "services.defaults.policy": "Politique : {name}", + "services.defaults.notifications": "Notifications : {name}", + "services.defaults.priority": "Priorité : {name}", + "services.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier ce service.", + "services.permissions.toggle": "Le rôle de responsable d’équipe est requis pour activer ou désactiver ce service.", + "services.permissions.delete": "L’autorisation de suppression est requise pour supprimer ce service.", + "services.dependencies.table": "Tableau", + "services.dependencies.graph": "Graphe", + "services.dependencies.view": "Vue des dépendances", + "services.dependencies.graph_mode": "Mode d’affichage du graphe", + "services.dependencies.overview": "Vue d’ensemble", + "services.dependencies.impact_only": "Impact uniquement", + "services.dependencies.full_graph": "Graphe complet", + "services.dependencies.collapse_sections": "Réduire les sections du graphe", + "services.dependencies.hide_healthy_leaves": "Masquer les nœuds terminaux sains", + "services.dependencies.collapse_team": "Regrouper par équipe", + "services.dependencies.collapse_prefix": "Regrouper par préfixe", + "services.dependencies.no_collapse": "Aucun regroupement", + "services.dependencies.focus_depth": "Profondeur de focalisation", + "services.dependencies.depth": "Profondeur {value}", + "services.dependencies.depth_all": "Toutes les profondeurs", + "services.dependencies.connected": "Connectés à la sélection", + "services.dependencies.outgoing": "Dépendances de la sélection", + "services.dependencies.incoming": "Services dépendant de la sélection", + "services.dependencies.all": "Tous", + "services.dependencies.hierarchy": "Hiérarchie", + "services.dependencies.force_directed": "Disposition par forces", + "services.dependencies.circle": "Cercle", + "services.dependencies.grid": "Grille", + "services.dependencies.fit": "Ajuster", + "services.dependencies.no_graph": "Aucun graphe de dépendances chargé", + "services.dependencies.correlation_off": "Corrélation désactivée", + "services.dependencies.correlation_delay": "Corrélation {seconds} s", + "services.dependencies.required": "Requise", + "services.dependencies.important": "Importante", + "services.dependencies.optional": "Facultative", + "services.dependencies.hard": "Forte", + "services.dependencies.soft": "Souple", + "services.dependencies.external": "Externe", + "services.dependencies.informational": "Informative", + "services.details.event_orchestrations": "Orchestrations d’événements", + "services.details.orchestration_disabled": "Désactivée", + "services.details.orchestration_active": "Active", + "services.details.orchestration_shadow": "Simulation" +} diff --git a/app/static/i18n/fr/services_full.json b/app/static/i18n/fr/services_full.json new file mode 100644 index 0000000..115cbbb --- /dev/null +++ b/app/static/i18n/fr/services_full.json @@ -0,0 +1,502 @@ +{ + "services_full.95_critical_alerts_acknowledged_within_15_minutes": "95 % des alertes critiques acquittées en moins de 15 minutes", + "services_full.api": "API", + "services_full.ack_latency": "Délai d’acquittement", + "services_full.acknowledged": "Acquittée", + "services_full.actions": "Actions", + "services_full.active": "Actif", + "services_full.active_and_upcoming_maintenance_that_can_affect_this_service": "Maintenances actives et à venir susceptibles d’affecter ce service.", + "services_full.actor": "Auteur", + "services_full.add_sli": "Ajouter un SLI", + "services_full.add_slo": "Ajouter un SLO", + "services_full.add_default_stakeholder": "Ajouter une partie prenante par défaut", + "services_full.add_stakeholder": "Ajouter une partie prenante", + "services_full.additional_matchers": "Critères supplémentaires", + "services_full.additional_matchers_json": "Critères supplémentaires au format JSON", + "services_full.affected": "Affectés", + "services_full.affected_9cd42f9d": "Pourcentage affecté", + "services_full.affected_samples": "Échantillons affectés", + "services_full.alert_acknowledgement_latency": "Délai d’acquittement des alertes", + "services_full.alert_group": "Groupe d’alertes", + "services_full.alert_groups": "Groupes d’alertes", + "services_full.alert_groups_trend": "Évolution des groupes d’alertes", + "services_full.alert_impact": "Impact des alertes", + "services_full.alert_resolution_latency": "Délai de résolution des alertes", + "services_full.alert_volume_grouped_by_service_for_the_selected_scope": "Volume d’alertes regroupé par service pour le périmètre sélectionné.", + "services_full.alerts": "Alertes", + "services_full.all": "Tous", + "services_full.all_criticalities": "Toutes les criticités", + "services_full.all_effective_statuses": "Tous les statuts effectifs", + "services_full.all_readiness": "Tous les niveaux de préparation", + "services_full.all_reasons": "Tous les motifs", + "services_full.all_services": "Tous les services", + "services_full.all_severities": "Tous les niveaux de gravité", + "services_full.all_statuses": "Tous les statuts", + "services_full.analytics": "Analytique", + "services_full.analytics_by_affected_system": "Analytique par système affecté", + "services_full.analytics_version": "Version de l’analytique", + "services_full.analytics_window": "Fenêtre d’analyse", + "services_full.at_risk": "À risque", + "services_full.attach_response_instructions_to_a_service": "Associez des instructions d’intervention à un service.", + "services_full.average_and_maximum_affected_services_from_persisted_impact_snap": "Nombre moyen et maximal de services affectés d’après les instantanés d’impact enregistrés.", + "services_full.avg_affected": "Moyenne affectée", + "services_full.behavior": "Comportement", + "services_full.blast_radius": "Périmètre d’impact", + "services_full.breached": "Dépassé", + "services_full.breached_alert_groups": "Groupes d’alertes en dépassement", + "services_full.budget": "Budget", + "services_full.budget_used": "Budget utilisé :", + "services_full.business": "Métier", + "services_full.business_component": "Composant métier", + "services_full.business_owner": "Responsable métier", + "services_full.business_service": "Service métier", + "services_full.cache": "Cache", + "services_full.cancel": "Annuler", + "services_full.capture_snapshot": "Capturer un instantané", + "services_full.category": "Catégorie", + "services_full.circle": "Cercle", + "services_full.close": "Fermer", + "services_full.cloud_postgresql": "PostgreSQL Cloud", + "services_full.collapse_by_prefix": "Regrouper par préfixe", + "services_full.collapse_by_team": "Regrouper par équipe", + "services_full.collapse_graph_sections": "Réduire les sections du graphe", + "services_full.comment_added": "Commentaire ajouté", + "services_full.comparison": "Comparaison", + "services_full.connected_to_selected": "Connectés à la sélection", + "services_full.correlation": "Corrélation", + "services_full.correlation_off": "Corrélation désactivée", + "services_full.correlation_uses_this_dependency_to_connect_related_active_alert": "La corrélation utilise cette dépendance pour relier les groupes d’alertes actifs associés. Le délai de propagation définit l’écart temporel maximal permettant encore une correspondance.", + "services_full.counters": "Compteurs", + "services_full.create_an_sli_first_then_add_an_slo_for_it": "Créez d’abord un SLI, puis ajoutez-lui un SLO.", + "services_full.create_dependency": "Créer une dépendance", + "services_full.create_link": "Créer un lien", + "services_full.create_runbook": "Créer une procédure", + "services_full.create_service": "Créer un service", + "services_full.critical": "Critique", + "services_full.critical_alert_acknowledgement_latency": "Délai d’acquittement des alertes critiques", + "services_full.critical_downstream": "Services critiques en aval", + "services_full.critical_open": "Critiques ouvertes", + "services_full.criticality": "Criticité", + "services_full.cron": "Cron", + "services_full.cross_service_and_cross_team_dependencies": "Dépendances entre services et entre équipes.", + "services_full.current": "Actuel", + "services_full.current_availability": "Disponibilité actuelle", + "services_full.current_compliance": "Conformité actuelle", + "services_full.current_impact": "Impact actuel", + "services_full.custom": "Personnalisé", + "services_full.customer_success": "Réussite client", + "services_full.cycle_detected": "Cycle détecté", + "services_full.cycles_depth_0_0": "Cycles/profondeur 0/0", + "services_full.dashboard": "Tableau de bord", + "services_full.dashboard_logs_docs_repository_or_another_service_link": "Tableau de bord, journaux, documentation, dépôt ou lien vers un autre service.", + "services_full.dashboards_logs_documentation_and_repositories": "Tableaux de bord, journaux, documentation et dépôts.", + "services_full.dashboards_logs_traces_repositories_and_documentation": "Tableaux de bord, journaux, traces, dépôts et documentation.", + "services_full.database": "Base de données", + "services_full.declare_another_service_this_service_depends_on": "Déclarez un autre service dont dépend ce service.", + "services_full.dedup_ratio": "Taux de déduplication", + "services_full.default_escalation_policy": "Politique d’escalade par défaut", + "services_full.default_rotation": "Rotation par défaut", + "services_full.default_stakeholders": "Parties prenantes par défaut", + "services_full.default_stakeholders_are_copied_to_new_incidents_for_this_servic": "Les parties prenantes par défaut sont copiées dans les nouveaux incidents de ce service. Les incidents existants ne sont pas modifiés.", + "services_full.defaults": "Valeurs par défaut", + "services_full.degraded": "Dégradé", + "services_full.degraded_path": "Chemin dégradé", + "services_full.delete": "Supprimer", + "services_full.delete_dependency": "Supprimer la dépendance", + "services_full.delete_link": "Supprimer le lien", + "services_full.delete_runbook": "Supprimer la procédure", + "services_full.delete_service": "Supprimer le service", + "services_full.delete_this_sli": "Supprimer ce SLI ?", + "services_full.delete_this_slo": "Supprimer ce SLO ?", + "services_full.delete_this_default_stakeholder": "Supprimer cette partie prenante par défaut ?", + "services_full.delete_this_dependency": "Supprimer cette dépendance ?", + "services_full.delete_this_link": "Supprimer ce lien ?", + "services_full.delete_this_runbook": "Supprimer cette procédure ?", + "services_full.delete_this_service": "Supprimer ce service ?", + "services_full.dependencies": "Dépendances", + "services_full.dependencies_of_selected": "Dépendances de la sélection", + "services_full.dependencies_view": "Vue des dépendances", + "services_full.dependency": "Dépendance", + "services_full.dependency_impact": "Impact de la dépendance", + "services_full.dependency_service_is_required": "Le service dépendant est requis.", + "services_full.dependency_strength": "Force de la dépendance", + "services_full.dependency_type": "Type de dépendance", + "services_full.dependents_of_selected": "Services dépendant de la sélection", + "services_full.depends_on": "Dépend de", + "services_full.depends_on_service": "Dépend du service", + "services_full.deprecated": "Obsolète", + "services_full.depth_1": "Profondeur 1", + "services_full.depth_2": "Profondeur 2", + "services_full.depth_3": "Profondeur 3", + "services_full.depth_5": "Profondeur 5", + "services_full.depth_all": "Toutes les profondeurs", + "services_full.depth_limited": "Profondeur limitée", + "services_full.description": "Description", + "services_full.development": "Développement", + "services_full.direct": "Direct", + "services_full.direct_downstream": "Directement en aval", + "services_full.disabled": "Désactivé", + "services_full.documentation": "Documentation", + "services_full.downtime": "Durée d’indisponibilité", + "services_full.edit": "Modifier", + "services_full.edit_sli": "Modifier le SLI", + "services_full.edit_slo": "Modifier le SLO", + "services_full.edit_default_stakeholder": "Modifier la partie prenante par défaut", + "services_full.edit_dependency": "Modifier la dépendance", + "services_full.edit_link": "Modifier le lien", + "services_full.edit_runbook": "Modifier la procédure", + "services_full.edit_service": "Modifier le service", + "services_full.effective_status": "Statut effectif", + "services_full.effective_status_root_cause_and_downstream_blast_radius": "Statut effectif, cause racine et périmètre d’impact en aval.", + "services_full.enabled": "Activé", + "services_full.environment": "Environnement", + "services_full.escalation_policy": "Politique d’escalade", + "services_full.event": "Événement", + "services_full.exclude_maintenance_from_availability_calculations": "Exclure les maintenances des calculs de disponibilité", + "services_full.executive": "Direction", + "services_full.experimental": "Expérimental", + "services_full.explanation": "Explication", + "services_full.external": "Externe", + "services_full.firing": "Déclenchée", + "services_full.firing_grouped_alerts": "Alertes groupées déclenchées", + "services_full.firing_grouped_alerts_by_day": "Alertes groupées déclenchées par jour.", + "services_full.firing_groups": "Groupes déclenchés", + "services_full.fit": "Ajuster", + "services_full.focus_depth": "Profondeur de focalisation", + "services_full.force_directed": "Disposition par forces", + "services_full.full_graph": "Graphe complet", + "services_full.good_alert_groups": "Groupes d’alertes conformes", + "services_full.good_time": "Durée conforme", + "services_full.grafana_dashboard": "Tableau de bord Grafana", + "services_full.graph": "Graphe", + "services_full.graph_view_mode": "Mode d’affichage du graphe", + "services_full.grid": "Grille", + "services_full.grouped_alerts_by_day_in_the_selected_window": "Alertes groupées par jour dans la fenêtre sélectionnée.", + "services_full.grouped_open_alerts": "Alertes ouvertes groupées", + "services_full.hard": "Forte", + "services_full.hide_healthy_leaves": "Masquer les nœuds terminaux sains", + "services_full.hierarchy": "Hiérarchie", + "services_full.high": "Élevée", + "services_full.historical_affected_services": "Services historiquement affectés", + "services_full.historical_impact": "Impact historique", + "services_full.historical_impact_reasons": "Motifs de l’impact historique", + "services_full.historically_affected_service": "Service historiquement affecté", + "services_full.history": "Historique", + "services_full.identity": "Identité", + "services_full.impact": "Impact", + "services_full.impact_incident_count": "Nombre d’incidents avec impact", + "services_full.impact_incidents": "Incidents avec impact", + "services_full.impact_only": "Impact uniquement", + "services_full.impact_snapshot_captured": "Instantané d’impact capturé", + "services_full.impact_v2_explains_effective_status_primary_reason_root_causes_p": "Impact v2 explique le statut effectif, le motif principal, les causes racines, les chemins et le périmètre d’impact en aval.", + "services_full.important": "Importante", + "services_full.inactive": "Inactif", + "services_full.incident_availability": "Disponibilité selon les incidents", + "services_full.incident_availability_and_incident_count_are_calculated_from_imp": "La disponibilité et le nombre d’incidents sont calculés à partir des priorités d’impact. Valeur par défaut : P1/P2.", + "services_full.incident_count": "Nombre d’incidents", + "services_full.incident_count_slo_requires_max_incidents": "Un SLO basé sur le nombre d’incidents requiert la valeur Nombre maximal d’incidents.", + "services_full.incident_created": "Incident créé", + "services_full.incident_resolved": "Incident résolu", + "services_full.incident_based_availability": "Disponibilité basée sur les incidents", + "services_full.indicators_describe_what_is_measured_objectives_define_the_targe": "Les indicateurs décrivent ce qui est mesuré. Les objectifs définissent la cible de ces indicateurs.", + "services_full.info": "Information", + "services_full.informational": "Informative", + "services_full.infrastructure": "Infrastructure", + "services_full.kind": "Type", + "services_full.label": "Libellé", + "services_full.last": "Dernier", + "services_full.last_30_days": "30 derniers jours", + "services_full.last_365_days": "365 derniers jours", + "services_full.last_7_days": "7 derniers jours", + "services_full.last_90_days": "90 derniers jours", + "services_full.last_affected": "Dernier impact", + "services_full.last_status": "Dernier statut", + "services_full.latency_slo_requires_threshold_minutes": "Un SLO de latence requiert un seuil en minutes.", + "services_full.latest_slo_measurements_for_services_in_the_selected_scope": "Dernières mesures SLO des services du périmètre sélectionné.", + "services_full.latest_affected": "Derniers services affectés", + "services_full.lifecycle": "Cycle de vie", + "services_full.link": "Lien", + "services_full.links": "Liens", + "services_full.load_more": "Charger davantage", + "services_full.loading_slo_health": "Chargement de la santé des SLO...", + "services_full.loading_slo_measurements": "Chargement des mesures SLO...", + "services_full.loading": "Chargement...", + "services_full.logs": "Journaux", + "services_full.low": "Faible", + "services_full.maintenance": "Maintenance", + "services_full.maintenance_blast": "Maintenance / impact", + "services_full.maintenance_excluded": "Maintenance exclue", + "services_full.maintenance_windows": "Fenêtres de maintenance", + "services_full.major_outage": "Panne majeure", + "services_full.major_outage_path": "Chemin de panne majeure", + "services_full.matcher_preset": "Préréglage de correspondance", + "services_full.matching": "Correspondance", + "services_full.max_alerts": "Nombre maximal d’alertes", + "services_full.max_incidents": "Nombre maximal d’incidents", + "services_full.max_incidents_7c4192bf": "Nombre maximal d’incidents ≤", + "services_full.max_incidents_b48e899b": "Nombre maximal d’incidents ≤", + "services_full.max_upstream": "Maximum en amont", + "services_full.medium": "Moyenne", + "services_full.met": "Atteint", + "services_full.metrics": "Métriques", + "services_full.name": "Nom", + "services_full.network": "Réseau", + "services_full.new_sli": "Nouveau SLI", + "services_full.new_slo": "Nouveau SLO", + "services_full.new_dependency": "Nouvelle dépendance", + "services_full.new_link": "Nouveau lien", + "services_full.new_runbook": "Nouvelle procédure", + "services_full.new_service": "Nouveau service", + "services_full.no": "Non", + "services_full.no_sli_slo_configured_for_this_service": "Aucun SLI/SLO configuré pour ce service.", + "services_full.no_slis_configured": "Aucun SLI configuré.", + "services_full.no_slo_measurements": "Aucune mesure SLO", + "services_full.no_slos_configured": "Aucun SLO configuré.", + "services_full.no_active_or_upcoming_maintenance_windows": "Aucune fenêtre de maintenance active ou à venir.", + "services_full.no_analytics": "Aucune donnée analytique", + "services_full.no_analytics_loaded": "Aucune donnée analytique chargée", + "services_full.no_collapse": "Aucun regroupement", + "services_full.no_data": "Aucune donnée", + "services_full.no_default_policy": "Aucune politique par défaut", + "services_full.no_default_rotation": "Aucune rotation par défaut", + "services_full.no_default_stakeholders": "Aucune partie prenante par défaut.", + "services_full.no_dependencies": "Aucune dépendance", + "services_full.no_dependency_graph_loaded": "Aucun graphe de dépendances chargé", + "services_full.no_description": "Aucune description", + "services_full.no_downstream_services_in_blast_radius": "Aucun service en aval dans le périmètre d’impact.", + "services_full.no_historical_impact_snapshots": "Aucun instantané d’impact historique", + "services_full.no_historical_impact_snapshots_use_capture_snapshot_or_wait_for_": "Aucun instantané d’impact historique. Utilisez Capturer un instantané ou attendez le planificateur.", + "services_full.no_impact_data": "Aucune donnée d’impact", + "services_full.no_impact_data_loaded": "Aucune donnée d’impact chargée", + "services_full.no_impact_detected": "Aucun impact détecté", + "services_full.no_links": "Aucun lien", + "services_full.no_links_3f8a499b": "Aucun lien.", + "services_full.no_notification_policy": "Aucune politique de notification", + "services_full.no_preset": "Aucun préréglage", + "services_full.no_preset_selected_only_the_additional_matchers_below_will_be_ev": "Aucun préréglage sélectionné. Seuls les critères supplémentaires ci-dessous seront évalués.", + "services_full.no_runbooks": "Aucune procédure", + "services_full.no_runbooks_2a27eaad": "Aucune procédure.", + "services_full.no_services_loaded": "Aucun service chargé", + "services_full.no_timeline_events": "Aucun événement dans la chronologie.", + "services_full.nodes": "Nœuds", + "services_full.none": "Aucun", + "services_full.not_applicable": "Non applicable", + "services_full.not_evaluated": "Non évalué", + "services_full.not_operational": "Non opérationnel", + "services_full.not_ready": "Non prêt", + "services_full.notification_policy": "Politique de notification", + "services_full.notifications": "Notifications", + "services_full.open_alert_groups": "Groupes d’alertes ouverts", + "services_full.open_alerts": "Alertes ouvertes", + "services_full.open_groups": "Groupes ouverts", + "services_full.operational": "Opérationnel", + "services_full.operational_path": "Chemin opérationnel", + "services_full.optional": "Facultative", + "services_full.optional_labels_matcher_for_this_runbook": "Critère facultatif sur les libellés pour cette procédure.", + "services_full.other": "Autre", + "services_full.overrides_the_team_s_default_priority_policy_for_incidents_assig": "Remplace la politique de priorité par défaut de l’équipe pour les incidents affectés à ce service.", + "services_full.overview": "Vue d’ensemble", + "services_full.own_status": "Statut propre", + "services_full.owner": "Propriétaire", + "services_full.ownership_identity_and_classification": "Propriété, identité et classification.", + "services_full.partial_outage": "Panne partielle", + "services_full.partial_outage_path": "Chemin de panne partielle", + "services_full.path": "Chemin", + "services_full.peak_affected": "Pic de services affectés", + "services_full.peak_critical": "Pic critique", + "services_full.pending_alert_groups": "Groupes d’alertes en attente", + "services_full.postgresql_outage": "Panne PostgreSQL", + "services_full.primary_reason": "Motif principal", + "services_full.priority": "Priorité", + "services_full.priority_changed": "Priorité modifiée", + "services_full.priority_policy": "Politique de priorité", + "services_full.priority_scope": "Périmètre de priorité", + "services_full.production": "Production", + "services_full.propagation_delay_seconds": "Délai de propagation, secondes", + "services_full.queue": "File d’attente", + "services_full.quick_actions": "Actions rapides", + "services_full.raw_alert_events_by_day_before_grouping": "Événements d’alerte bruts par jour avant regroupement.", + "services_full.raw_alert_volume": "Volume brut d’alertes", + "services_full.raw_alerts": "Alertes brutes", + "services_full.readiness": "Préparation", + "services_full.readiness_check_could_not_be_evaluated": "Le contrôle de préparation n’a pas pu être évalué", + "services_full.ready": "Prêt", + "services_full.reason": "Motif", + "services_full.recent_alerts": "Alertes récentes", + "services_full.recent_service_readiness_and_configuration_events": "Événements récents liés au service, à la préparation et à la configuration.", + "services_full.reliability": "Fiabilité", + "services_full.reload": "Recharger", + "services_full.repository": "Dépôt", + "services_full.required": "Requise", + "services_full.reset": "Réinitialiser", + "services_full.resolve_latency": "Délai de résolution", + "services_full.resolved": "Résolue", + "services_full.response_instructions_attached_to_services": "Instructions d’intervention associées aux services.", + "services_full.response_instructions_for_this_service": "Instructions d’intervention pour ce service.", + "services_full.retired": "Retiré", + "services_full.role": "Rôle", + "services_full.runbook": "Procédure", + "services_full.runbooks": "Procédures", + "services_full.sli_slo": "SLI / SLO", + "services_full.sli_slo_health": "Santé des SLI / SLO", + "services_full.sli_name": "Nom du SLI", + "services_full.sli_name_and_slug_are_required": "Le nom et le slug du SLI sont requis.", + "services_full.sli_type": "Type de SLI", + "services_full.sli_slo_health_latest_measurements_and_error_budget_state": "Santé des SLI/SLO, dernières mesures et état du budget d’erreur.", + "services_full.slis": "SLI", + "services_full.slo_name": "Nom du SLO", + "services_full.slo_name_and_sli_are_required": "Le nom du SLO et le SLI sont requis.", + "services_full.slo_target_percent_is_required": "Le pourcentage cible du SLO est requis.", + "services_full.slos": "SLO", + "services_full.save": "Enregistrer", + "services_full.save_sli": "Enregistrer le SLI", + "services_full.save_slo": "Enregistrer le SLO", + "services_full.save_dependency": "Enregistrer la dépendance", + "services_full.save_link": "Enregistrer le lien", + "services_full.save_runbook": "Enregistrer la procédure", + "services_full.save_service": "Enregistrer le service", + "services_full.scope": "Périmètre", + "services_full.search_dependencies": "Rechercher des dépendances...", + "services_full.search_dependency_service": "Rechercher un service dépendant...", + "services_full.search_graph": "Rechercher dans le graphe...", + "services_full.search_impacted_services_reasons_root_causes_or_paths": "Rechercher des services affectés, motifs, causes racines ou chemins...", + "services_full.search_links": "Rechercher des liens...", + "services_full.search_runbooks": "Rechercher des procédures...", + "services_full.search_services_sli_or_slo": "Rechercher des services, SLI ou SLO...", + "services_full.search_services": "Rechercher des services...", + "services_full.search_source_service": "Rechercher un service source...", + "services_full.select_a_service_to_view_details": "Sélectionnez un service pour afficher ses détails.", + "services_full.select_dependency_service": "Sélectionner un service dépendant...", + "services_full.select_service": "Sélectionner un service", + "services_full.select_user": "Sélectionner un utilisateur", + "services_full.service": "Service", + "services_full.service_default_rotation_is_disabled_or_deleted": "La rotation par défaut du service est désactivée ou supprimée", + "services_full.service_details": "Détails du service", + "services_full.service_escalation_policy_has_no_active_rules": "La politique d’escalade du service ne contient aucune règle active", + "services_full.service_escalation_policy_is_disabled_or_deleted": "La politique d’escalade du service est désactivée ou supprimée", + "services_full.service_has_an_active_default_rotation": "Le service possède une rotation par défaut active", + "services_full.service_has_an_active_direct_alert_route": "Le service possède une route d’alerte directe active", + "services_full.service_has_an_active_escalation_policy": "Le service possède une politique d’escalade active", + "services_full.service_has_an_active_notification_policy": "Le service possède une politique de notification active", + "services_full.service_has_an_active_route_through_a_match_rule": "Le service possède une route active via une règle de correspondance", + "services_full.service_has_no_active_alert_route": "Le service ne possède aucune route d’alerte active", + "services_full.service_has_no_default_escalation_policy": "Le service n’a pas de politique d’escalade par défaut", + "services_full.service_has_no_default_rotation": "Le service n’a pas de rotation par défaut", + "services_full.service_has_no_notification_policy": "Le service n’a pas de politique de notification", + "services_full.service_impact": "Impact du service", + "services_full.service_impact_view": "Vue de l’impact du service", + "services_full.service_is_not_part_of_a_dependency_cycle": "Le service ne fait partie d’aucun cycle de dépendances", + "services_full.service_is_part_of_a_dependency_cycle": "Le service fait partie d’un cycle de dépendances", + "services_full.service_is_required": "Le service est requis.", + "services_full.service_links": "Liens du service", + "services_full.service_notification_policy_has_no_active_channels": "La politique de notification du service ne contient aucun canal actif", + "services_full.service_notification_policy_has_no_active_rules": "La politique de notification du service ne contient aucune règle active", + "services_full.service_notification_policy_is_disabled_or_deleted": "La politique de notification du service est désactivée ou supprimée", + "services_full.service_status": "Statut du service", + "services_full.service_title_url_and_severity": "Service, titre, URL et gravité.", + "services_full.service_level_counters_for_the_selected_analytics_window": "Compteurs par service pour la fenêtre d’analyse sélectionnée.", + "services_full.services": "Services", + "services_full.services_in_scope": "Services dans le périmètre", + "services_full.severity": "Gravité", + "services_full.severity_scope": "Périmètre de gravité", + "services_full.shared": "Partagé", + "services_full.show_on_status_page_later": "Afficher ultérieurement sur la page de statut", + "services_full.show_operational": "Afficher les services opérationnels", + "services_full.showing": "Affichage", + "services_full.slug": "Slug", + "services_full.snapshot_item_counts_by_primary_reason": "Nombre d’éléments des instantanés par motif principal.", + "services_full.snapshots": "Instantanés", + "services_full.soft": "Souple", + "services_full.source": "Source", + "services_full.staging": "Préproduction", + "services_full.stakeholder": "Partie prenante", + "services_full.status": "Statut", + "services_full.status_and_defaults": "Statut et valeurs par défaut", + "services_full.status_changed": "Statut modifié", + "services_full.status_changes": "Modifications de statut", + "services_full.status_message": "Message de statut", + "services_full.status_page": "Page de statut", + "services_full.status_rotation_escalation_notification_and_priority_policies": "Statut, rotation, politiques d’escalade, de notification et de priorité.", + "services_full.storage": "Stockage", + "services_full.support": "Support", + "services_full.tz": "Fuseau horaire", + "services_full.table": "Tableau", + "services_full.target": "Cible", + "services_full.target_status": "Statut cible", + "services_full.target_1e61501c": "Cible ≥", + "services_full.target_bfd2eaea": "Cible ≥", + "services_full.target_453c00c0": "Cible, %", + "services_full.targets_and_evaluation_window": "Cibles et fenêtre d’évaluation", + "services_full.team": "Équipe", + "services_full.team_default": "Valeur par défaut de l’équipe", + "services_full.team_is_required": "L’équipe est requise.", + "services_full.technical": "Technique", + "services_full.technical_service_or_system_affected_by_alerts": "Service technique ou système affecté par les alertes.", + "services_full.testing": "Test", + "services_full.the_preset_and_additional_matchers_are_combined_using_and_empty_": "Le préréglage et les critères supplémentaires sont combinés avec ET. Un objet {} vide signifie qu’aucune condition supplémentaire n’est appliquée.", + "services_full.threshold": "Seuil ≤", + "services_full.threshold_a1795ecd": "Seuil ≤", + "services_full.threshold_minutes": "Seuil, minutes", + "services_full.tier": "Niveau", + "services_full.tier_1": "Niveau 1", + "services_full.tier_1_downstream": "Niveau 1 en aval", + "services_full.tier_2": "Niveau 2", + "services_full.tier_3": "Niveau 3", + "services_full.tier_4": "Niveau 4", + "services_full.time": "Heure", + "services_full.timeline": "Chronologie", + "services_full.title": "Titre", + "services_full.total": "Total", + "services_full.total_slos": "Nombre total de SLO", + "services_full.total_alert_groups": "Nombre total de groupes d’alertes", + "services_full.total_alerts": "Nombre total d’alertes", + "services_full.total_downstream": "Total en aval", + "services_full.total_groups": "Nombre total de groupes", + "services_full.total_matching_alert_groups": "Nombre total de groupes d’alertes correspondants", + "services_full.total_window": "Fenêtre totale", + "services_full.traces": "Traces", + "services_full.track_open_alerts_as_pending_breached": "Considérer les alertes ouvertes comme en attente/en dépassement", + "services_full.type": "Type", + "services_full.url": "URL", + "services_full.unknown": "Inconnu", + "services_full.unknown_path": "Chemin inconnu", + "services_full.upstream_dependency": "Dépendance en amont", + "services_full.upstream_issues": "Problèmes en amont", + "services_full.upstream_services_this_service_needs_and_downstream_services_tha": "Services en amont nécessaires à ce service et services en aval qui en dépendent.", + "services_full.use_team_default": "Utiliser la valeur par défaut de l’équipe", + "services_full.use_this_dependency_for_alert_correlation": "Utiliser cette dépendance pour corréler les alertes", + "services_full.use_this_owner_for_new_incidents": "Utiliser ce propriétaire pour les nouveaux incidents", + "services_full.use_when_the_preset_alone_should_determine_whether_the_runbook_m": "Utilisez {} lorsque le préréglage doit déterminer à lui seul si la procédure correspond.", + "services_full.used_by": "Utilisé par", + "services_full.used_when_a_route_is_configured_to_deliver_through_the_service_n": "Utilisée lorsqu’une route est configurée pour envoyer les notifications via la politique de notification du service.", + "services_full.user": "Utilisateur", + "services_full.user_is_required": "L’utilisateur est requis.", + "services_full.warning": "Avertissement", + "services_full.web": "Web", + "services_full.wiki": "Wiki", + "services_full.window": "Fenêtre", + "services_full.window_days": "Fenêtre, jours", + "services_full.windows": "Fenêtres", + "services_full.worker": "Worker", + "services_full.worst_status": "Statut le plus grave", + "services_full.yes": "Oui", + "services_full.you_do_not_have_permission_to_edit_this_service": "Vous n’avez pas l’autorisation de modifier ce service.", + "services_full.you_do_not_have_permission_to_update_this_service": "Vous n’avez pas l’autorisation de mettre à jour ce service.", + "services_full.all_matching_alert_groups": "tous les groupes d’alertes correspondants", + "services_full.correlation_off_c0b08de1": "corrélation désactivée", + "services_full.correlation_on": "corrélation activée", + "services_full.cycle_detected_e29cd94b": "cycle détecté", + "services_full.day_window": "fenêtre en jours", + "services_full.effective_status_dd133723": "statut effectif", + "services_full.maintenance_excluded_80b4b014": "maintenance exclue", + "services_full.major_outages": "pannes majeures", + "services_full.no_snapshots": "aucun instantané", + "services_full.not_operational_43ac205c": "non opérationnel", + "services_full.of": "sur", + "services_full.over_budget_by": "dépassement du budget de", + "services_full.selected_window": "fenêtre sélectionnée", + "services_full.services_3e7aaa79": "services", + "services_full.services_in_impact_scope": "services dans le périmètre d’impact", + "services_full.upstream_downstream": "amont / aval", + "services_full.team_default_129a7b2f": "· Valeur par défaut de l’équipe" +} diff --git a/app/static/i18n/fr/shared_ui.json b/app/static/i18n/fr/shared_ui.json new file mode 100644 index 0000000..1d15721 --- /dev/null +++ b/app/static/i18n/fr/shared_ui.json @@ -0,0 +1,76 @@ +{ + "shared.action": "Action", + "shared.actions": "Actions", + "shared.edit": "Modifier", + "shared.enabled": "Activé", + "shared.disabled": "Désactivé", + "shared.dialog.message": "Message", + "shared.dialog.cancel": "Annuler", + "shared.dialog.ok": "OK", + "shared.dialog.success": "Succès", + "shared.dialog.done": "Terminé", + "shared.dialog.confirm_action": "Confirmer l’action", + "shared.dialog.are_you_sure": "Êtes-vous sûr ?", + "shared.dialog.confirm": "Confirmer", + "shared.dialog.close": "Fermer", + "shared.dialog.error": "Erreur", + "shared.dialog.unexpected_error": "Erreur inattendue", + "shared.json.invalid_title": "JSON non valide", + "shared.json.invalid_field": "{label} contient un JSON non valide :\n\n{error}", + "shared.json.invalid_selector": "JSON non valide dans {selector} : {error}", + "shared.pagination.rows": "Lignes par page", + "shared.pagination.previous": "Précédent", + "shared.pagination.next": "Suivant", + "shared.pagination.showing": "Affichage", + "shared.pagination.of": "sur", + "shared.pagination.page": "Page", + "shared.api.request_failed": "Échec de la requête", + "shared.api.validation_failed": "Échec de la validation", + "shared.api.field": "champ", + "shared.api.invalid_value": "Valeur non valide", + "shared.api.session_expired": "Votre session a expiré. Veuillez vous reconnecter.", + "shared.api.unauthorized": "Non autorisé", + "shared.api.validation_error": "Erreur de validation", + "shared.api.access_denied": "Accès refusé", + "shared.api.resource_not_found": "Ressource introuvable", + "shared.api.not_found": "Introuvable", + "shared.api.server_error": "Erreur du serveur", + "shared.api.api_error": "Erreur API", + "shared.select.all_groups": "Tous les groupes", + "shared.select.all_teams": "Toutes les équipes", + "shared.select.all_my_groups": "Tous mes groupes", + "shared.select.user": "Utilisateur n° {id}", + "shared.select.user_placeholder": "Sélectionner un utilisateur...", + "shared.select.no_users": "Aucun utilisateur trouvé", + "shared.timezone.select": "Sélectionner un fuseau horaire", + "shared.timezone.browser": "Utiliser le fuseau horaire du navigateur", + "shared.timezone.browser_named": "Utiliser le fuseau horaire du navigateur ({timezone})", + "shared.oncall.team": "Équipe", + "shared.oncall.rotation": "Rotation n° {id}", + "shared.oncall.override": "Remplacement", + "shared.oncall.layer": "Couche", + "shared.oncall.policy": "Politique n° {id}", + "shared.oncall.level": "niveau {level}", + "shared.oncall.after_minutes": "après {count} min", + "shared.oncall.primary_now": "Vous êtes actuellement l’astreinte principale", + "shared.oncall.oncall_now": "Vous êtes actuellement d’astreinte", + "shared.oncall.backup_now": "Vous êtes actuellement le relais d’escalade", + "shared.oncall.not_now": "Vous n’êtes pas d’astreinte actuellement", + "shared.oncall.backup": "Relais d’escalade :", + "shared.oncall.next_shifts": "Prochaines astreintes :", + "shared.oncall.next_primary": "Prochaines astreintes principales :", + "shared.oncall.next_backup": "Prochains relais d’escalade :", + "shared.oncall.no_upcoming": "Aucune astreinte prévue dans les {days} prochains jours.", + "shared.oncall.no_primary": "Aucune astreinte principale prévue dans les {days} prochains jours.", + "shared.oncall.unavailable": "Le statut d’astreinte est indisponible", + "shared.oncall.not_authenticated": "Non authentifié", + "shared.push.browser": "Navigateur", + "shared.push.android": "Navigateur Android", + "shared.push.ios": "Navigateur iOS", + "shared.push.windows": "Navigateur Windows", + "shared.push.macos": "Navigateur macOS", + "shared.push.linux": "Navigateur Linux", + "shared.push.unsupported": "Les notifications push ne sont pas prises en charge par ce navigateur.", + "shared.push.not_configured": "Les notifications push du navigateur ne sont pas configurées sur le serveur.", + "shared.push.permission_denied": "L’autorisation d’afficher des notifications n’a pas été accordée." +} diff --git a/app/static/i18n/fr/silences.json b/app/static/i18n/fr/silences.json new file mode 100644 index 0000000..5eeabd6 --- /dev/null +++ b/app/static/i18n/fr/silences.json @@ -0,0 +1,93 @@ +{ + "silences.summary.total": "Silences", + "silences.summary.total_hint": "Portée actuelle", + "silences.summary.active": "Actifs actuellement", + "silences.summary.active_hint": "Suppriment des alertes", + "silences.summary.scheduled": "Planifiés", + "silences.summary.scheduled_hint": "Commencent plus tard", + "silences.summary.expired": "Expirés", + "silences.summary.expired_hint": "Fenêtre terminée", + "silences.summary.disabled": "Désactivés", + "silences.summary.disabled_hint": "Non appliqués", + "silences.list.title": "Règles de silence", + "silences.list.showing": "Affichage", + "silences.list.of": "sur", + "silences.list.silences": "silences", + "silences.actions.new": "Nouveau silence", + "silences.actions.reload": "Recharger", + "silences.actions.edit": "Modifier", + "silences.actions.enable": "Activer", + "silences.actions.disable": "Désactiver", + "silences.actions.edit_silence": "Modifier le silence", + "silences.actions.enable_silence": "Activer le silence", + "silences.actions.disable_silence": "Désactiver le silence", + "silences.actions.reset": "Réinitialiser", + "silences.actions.save": "Enregistrer le silence", + "silences.search.placeholder": "Rechercher des silences...", + "silences.filters.all_statuses": "Tous les statuts", + "silences.status.active": "Actif actuellement", + "silences.status.scheduled": "Planifié", + "silences.status.expired": "Expiré", + "silences.status.disabled": "Désactivé", + "silences.table.silence": "Silence", + "silences.table.team": "Équipe", + "silences.table.reason": "Motif", + "silences.table.window": "Fenêtre", + "silences.table.matching": "Correspondance", + "silences.table.status": "Statut", + "silences.table.actions": "Actions", + "silences.empty.loaded": "Aucun silence chargé", + "silences.empty.found": "Aucun silence", + "silences.details.title": "Détails du silence", + "silences.details.select": "Sélectionner un silence", + "silences.details.select_help": "Cliquez sur le nom d’un silence pour examiner sa fenêtre, son motif et ses critères.", + "silences.details.name": "Nom", + "silences.details.team": "Équipe", + "silences.details.reason": "Motif", + "silences.details.starts_at": "Commence le", + "silences.details.ends_at": "Se termine le", + "silences.details.status": "Statut", + "silences.details.matcher_preset": "Préréglage de correspondance", + "silences.details.additional_matchers": "Critères supplémentaires", + "silences.form.create": "Créer un silence", + "silences.form.edit": "Modifier le silence n° {id}", + "silences.form.subtitle": "Mettez en silence les alertes correspondantes pour une équipe et une fenêtre sélectionnées.", + "silences.form.silence": "Silence", + "silences.form.silence_help": "Équipe, nom, motif et fenêtre active.", + "silences.form.team": "Équipe", + "silences.form.name": "Nom", + "silences.form.name_placeholder": "Fenêtre de maintenance", + "silences.form.reason": "Motif", + "silences.form.reason_placeholder": "Maintenance planifiée, alerte bruyante, suppression temporaire...", + "silences.form.starts_at": "Commence le", + "silences.form.ends_at": "Se termine le", + "silences.form.matchers": "Critères de correspondance", + "silences.form.matchers_help": "Filtres JSON utilisés pour faire correspondre les alertes.", + "silences.form.matcher_preset": "Préréglage de correspondance", + "silences.form.no_preset": "Aucun préréglage", + "silences.form.no_preset_hint": "Aucun préréglage sélectionné. Seuls les critères supplémentaires ci-dessous seront évalués.", + "silences.form.additional_matchers_json": "Critères supplémentaires au format JSON", + "silences.form.additional_matchers": "Critères supplémentaires", + "silences.form.additional_help": "Le préréglage et les critères supplémentaires sont combinés avec ET. Un objet {} vide signifie qu’aucune condition supplémentaire n’est appliquée.", + "silences.row.id": "Silence n° {id}", + "silences.row.until": "jusqu’à {time}", + "silences.row.preset": "Préréglage : {preset}", + "silences.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier ce silence.", + "silences.permissions.toggle": "Le rôle de responsable d’équipe est requis pour activer ou désactiver ce silence.", + "silences.permissions.edit_denied": "Vous n’êtes pas autorisé à modifier ce silence.", + "silences.permissions.disable_denied": "Vous n’êtes pas autorisé à désactiver ce silence.", + "silences.confirm.disable_title": "Désactiver ce silence ?", + "silences.confirm.disable_message": "Les silences désactivés ne suppriment plus les alertes correspondantes.", + "silences.confirm.disable": "Désactiver", + "silences.details.apply_to_existing": "Alertes existantes", + "silences.details.apply_to_existing_enabled": "Appliquer aux alertes non résolues correspondantes", + "silences.details.apply_to_existing_disabled": "Nouvelles alertes uniquement", + "silences.details.reactivate_on_end": "Après la fin du silence", + "silences.details.reactivate_on_end_enabled": "Réactiver les alertes concernées", + "silences.details.reactivate_on_end_disabled": "Conserver les alertes concernées en silence", + "silences.form.apply_to_existing": "Appliquer aux alertes non résolues existantes", + "silences.form.apply_to_existing_help": "Facultatif. Les alertes actives correspondantes seront immédiatement mises en silence. Les notifications déjà envoyées restent dans l’historique.", + "silences.form.reactivate_on_end": "Réactiver les alertes mises en silence à la fin de ce silence", + "silences.form.reactivate_on_end_help": "Activé par défaut. Les alertes ne sont réactivées qu’après la fin du dernier silence correspondant.", + "silences.form.reactivate_on_end_warning": "Les alertes concernées resteront en silence après la fin de ce silence. Activez cette option plus tard et enregistrez le silence pour les libérer." +} diff --git a/app/static/i18n/fr/sso.json b/app/static/i18n/fr/sso.json new file mode 100644 index 0000000..bdea05c --- /dev/null +++ b/app/static/i18n/fr/sso.json @@ -0,0 +1,151 @@ +{ + "sso.summary.providers": "Fournisseurs", + "sso.summary.providers_hint": "Sources de connexion", + "sso.summary.enabled": "Activés", + "sso.summary.enabled_hint": "Affichés sur la page de connexion", + "sso.summary.disabled": "Désactivés", + "sso.summary.disabled_hint": "Masqués sur la page de connexion", + "sso.summary.oidc_hint": "OpenID Connect", + "sso.summary.saml_hint": "SAML 2.0", + "sso.list.title": "Fournisseurs SSO", + "sso.list.of": "sur", + "sso.list.providers": "fournisseurs", + "sso.actions.add_provider": "Ajouter un fournisseur", + "sso.actions.reload": "Recharger", + "sso.search.placeholder": "Rechercher un fournisseur", + "sso.filters.all_protocols": "Tous les protocoles", + "sso.filters.all_statuses": "Tous les statuts", + "sso.table.id": "ID", + "sso.table.provider": "Fournisseur", + "sso.table.protocol": "Protocole", + "sso.table.status": "Statut", + "sso.table.auto_create": "Création automatique", + "sso.table.sync_groups": "Synchronisation des groupes", + "sso.table.actions": "Actions", + "sso.status.enabled": "Activé", + "sso.status.disabled": "Désactivé", + "sso.values.yes": "Oui", + "sso.values.no": "Non", + "sso.empty.providers": "Aucun fournisseur SSO", + "sso.actions.mappings": "Mappages", + "sso.actions.edit": "Modifier", + "sso.actions.test": "Tester", + "sso.actions.metadata": "Métadonnées", + "sso.actions.enable": "Activer", + "sso.actions.disable": "Désactiver", + "sso.actions.delete": "Supprimer", + "sso.actions.reset": "Réinitialiser", + "sso.actions.save_provider": "Enregistrer le fournisseur", + "sso.actions.fetch_metadata": "Récupérer les métadonnées", + "sso.actions.add_mapping": "Ajouter un mappage", + "sso.actions.save_mapping": "Enregistrer le mappage", + "sso.actions.close": "Fermer", + "sso.provider.new": "Nouveau fournisseur SSO", + "sso.provider.edit": "Modifier le fournisseur SSO", + "sso.provider.subtitle": "Configurer la connexion OIDC ou SAML.", + "sso.provider.slug": "Slug", + "sso.provider.label": "Libellé", + "sso.provider.protocol": "Protocole", + "sso.provider.enabled": "Activé", + "sso.claims.title": "Attributs", + "sso.claims.subject": "Attribut du sujet", + "sso.claims.email": "Attribut de l’e-mail", + "sso.claims.username": "Attribut du nom d’utilisateur", + "sso.claims.display_name": "Attribut du nom affiché", + "sso.claims.phone": "Attribut du téléphone", + "sso.claims.groups": "Attribut des groupes", + "sso.claims.allowed_domains": "Domaines autorisés", + "sso.policy.title": "Politique de connexion", + "sso.policy.auto_create": "Créer automatiquement les utilisateurs", + "sso.policy.auto_link": "Associer automatiquement par e-mail", + "sso.policy.verified_email": "Exiger un e-mail vérifié", + "sso.policy.sync_memberships": "Synchroniser les appartenances aux groupes", + "sso.policy.remove_missing": "Supprimer les appartenances absentes", + "sso.oidc.title": "Paramètres OIDC", + "sso.oidc.client_id": "ID client", + "sso.oidc.client_secret": "Secret client", + "sso.oidc.issuer": "URL de l’émetteur", + "sso.oidc.metadata_url": "URL des métadonnées", + "sso.oidc.scope": "Portée", + "sso.oidc.advanced": "Points de terminaison OIDC avancés", + "sso.oidc.authorization": "Point de terminaison d’autorisation", + "sso.oidc.token": "Point de terminaison du jeton", + "sso.oidc.userinfo": "Point de terminaison UserInfo", + "sso.oidc.jwks": "URI JWKS", + "sso.saml.title": "Paramètres SAML", + "sso.saml.metadata_url": "URL des métadonnées de l’IdP", + "sso.saml.entity_id": "ID d’entité de l’IdP", + "sso.saml.sso_url": "URL SSO de l’IdP", + "sso.saml.slo_url": "URL SLO de l’IdP", + "sso.saml.idp_cert": "Certificat x509 de l’IdP", + "sso.saml.sp_entity_id": "ID d’entité du SP", + "sso.saml.acs_url": "URL ACS du SP", + "sso.saml.sls_url": "URL SLS du SP", + "sso.saml.sp_cert": "Certificat x509 du SP", + "sso.saml.private_key": "Clé privée du SP", + "sso.saml.nameid_format": "Format NameID", + "sso.saml.security": "Sécurité SAML", + "sso.saml.sign_authn": "Signer AuthnRequest", + "sso.saml.sign_logout_request": "Signer LogoutRequest", + "sso.saml.sign_logout_response": "Signer LogoutResponse", + "sso.saml.sign_metadata": "Signer les métadonnées du SP", + "sso.saml.require_signed_responses": "Exiger des réponses signées", + "sso.saml.require_signed_assertions": "Exiger des assertions signées", + "sso.saml.require_encrypted_nameid": "Exiger un NameID chiffré", + "sso.saml.require_encrypted_assertions": "Exiger des assertions chiffrées", + "sso.saml.require_attributes": "Exiger AttributeStatement", + "sso.placeholder.keep_existing": "Laissez vide pour conserver la valeur existante", + "sso.placeholder.use_metadata": "Laissez vide pour utiliser l’URL des métadonnées", + "sso.placeholder.use_callback": "Laissez vide pour utiliser l’URL de rappel", + "sso.placeholder.optional_logout": "URL de déconnexion unique facultative", + "sso.placeholder.signing_required": "Requis pour signer AuthnRequest, la déconnexion ou les métadonnées", + "sso.mappings.new": "Nouveau mappage de groupe", + "sso.mappings.edit": "Modifier le mappage de groupe", + "sso.mappings.subtitle": "Associer un groupe SSO à un groupe IncidentRelay.", + "sso.mappings.external_group": "Groupe SSO externe", + "sso.mappings.ir_group": "Groupe IncidentRelay", + "sso.mappings.group_role": "Rôle dans le groupe", + "sso.mappings.global_admin": "Administrateur global", + "sso.mappings.ir_team": "Équipe IncidentRelay", + "sso.mappings.team_help": "Facultatif. Lorsqu’une équipe est sélectionnée, les utilisateurs correspondants y sont également ajoutés.", + "sso.mappings.team_role": "Rôle dans l’équipe", + "sso.mappings.priority": "Priorité", + "sso.mappings.enabled": "Activé", + "sso.mappings.title": "Mappages de groupes", + "sso.mappings.title_provider": "Mappages : {provider}", + "sso.mappings.list_subtitle": "Associer les groupes SSO externes aux groupes IncidentRelay.", + "sso.mappings.provider_subtitle": "Groupes SSO externes associés aux groupes IncidentRelay pour le fournisseur « {provider} ».", + "sso.mappings.select_provider": "Sélectionnez d’abord un fournisseur SSO.", + "sso.mappings.loading": "Chargement des mappages...", + "sso.mappings.empty": "Aucun mappage de groupe pour ce fournisseur.", + "sso.mappings.no_team": "Aucun mappage d’équipe", + "sso.mappings.group_fallback": "Groupe", + "sso.mappings.team_fallback": "Équipe", + "sso.roles.group.viewer": "Lecteur du groupe", + "sso.roles.group.editor": "Éditeur du groupe", + "sso.roles.group.user_admin": "Administrateur du groupe", + "sso.roles.group.global_admin": "Administrateur global", + "sso.roles.team.viewer": "Lecteur de l’équipe", + "sso.roles.team.responder": "Intervenant de l’équipe", + "sso.roles.team.manager": "Responsable de l’équipe", + "sso.validation.slug_label": "Le slug et le libellé sont requis", + "sso.validation.oidc_client": "L’ID client est requis pour un fournisseur OIDC", + "sso.validation.saml_idp": "L’ID d’entité et l’URL SSO de l’IdP sont requis pour un fournisseur SAML", + "sso.validation.select_provider": "Sélectionnez d’abord un fournisseur SSO", + "sso.validation.external_group": "Le groupe SSO externe est requis", + "sso.validation.ir_group": "Le groupe IncidentRelay est requis", + "sso.confirm.delete_provider_title": "Supprimer le fournisseur SSO ?", + "sso.confirm.delete_provider_message": "Le fournisseur « {provider} » sera désactivé et supprimé. Les mappages de groupes seront désactivés.", + "sso.confirm.delete_mapping_title": "Supprimer le mappage de groupe ?", + "sso.confirm.delete_mapping_message": "Le mappage du groupe externe « {group} » sera supprimé.", + "sso.confirm.enable_title": "Activer le fournisseur SSO", + "sso.confirm.disable_title": "Désactiver le fournisseur SSO", + "sso.confirm.toggle_message": "Voulez-vous vraiment {action} « {provider} » ?", + "sso.confirm.enable_action": "activer", + "sso.confirm.disable_action": "désactiver", + "sso.provider.fallback": "Fournisseur SSO", + "sso.metadata.url_required": "L’URL des métadonnées est requise.", + "sso.metadata.fetching": "Récupération des métadonnées...", + "sso.metadata.loaded": "Métadonnées chargées. Vérifiez les valeurs, puis enregistrez le fournisseur.", + "sso.metadata.failed": "Impossible de récupérer les métadonnées." +} diff --git a/app/static/i18n/fr/teams.json b/app/static/i18n/fr/teams.json new file mode 100644 index 0000000..c69268d --- /dev/null +++ b/app/static/i18n/fr/teams.json @@ -0,0 +1,122 @@ +{ + "teams.summary.teams": "Équipes", + "teams.summary.teams_hint": "Portée actuelle", + "teams.summary.active": "Actives", + "teams.summary.active_hint": "Équipes activées", + "teams.summary.escalation": "Escalade", + "teams.summary.escalation_hint": "Escalade simple par rotation", + "teams.summary.groups": "Groupes", + "teams.summary.groups_hint": "Groupes utilisés", + "teams.list.title": "Équipes", + "teams.list.showing": "Affichage", + "teams.list.of": "sur", + "teams.list.teams": "équipes", + "teams.actions.new": "Nouvelle équipe", + "teams.actions.reload": "Recharger", + "teams.actions.edit": "Modifier", + "teams.actions.edit_team": "Modifier l’équipe", + "teams.actions.details": "Détails", + "teams.actions.members": "Membres", + "teams.actions.enable": "Activer", + "teams.actions.disable": "Désactiver", + "teams.actions.remove": "Supprimer", + "teams.actions.reset": "Réinitialiser", + "teams.actions.save": "Enregistrer l’équipe", + "teams.actions.save_membership": "Enregistrer l’appartenance", + "teams.actions.close": "Fermer", + "teams.search.placeholder": "Rechercher des équipes...", + "teams.filters.all_groups": "Tous les groupes", + "teams.filters.all_statuses": "Tous les statuts", + "teams.filters.active": "Actives", + "teams.filters.inactive": "Inactives", + "teams.table.team": "Équipe", + "teams.table.group": "Groupe", + "teams.table.slug": "Slug", + "teams.table.escalation": "Escalade", + "teams.table.health": "Santé", + "teams.table.status": "Statut", + "teams.table.actions": "Actions", + "teams.table.id": "ID", + "teams.table.user": "Utilisateur", + "teams.table.name": "Nom", + "teams.table.role": "Rôle", + "teams.table.active": "Actif", + "teams.empty.teams": "Aucune équipe", + "teams.empty.no_groups": "Aucun groupe disponible", + "teams.empty.select_team": "Aucune équipe sélectionnée", + "teams.empty.members": "Aucun membre", + "teams.status.active": "Active", + "teams.status.inactive": "Inactive", + "teams.status.enabled": "Activée", + "teams.status.disabled": "Désactivée", + "teams.row.team_id": "Équipe n° {id}", + "teams.row.escalation_simple": "Simple après {count}", + "teams.details.title": "Détails de l’équipe", + "teams.details.select": "Sélectionner une équipe", + "teams.details.select_help": "Cliquez sur le nom d’une équipe pour examiner son groupe, ses paramètres d’escalade et ses actions rapides.", + "teams.details.name": "Nom", + "teams.details.slug": "Slug", + "teams.details.group": "Groupe", + "teams.details.description": "Description", + "teams.details.simple_escalation": "Escalade simple par rotation", + "teams.details.after_reminders": "Escalade simple après les rappels", + "teams.details.policy_mode": "Mode politique", + "teams.details.policy_mode_help": "Les routes associées à une politique d’escalade utilisent les délais des règles de cette politique", + "teams.details.status": "Statut", + "teams.form.create": "Créer une équipe", + "teams.form.edit": "Modifier l’équipe n° {id}", + "teams.form.subtitle": "Créez ou modifiez une équipe IncidentRelay.", + "teams.form.group": "Groupe", + "teams.form.name": "Nom", + "teams.form.name_placeholder": "Infrastructure", + "teams.form.slug": "Slug", + "teams.form.slug_placeholder": "infra", + "teams.form.escalation_after": "Escalade simple après les rappels", + "teams.form.escalation_help": "Utilisée uniquement par les routes en mode rotation simple. Les routes associées à une politique d’escalade utilisent les délais des règles de cette politique.", + "teams.form.description": "Description", + "teams.form.description_placeholder": "Objectif de l’équipe, responsabilités ou notes de routage", + "teams.form.simple_escalation": "Escalade simple par rotation", + "teams.form.active": "Active", + "teams.members.title": "Membres de l’équipe", + "teams.members.title_named": "Membres de l’équipe : {name}", + "teams.members.subtitle": "Gérez les utilisateurs, les rôles et le statut des appartenances.", + "teams.members.membership": "Appartenance", + "teams.members.membership_help": "Ajoutez ou mettez à jour un utilisateur dans cette équipe.", + "teams.members.selected_team": "Équipe sélectionnée", + "teams.members.user": "Utilisateur", + "teams.members.user_placeholder": "Sélectionner un utilisateur...", + "teams.members.role": "Rôle", + "teams.members.active": "Actif", + "teams.members.current": "Membres", + "teams.members.current_help": "Utilisateurs actuels de l’équipe sélectionnée.", + "teams.validation.team_not_found": "Équipe introuvable.", + "teams.validation.select_team": "Sélectionnez d’abord une équipe.", + "teams.validation.select_group": "Sélectionnez d’abord un groupe.", + "teams.validation.user_required": "L’utilisateur est requis.", + "teams.permissions.edit": "Le rôle de responsable d’équipe est requis pour modifier cette équipe.", + "teams.permissions.members": "Le rôle de responsable d’équipe est requis pour gérer les membres de cette équipe.", + "teams.permissions.toggle": "Le rôle de responsable d’équipe est requis pour activer ou désactiver cette équipe.", + "teams.permissions.delete": "L’autorisation de suppression est requise pour supprimer cette équipe.", + "teams.permissions.manage_members": "Vous n’êtes pas autorisé à gérer les membres de cette équipe.", + "teams.permissions.edit_member": "Le rôle de responsable d’équipe est requis pour modifier les membres.", + "teams.permissions.toggle_member": "Le rôle de responsable d’équipe est requis pour activer ou désactiver les membres.", + "teams.permissions.remove_member": "Le rôle de responsable d’équipe est requis pour supprimer les membres.", + "teams.permissions.edit_denied": "Vous n’êtes pas autorisé à modifier cette équipe.", + "teams.permissions.update_denied": "Vous n’êtes pas autorisé à mettre à jour cette équipe.", + "teams.permissions.remove_denied": "Vous n’êtes pas autorisé à supprimer cette équipe.", + "teams.confirm.title": "Êtes-vous sûr ?", + "teams.confirm.enable_action": "activer", + "teams.confirm.disable_action": "désactiver", + "teams.confirm.toggle_member": "Voulez-vous vraiment {action} ce membre de l’équipe ?", + "teams.confirm.toggle_team": "Voulez-vous vraiment {action} cette équipe ?", + "teams.confirm.remove_member_title": "Suppression d’un membre de l’équipe", + "teams.confirm.remove_member_message": "Retirer cet utilisateur de l’équipe et de toutes ses rotations ?", + "teams.confirm.remove_team_title": "Supprimer l’équipe", + "teams.confirm.remove_team_message": "Supprimer l’équipe « {team} » ?\n\nCette opération supprimera les rotations, routes, canaux de notification, silences,\nappartenances à l’équipe et associations route-canal de cette équipe.\n\nLes alertes historiques seront conservées.\n\nContinuer ?", + "rbac.group.viewer": "Lecteur du groupe", + "rbac.group.editor": "Éditeur du groupe", + "rbac.group.admin": "Administrateur du groupe", + "rbac.team.viewer": "Lecteur de l’équipe", + "rbac.team.responder": "Intervenant de l’équipe", + "rbac.team.manager": "Responsable de l’équipe" +} diff --git a/app/static/i18n/fr/users.json b/app/static/i18n/fr/users.json new file mode 100644 index 0000000..3391881 --- /dev/null +++ b/app/static/i18n/fr/users.json @@ -0,0 +1,95 @@ +{ + "users.summary.users": "Utilisateurs", + "users.summary.users_hint": "Utilisateurs enregistrés", + "users.summary.active": "Actifs", + "users.summary.active_hint": "Utilisateurs activés", + "users.summary.inactive": "Inactifs", + "users.summary.inactive_hint": "Utilisateurs désactivés", + "users.summary.admins": "Administrateurs", + "users.summary.admins_hint": "Administrateurs globaux", + "users.list.title": "Utilisateurs", + "users.list.subtitle": "Gérez les utilisateurs locaux, les identifiants de notification et les accès administrateur.", + "users.search.placeholder": "Rechercher des utilisateurs...", + "users.actions.reload": "Recharger", + "users.actions.new": "Nouvel utilisateur", + "users.actions.edit": "Modifier", + "users.actions.enable": "Activer", + "users.actions.disable": "Désactiver", + "users.actions.remove": "Supprimer", + "users.actions.save": "Enregistrer l’utilisateur", + "users.actions.reset": "Réinitialiser", + "users.actions.close": "Fermer", + "users.table.id": "ID", + "users.table.user": "Utilisateur", + "users.table.contacts": "Coordonnées", + "users.table.messengers": "Messageries", + "users.table.role": "Rôle", + "users.table.status": "Statut", + "users.table.actions": "Actions", + "users.empty": "Aucun utilisateur", + "users.row.user_fallback": "Utilisateur n° {id}", + "users.row.admin": "Administrateur", + "users.row.user": "Utilisateur", + "users.row.active": "Actif", + "users.row.inactive": "Inactif", + "users.row.no_email": "Aucune adresse e-mail", + "users.row.no_phone": "Aucun numéro de téléphone", + "users.row.telegram": "Telegram : {id}", + "users.row.slack": "Slack : {id}", + "users.row.mattermost": "Mattermost : {id}", + "users.row.current": "Utilisateur actuel", + "users.pagination.rows": "Lignes par page", + "users.modal.details": "Détails de l’utilisateur", + "users.modal.details_subtitle": "Créez ou mettez à jour un compte utilisateur.", + "users.modal.new": "Nouvel utilisateur", + "users.modal.new_subtitle": "Créez un compte utilisateur local.", + "users.modal.user_fallback": "Utilisateur n° {id}", + "users.form.account": "Compte", + "users.form.account_help": "Identifiant, nom d’affichage et indicateurs d’accès.", + "users.form.username": "Nom d’utilisateur", + "users.form.display_name": "Nom d’affichage", + "users.form.password": "Mot de passe", + "users.form.password_keep": "Laisser vide pour conserver le mot de passe actuel", + "users.form.group": "Groupe", + "users.form.group_help": "Les administrateurs globaux peuvent attribuer ou mettre à jour le groupe et le rôle par défaut de cet utilisateur.", + "users.form.group_help_manage": "Les administrateurs globaux peuvent attribuer ou mettre à jour le groupe et le rôle par défaut de cet utilisateur. Les appartenances supplémentaires se gèrent depuis la page Groupes.", + "users.form.group_help_disabled": "Les contrôles de groupe sont désactivés.", + "users.form.group_role": "Rôle dans le groupe", + "users.form.global_admin": "Administrateur global", + "users.form.active": "Actif", + "users.form.contacts": "Coordonnées", + "users.form.contacts_help": "Utilisées par les canaux de notification et les intégrations.", + "users.form.email": "E-mail", + "users.form.phone": "Téléphone", + "users.form.telegram": "ID utilisateur Telegram", + "users.form.slack": "ID utilisateur Slack", + "users.form.mattermost": "ID utilisateur Mattermost", + "users.form.no_group": "Ne pas ajouter à un groupe", + "users.security.title": "Remarque de sécurité", + "users.security.subtitle": "Le mot de passe est uniquement accessible en écriture et n’est jamais affiché après l’enregistrement.", + "users.security.create": "Créer l’utilisateur", + "users.security.create_help": "La validation exige un mot de passe lors de la création d’un nouvel utilisateur.", + "users.security.update": "Mettre à jour l’utilisateur", + "users.security.update_help": "Laissez le mot de passe vide pour conserver l’empreinte actuelle.", + "users.security.disable": "Désactiver l’utilisateur", + "users.security.disable_help": "Les utilisateurs désactivés restent visibles dans l’espace d’administration et peuvent être réactivés. Un administrateur global ne peut pas désactiver ni supprimer son propre compte.", + "users.validation.username": "Le nom d’utilisateur est requis", + "users.validation.password": "Un mot de passe est requis pour un nouvel utilisateur", + "users.self.disable_help": "Vous ne pouvez pas désactiver votre propre compte. Demandez à un autre administrateur global de le faire.", + "users.self.disable_error": "Vous ne pouvez pas désactiver votre propre compte.", + "users.self.remove_error": "Vous ne pouvez pas supprimer votre propre compte.", + "users.permissions.group_admin_help": "Les administrateurs de groupe ne peuvent pas activer ou désactiver globalement les comptes utilisateur. Utilisez le statut de l’appartenance sur la page Groupes.", + "users.confirm.title": "Êtes-vous sûr ?", + "users.confirm.toggle": "Voulez-vous vraiment {action} cet utilisateur ?", + "users.confirm.enable": "activer", + "users.confirm.disable": "désactiver", + "users.confirm.remove_title": "Supprimer l’utilisateur ?", + "users.confirm.remove_message": "Supprimer l’utilisateur « {user} » ?\n\nCette opération révoquera ses jetons API personnels et le retirera des groupes,\ndes équipes, des rotations et des remplacements.\n\nLes alertes historiques seront conservées.\n\nContinuer ?", + "users.confirm.remove": "Supprimer l’utilisateur", + "rbac.group.viewer": "Lecteur du groupe", + "rbac.group.editor": "Éditeur du groupe", + "rbac.group.admin": "Administrateur du groupe", + "rbac.team.viewer": "Lecteur de l’équipe", + "rbac.team.responder": "Intervenant de l’équipe", + "rbac.team.manager": "Responsable de l’équipe" +} diff --git a/app/static/i18n/ru/audit_logs.json b/app/static/i18n/ru/audit_logs.json new file mode 100644 index 0000000..6b39f9c --- /dev/null +++ b/app/static/i18n/ru/audit_logs.json @@ -0,0 +1,64 @@ +{ + "nav.audit_log": "Журнал аудита", + "pages.audit-log.title": "Журнал аудита", + "pages.audit-log.subtitle": "История административных действий и изменений безопасности", + "audit.summary.entries": "Записи", + "audit.summary.entries_hint": "Записи, соответствующие текущим фильтрам", + "audit.summary.actors": "Исполнители", + "audit.summary.actors_hint": "Пользователи в выбранных результатах", + "audit.summary.actions": "Действия", + "audit.summary.actions_hint": "Уникальные типы действий в результатах", + "audit.summary.groups": "Группы", + "audit.summary.groups_hint": "Группы, доступные вашей учетной записи", + "audit.list.title": "Административные действия", + "audit.list.subtitle": "Просматривайте изменения, выполненные через интерфейс и API.", + "audit.list.global_scope": "Глобальные администраторы видят все записи аудита, включая глобальные записи без группы.", + "audit.list.editor_scope": "Редакторы групп видят только записи групп, в которых у них назначена роль editor.", + "audit.actions.reload": "Обновить", + "audit.actions.clear_filters": "Сбросить фильтры", + "audit.actions.view": "Открыть", + "audit.actions.close": "Закрыть", + "audit.filters.search": "Поиск", + "audit.filters.search_placeholder": "Действие, объект, сообщение или исполнитель", + "audit.filters.group": "Группа", + "audit.filters.all_groups": "Все доступные группы", + "audit.filters.actor": "Исполнитель", + "audit.filters.all_actors": "Все исполнители", + "audit.filters.action": "Действие", + "audit.filters.all_actions": "Все действия", + "audit.filters.object_type": "Тип объекта", + "audit.filters.all_object_types": "Все типы объектов", + "audit.filters.date_from": "С", + "audit.filters.date_to": "По", + "audit.table.time": "Время", + "audit.table.actor": "Исполнитель", + "audit.table.action": "Действие", + "audit.table.object": "Объект", + "audit.table.scope": "Область", + "audit.table.message": "Сообщение", + "audit.table.details": "Подробности", + "audit.row.system": "Система", + "audit.row.api_token": "API-токен: {name}", + "audit.row.global_scope": "Глобальная", + "audit.row.team": "Команда: {name}", + "audit.row.not_available": "Нет данных", + "audit.empty": "По текущим фильтрам записи аудита не найдены.", + "audit.pagination.rows": "Строк", + "audit.pagination.page": "Страница", + "audit.pagination.page_value": "Страница {page} / {total}", + "audit.pagination.range": "{from}–{to} / {total}", + "audit.pagination.previous": "Назад", + "audit.pagination.next": "Вперед", + "audit.details.title": "Запись аудита", + "audit.details.entry": "Запись аудита №{id}", + "audit.details.time": "Время", + "audit.details.actor": "Исполнитель", + "audit.details.action": "Действие", + "audit.details.object": "Объект", + "audit.details.group": "Группа", + "audit.details.team": "Команда", + "audit.details.message": "Сообщение", + "audit.details.payload": "Сохраненные данные", + "audit.details.payload_hint": "Секретные значения скрываются до сохранения записи аудита.", + "audit.errors.access_denied": "Для просмотра журнала аудита нужна роль глобального администратора или редактора группы." +} diff --git a/app/static/i18n/ru/maintenance.json b/app/static/i18n/ru/maintenance.json index 31a188f..b05c88b 100644 --- a/app/static/i18n/ru/maintenance.json +++ b/app/static/i18n/ru/maintenance.json @@ -134,5 +134,13 @@ "maintenance.permissions.duplicate": "Для дублирования окна требуется роль менеджера команды.", "maintenance.permissions.cancel": "Для отмены окна требуется роль менеджера команды.", "maintenance.permissions.delete": "Для удаления окна требуется разрешение на удаление.", - "maintenance.badge.default": "Технические работы" + "maintenance.badge.default": "Технические работы", + "maintenance.form.apply_to_existing": "Применить к существующим незакрытым алертам", + "maintenance.form.apply_to_existing_help": "Необязательно. Подходящие незакрытые группы алертов, существовавшие до начала окна, будут затронуты сразу.", + "maintenance.form.apply_to_existing_unavailable": "Suppress incident предотвращает создание новой группы и не может применяться задним числом.", + "maintenance.form.reactivate_on_end": "Повторно активировать затронутые алерты после завершения обслуживания", + "maintenance.form.reactivate_on_end_help": "Включено по умолчанию. Эффект снимается только после завершения последнего применимого окна обслуживания.", + "maintenance.form.reactivate_on_end_warning": "После завершения окна затронутые алерты сохранят эффект обслуживания. Чтобы освободить их позже, включите эту опцию и сохраните окно.", + "maintenance.details.apply_to_existing": "Существующие алерты", + "maintenance.details.reactivate_on_end": "Активация после завершения" } diff --git a/app/static/i18n/ru/orchestrations.json b/app/static/i18n/ru/orchestrations.json new file mode 100644 index 0000000..c4be556 --- /dev/null +++ b/app/static/i18n/ru/orchestrations.json @@ -0,0 +1,137 @@ +{ + "nav.event_orchestration": "Оркестрация событий", + "pages.orchestrations.title": "Оркестрация событий", + "pages.orchestrations.subtitle": "Маршрутизация, обогащение, подавление и автоматизация входящих событий", + "orchestrations.summary.total": "Оркестрации", + "orchestrations.summary.total_hint": "Определения в выбранной группе", + "orchestrations.summary.active": "Активные", + "orchestrations.summary.active_hint": "Применяются к production-событиям", + "orchestrations.summary.shadow": "Теневые", + "orchestrations.summary.shadow_hint": "Проверяются без изменения поведения", + "orchestrations.summary.drafts": "Черновики", + "orchestrations.summary.drafts_hint": "Неопубликованные рабочие версии", + "orchestrations.list.title": "Оркестрации событий", + "orchestrations.list.items": "оркестраций", + "orchestrations.actions.create": "Новая оркестрация", + "orchestrations.actions.reload": "Обновить", + "orchestrations.actions.open": "Открыть", + "orchestrations.actions.back": "Назад", + "orchestrations.actions.save_draft": "Сохранить черновик", + "orchestrations.actions.validate": "Проверить", + "orchestrations.actions.publish": "Опубликовать", + "orchestrations.actions.add_rule": "Добавить правило", + "orchestrations.actions.json_view": "JSON", + "orchestrations.actions.builder_view": "Конструктор", + "orchestrations.actions.apply_json": "Применить JSON", + "orchestrations.actions.format_json": "Форматировать JSON", + "orchestrations.actions.run_simulation": "Запустить симуляцию", + "orchestrations.actions.view": "Просмотр", + "orchestrations.actions.rollback": "Откатить", + "orchestrations.actions.trace": "Трассировка", + "orchestrations.actions.add_webhook": "Добавить webhook", + "orchestrations.actions.save": "Сохранить", + "orchestrations.actions.save_runtime": "Сохранить режим", + "orchestrations.actions.delete": "Удалить", + "orchestrations.actions.edit": "Изменить", + "orchestrations.actions.duplicate": "Дублировать", + "orchestrations.actions.save_rule": "Сохранить правило", + "orchestrations.search.placeholder": "Поиск оркестраций", + "orchestrations.filters.all_modes": "Все режимы", + "orchestrations.filters.all_scopes": "Все области", + "orchestrations.mode.active": "Активна", + "orchestrations.mode.shadow": "Теневая", + "orchestrations.mode.disabled": "Отключена", + "orchestrations.scope.global": "Глобальная", + "orchestrations.scope.service": "Сервис", + "orchestrations.table.name": "Название", + "orchestrations.table.scope": "Область", + "orchestrations.table.mode": "Режим", + "orchestrations.table.compatibility": "Совместимость", + "orchestrations.table.version": "Версия", + "orchestrations.table.updated": "Обновлено", + "orchestrations.table.actions": "Действия", + "orchestrations.empty.loading": "Загрузка оркестраций...", + "orchestrations.empty.none": "Оркестрации не найдены", + "orchestrations.tabs.rules": "Правила", + "orchestrations.tabs.simulator": "Симулятор", + "orchestrations.tabs.versions": "Версии", + "orchestrations.tabs.executions": "Выполнения", + "orchestrations.tabs.webhooks": "Webhook-действия", + "orchestrations.tabs.settings": "Настройки", + "orchestrations.rules.title": "Упорядоченные правила", + "orchestrations.rules.help": "Правила выполняются сверху вниз. Используйте вложенные группы для AND, OR и NOT.", + "orchestrations.rules.definition_json": "JSON определения", + "orchestrations.rules.empty": "В черновике пока нет правил.", + "orchestrations.rules.catch_all": "Совпадает со всеми событиями", + "orchestrations.rules.no_actions": "Нет действий", + "orchestrations.rules.no_draft": "Нет черновика", + "orchestrations.simulator.input": "Входное событие", + "orchestrations.simulator.help": "Проверка черновика без создания алертов и вызова webhook.", + "orchestrations.simulator.source": "Формат входа", + "orchestrations.simulator.normalized": "Нормализованное событие", + "orchestrations.simulator.payload": "JSON события", + "orchestrations.simulator.compare_active": "Сравнить с активной версией", + "orchestrations.simulator.result": "Результат симуляции", + "orchestrations.simulator.result_help": "Полная детерминированная трассировка правил и действий.", + "orchestrations.simulator.no_result": "Запустите симуляцию, чтобы увидеть результат.", + "orchestrations.versions.title": "История версий", + "orchestrations.versions.status": "Статус", + "orchestrations.versions.comment": "Комментарий", + "orchestrations.versions.hash": "Хеш определения", + "orchestrations.versions.published": "Опубликовано", + "orchestrations.versions.selected_definition": "Выбранное определение", + "orchestrations.versions.active_diff": "Разница с активной", + "orchestrations.versions.no_diff": "Различий нет", + "orchestrations.executions.title": "История выполнений", + "orchestrations.executions.source": "Источник", + "orchestrations.executions.disposition": "Результат", + "orchestrations.executions.matches": "Совпавшие правила", + "orchestrations.executions.duration": "Длительность", + "orchestrations.executions.created": "Создано", + "orchestrations.webhooks.title": "Webhook-действия", + "orchestrations.webhooks.help": "Переиспользуемые зашифрованные исходящие действия, выполняемые асинхронно.", + "orchestrations.webhooks.name": "Название", + "orchestrations.webhooks.method": "Метод", + "orchestrations.webhooks.retry": "Повторы", + "orchestrations.webhooks.status": "Статус", + "orchestrations.webhooks.editor_title": "Webhook-действие", + "orchestrations.webhooks.secret_help": "Заголовки шифруются и никогда не возвращаются API.", + "orchestrations.webhooks.headers": "JSON секретных заголовков", + "orchestrations.webhooks.body": "Шаблон тела", + "orchestrations.webhooks.timeout": "Таймаут, секунд", + "orchestrations.webhooks.delete_title": "Удалить webhook-действие", + "orchestrations.webhooks.delete_message": "Действие больше не будет доступно правилам оркестрации.", + "orchestrations.settings.metadata": "Метаданные", + "orchestrations.settings.runtime": "Runtime", + "orchestrations.settings.runtime_help": "Для включения active или shadow необходима опубликованная версия.", + "orchestrations.form.name": "Название", + "orchestrations.form.description": "Описание", + "orchestrations.form.scope": "Область", + "orchestrations.form.service": "Сервис", + "orchestrations.form.compatibility": "Режим совместимости", + "orchestrations.form.mode": "Runtime-режим", + "orchestrations.create.title": "Создать оркестрацию событий", + "orchestrations.create.help": "Создаёт отключённую оркестрацию с начальным черновиком.", + "orchestrations.rule_editor.title": "Редактор правила", + "orchestrations.rule_editor.help": "Создавайте условия и действия без ручного JSON.", + "orchestrations.rule_editor.processing_mode": "После совпадения", + "orchestrations.rule_editor.enabled": "Включено", + "orchestrations.rule_editor.disabled": "Отключено", + "orchestrations.rule_editor.conditions": "Условия", + "orchestrations.rule_editor.condition": "Условие", + "orchestrations.rule_editor.group": "Группа", + "orchestrations.rule_editor.actions": "Действия", + "orchestrations.rule_editor.action": "Действие", + "orchestrations.validation.valid": "Черновик валиден.", + "orchestrations.publish.title": "Опубликовать черновик", + "orchestrations.publish.message": "Опубликовать неизменяемую версию и сделать её активной? Безусловный drop требует явного подтверждения.", + "orchestrations.rollback.title": "Откатить версию", + "orchestrations.rollback.message": "Опубликовать новую неизменяемую копию исторической версии?", + "orchestrations.delete.title": "Удалить оркестрацию", + "orchestrations.delete.message": "Отключить и архивировать эту оркестрацию?", + "orchestrations.errors.invalid_json": "Некорректный JSON", + "orchestrations.errors.rules_required": "Определение должно содержать массив rules", + "orchestrations.webhooks.private_network_policy": "Политика приватных сетей", + "orchestrations.edit.title": "Редактирование оркестрации событий", + "orchestrations.edit.help": "Измените метаданные, область применения и настройки выполнения." +} diff --git a/app/static/i18n/ru/profile.json b/app/static/i18n/ru/profile.json index de5474b..2054f96 100644 --- a/app/static/i18n/ru/profile.json +++ b/app/static/i18n/ru/profile.json @@ -7,6 +7,15 @@ "profile.tabs.tokens": "API-токены", "profile.personal.title": "Личные данные", "profile.personal.subtitle": "Контактные данные, используемые каналами уведомлений.", + "profile.interface.title": "Интерфейс", + "profile.interface.subtitle": "Язык, оформление и настройки локального времени.", + "profile.fields.language": "Язык", + "profile.fields.language_help": "Язык интерфейса изменится после сохранения профиля.", + "profile.fields.theme": "Тема", + "profile.fields.theme_help": "Системная тема следует настройкам операционной системы или браузера.", + "profile.theme.system": "Системная", + "profile.theme.light": "Светлая", + "profile.theme.dark": "Тёмная", "profile.fields.username": "Имя пользователя", "profile.fields.display_name": "Отображаемое имя", "profile.fields.email": "Email", diff --git a/app/static/i18n/ru/routes.json b/app/static/i18n/ru/routes.json index 07c8f72..974aace 100644 --- a/app/static/i18n/ru/routes.json +++ b/app/static/i18n/ru/routes.json @@ -198,7 +198,11 @@ "routes.intake.webhook_subtitle": "Для обычного payload используйте токен как Authorization: Bearer, а для формата PagerDuty Events API v2 — как routing_key.", "routes.intake.webhook_help": "Обычный payload использует Authorization: Bearer. Совместимый с PagerDuty Events API v2 payload передаёт тот же токен в routing_key.", "routes.form.datadog_help": "Создайте Datadog Webhooks integration, которая отправляет рекомендуемый custom payload в этот маршрут. Добавьте intake token маршрута в custom header Authorization: Bearer.", + "routes.form.uptime_kuma_help": "Uptime Kuma отправляет в этот маршрут стандартный JSON Webhook. Добавьте intake token маршрута в заголовок Authorization: Bearer. DOWN открывает алерт, а UP или maintenance закрывает его.", "routes.intake.datadog_example_comment": "Пример custom payload для Datadog Webhooks", "routes.intake.datadog_subtitle": "Используйте этот endpoint в Datadog Webhooks integration и добавьте токен маршрута в custom header Authorization: Bearer.", - "routes.intake.datadog_help": "Настройте JSON custom payload с ALERT_CYCLE_KEY и ALERT_TRANSITION, чтобы recovery обновлял тот же алерт IncidentRelay." + "routes.intake.datadog_help": "Настройте JSON custom payload с ALERT_CYCLE_KEY и ALERT_TRANSITION, чтобы recovery обновлял тот же алерт IncidentRelay.", + "routes.intake.uptime_kuma_example_comment": "Пример стандартного Webhook payload Uptime Kuma", + "routes.intake.uptime_kuma_subtitle": "Используйте этот endpoint и token маршрута в Webhook notification Uptime Kuma.", + "routes.intake.uptime_kuma_help": "В Uptime Kuma создайте Webhook notification с методом POST, типом application/json, intake URL маршрута и дополнительным заголовком Authorization. Оставьте стандартный request body." } diff --git a/app/static/i18n/ru/services.json b/app/static/i18n/ru/services.json index 41ad6bd..440b003 100644 --- a/app/static/i18n/ru/services.json +++ b/app/static/i18n/ru/services.json @@ -127,5 +127,9 @@ "services.dependencies.hard": "Жёсткая", "services.dependencies.soft": "Мягкая", "services.dependencies.external": "Внешняя", - "services.dependencies.informational": "Информационная" + "services.dependencies.informational": "Информационная", + "services.details.event_orchestrations": "Оркестрации событий", + "services.details.orchestration_disabled": "Отключена", + "services.details.orchestration_active": "Активна", + "services.details.orchestration_shadow": "Теневой режим" } diff --git a/app/static/i18n/ru/silences.json b/app/static/i18n/ru/silences.json index 7fdda92..0c006f2 100644 --- a/app/static/i18n/ru/silences.json +++ b/app/static/i18n/ru/silences.json @@ -78,5 +78,16 @@ "silences.permissions.disable_denied": "У вас нет права отключать это подавление.", "silences.confirm.disable_title": "Отключить это подавление?", "silences.confirm.disable_message": "После отключения совпадающие алерты больше не будут подавляться.", - "silences.confirm.disable": "Отключить" + "silences.confirm.disable": "Отключить", + "silences.details.apply_to_existing": "Существующие алерты", + "silences.details.apply_to_existing_enabled": "Применять к подходящим незакрытым алертам", + "silences.details.apply_to_existing_disabled": "Только новые алерты", + "silences.details.reactivate_on_end": "После завершения Silence", + "silences.details.reactivate_on_end_enabled": "Повторно активировать затронутые алерты", + "silences.details.reactivate_on_end_disabled": "Оставить затронутые алерты заглушёнными", + "silences.form.apply_to_existing": "Применить к существующим незакрытым алертам", + "silences.form.apply_to_existing_help": "Необязательно. Подходящие firing-алерты будут заглушены сразу. Уже отправленные уведомления останутся в истории.", + "silences.form.reactivate_on_end": "Повторно активировать заглушённые алерты после завершения Silence", + "silences.form.reactivate_on_end_help": "Включено по умолчанию. Алерты активируются только после завершения последнего подходящего Silence.", + "silences.form.reactivate_on_end_warning": "Затронутые алерты останутся заглушёнными после завершения Silence. Чтобы освободить их позже, включите эту опцию и сохраните Silence." } diff --git a/app/static/js/components/dependency_graph.js b/app/static/js/components/dependency_graph.js index 3d9be1d..2b20d8f 100644 --- a/app/static/js/components/dependency_graph.js +++ b/app/static/js/components/dependency_graph.js @@ -484,7 +484,7 @@ function serviceDependencyGraphNodeDisplayStatus(service, impactMap) { return serviceDependencyGraphNormalizeStatus(impact.effective_status); } - return serviceDependencyGraphNodeStatus(service); + return serviceDependencyGraphServiceStatus(service); } function serviceDependencyGraphEdgeImpactStatus(dependency, upstreamService, impactMap) { @@ -646,7 +646,7 @@ function serviceDependencyGraphServiceFromDependency(dependency, side, id, servi }; } -function serviceDependencyGraphNodeStatus(service) { +function serviceDependencyGraphServiceStatus(service) { if (!service || service.enabled === false) { return "disabled"; } diff --git a/app/static/js/core/dates.js b/app/static/js/core/dates.js index 8a028a6..a9cacc7 100644 --- a/app/static/js/core/dates.js +++ b/app/static/js/core/dates.js @@ -205,8 +205,47 @@ function formatDateTime24(value, options) { return formatDateTime(value); } + + +function datetimeLocalToUtcIso(value) { + /* Convert a browser-local datetime-local value to an explicit UTC ISO string. */ + const text = String(value || "").trim(); + + if (!text) { + return ""; + } + + const date = new Date(text); + + if (Number.isNaN(date.getTime())) { + return text; + } + + return date.toISOString(); +} + + +function utcIsoToDatetimeLocal(value) { + /* Convert an API UTC instant to browser-local datetime-local format. */ + const date = parseDisplayDate(value); + + if (!date) { + return ""; + } + + return [ + date.getFullYear(), + padDateTimePart(date.getMonth() + 1), + padDateTimePart(date.getDate()), + ].join("-") + "T" + [ + padDateTimePart(date.getHours()), + padDateTimePart(date.getMinutes()), + ].join(":"); +} + + function isoToDatetimeLocal(value) { - /* Convert an ISO date string to datetime-local format. */ + /* Convert an offset-free wall-clock ISO string to datetime-local format. */ if (!value) { return ""; } return value.slice(0, 16); } diff --git a/app/static/js/core/i18n.js b/app/static/js/core/i18n.js index 4eb46cf..c036c1f 100644 --- a/app/static/js/core/i18n.js +++ b/app/static/js/core/i18n.js @@ -99,6 +99,7 @@ "teams": "nav.teams", "groups": "nav.groups", "admin-users": "nav.users", + "audit-log": "nav.audit_log", "sso": "nav.sso", }; @@ -134,7 +135,6 @@ document.documentElement.lang = locale; translateMenu(); - setText('label[for="global-language-filter"]', "common.language"); setText('label[for="global-team-filter"]', "common.team"); setText("#app-dialog-title", "common.message"); setText("#app-dialog-cancel", "common.cancel"); diff --git a/app/static/js/core/router.js b/app/static/js/core/router.js index 012801d..cd2e5ec 100644 --- a/app/static/js/core/router.js +++ b/app/static/js/core/router.js @@ -38,6 +38,10 @@ function navigate(path, pushState) { showAppError(i18n.t("errors.admin_role_required")); path = "/"; } + if (routePath === "/admin/audit-log" && !hasAuditLogAccess()) { + showAppError(i18n.t("audit.errors.access_denied")); + path = "/"; + } if (routePath === "/admin/users" && !hasGroupUserAdminAccess()) { showAppError(i18n.t("errors.group_admin_role_required")); path = "/"; @@ -110,12 +114,19 @@ function updateAuthUi() { */ const isGlobalAdmin = !!(currentUser && currentUser.is_admin); const canManageUsers = hasGroupUserAdminAccess(); + const canViewAuditLog = hasAuditLogAccess(); const adminSection = $(".menu-section-admin, .menu-link-admin"); adminSection.addClass("is-hidden"); $(".menu-link-users").addClass("is-hidden"); $(".menu-link-groups").addClass("is-hidden"); $(".menu-link-global-admin").addClass("is-hidden"); + $(".menu-link-audit").addClass("is-hidden"); + + if (canViewAuditLog) { + adminSection.removeClass("is-hidden"); + $(".menu-link-audit").removeClass("is-hidden"); + } if (canManageUsers) { adminSection.removeClass("is-hidden"); @@ -128,6 +139,7 @@ function updateAuthUi() { $(".menu-link-users").removeClass("is-hidden"); $(".menu-link-groups").removeClass("is-hidden"); $(".menu-link-global-admin").removeClass("is-hidden"); + $(".menu-link-audit").removeClass("is-hidden"); } if (currentUser) { @@ -241,6 +253,22 @@ function currentAppUrl() { return window.location.pathname + window.location.search + window.location.hash; } +function hasAuditLogAccess() { + /* + * Audit logs are visible to global admins and group editors only. + */ + if (!currentUser) { + return false; + } + if (currentUser.is_admin) { + return true; + } + return asArray(currentUser.groups).some(function (group) { + return group.role === GROUP_EDITOR_ROLE || group.role === "editor"; + }); +} + + function hasGroupUserAdminAccess() { /* * Return true when the current user can manage users in at least one group. diff --git a/app/static/js/core/state.js b/app/static/js/core/state.js index 790eb1c..ccb62ba 100644 --- a/app/static/js/core/state.js +++ b/app/static/js/core/state.js @@ -10,6 +10,7 @@ const routes = { "/business-services": { page: "business-services", title: "Business Services", subtitle: "Customer-facing capabilities, business impact and status page services", load: function () { loadBusinessServices(); }}, "/heartbeats": { page: "heartbeats", title: "Heartbeats", subtitle: "Dead-man checks for jobs, monitoring pipelines and alert delivery paths", load: function () { loadHeartbeats(); }}, "/maintenance-windows": { page: "maintenance-windows", title: "Maintenance Windows", subtitle: "Planned maintenance, notification suppression and escalation handling", load: function () {loadMaintenanceWindows();}}, + "/event-orchestration": { page: "orchestrations", title: "Event Orchestration", subtitle: "Route, enrich, suppress and automate incoming events", load: function () { loadOrchestrations(); } }, "/escalation-policies": { page: "escalation-policies", title: "Escalation Policies", subtitle: "Define alert escalation chains by team", load: function () { loadEscalationPolicies(); } }, "/notification-policies": { page: "notification-policies", title: "Notification Policies", subtitle: "Select shared notification channels for service events", load: function () { loadNotificationPolicies(); }}, "/matcher-presets": { page: "matcher-presets", title: "Matcher Presets", subtitle: "Reusable alert matchers for service policies", load: function () { loadMatcherPresets(); } }, @@ -20,6 +21,7 @@ const routes = { "/groups": { page: "groups", title: "Groups", subtitle: "Access boundaries and user roles", load: function () { loadGroups(); } }, "/profile": { page: "profile", title: "Profile", subtitle: "User profile and personal API token", load: function () { loadProfile(); } }, "/admin/users": { page: "admin-users", title: "Admin users", subtitle: "Admin-only user workspace", load: function () { loadAdminUsers(); } }, + "/admin/audit-log": { page: "audit-log", title: "Audit log", subtitle: "Administrative activity and security history", load: function () { loadAuditLog(); } }, "/admin/sso": { page: "sso", title: "SSO", subtitle: "OIDC and SAML login providers", load: function () { loadSsoAdmin(); } }, "/login": { page: "login", title: "Login", subtitle: "JWT authentication", load: function () { loadLogin(); } } }; diff --git a/app/static/js/core/theme.js b/app/static/js/core/theme.js new file mode 100644 index 0000000..d069763 --- /dev/null +++ b/app/static/js/core/theme.js @@ -0,0 +1,104 @@ +(function (window, document) { + "use strict"; + + const config = window.__INCIDENTRELAY_UI__ || {}; + const supportedThemes = ["system", "light", "dark"]; + const mediaQuery = window.matchMedia + ? window.matchMedia("(prefers-color-scheme: dark)") + : null; + let preference = normalizeTheme(config.theme) || "system"; + + function normalizeTheme(value) { + const normalized = String(value || "").trim().toLowerCase(); + return supportedThemes.indexOf(normalized) >= 0 ? normalized : null; + } + + function resolveColorScheme(value) { + const normalized = normalizeTheme(value) || "system"; + + if (normalized === "dark") { + return "dark"; + } + + if (normalized === "light") { + return "light"; + } + + return mediaQuery && mediaQuery.matches ? "dark" : "light"; + } + + function updateThemeColor(colorScheme) { + const element = document.querySelector('meta[name="theme-color"]'); + if (element) { + element.setAttribute( + "content", + colorScheme === "dark" ? "#0f172a" : "#0b5cff" + ); + } + } + + function applyChartDefaults(colorScheme) { + if (!window.Chart || !window.Chart.defaults) { + return; + } + + const dark = colorScheme === "dark"; + window.Chart.defaults.color = dark ? "#cbd5e1" : "#334155"; + window.Chart.defaults.borderColor = dark + ? "rgba(148, 163, 184, 0.22)" + : "rgba(100, 116, 139, 0.18)"; + } + + function applyTheme(nextPreference) { + preference = normalizeTheme(nextPreference) || "system"; + const colorScheme = resolveColorScheme(preference); + + document.documentElement.dataset.theme = preference; + document.documentElement.dataset.colorScheme = colorScheme; + document.documentElement.style.colorScheme = colorScheme; + updateThemeColor(colorScheme); + applyChartDefaults(colorScheme); + + document.dispatchEvent( + new CustomEvent("incidentrelay:theme-change", { + detail: { + preference: preference, + colorScheme: colorScheme, + }, + }) + ); + + return colorScheme; + } + + function handleSystemThemeChange() { + if (preference === "system") { + applyTheme(preference); + } + } + + if (mediaQuery) { + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", handleSystemThemeChange); + } else if (typeof mediaQuery.addListener === "function") { + mediaQuery.addListener(handleSystemThemeChange); + } + } + + window.AppTheme = Object.freeze({ + apply: applyTheme, + applyChartDefaults: function () { + applyChartDefaults(resolveColorScheme(preference)); + }, + getPreference: function () { + return preference; + }, + getColorScheme: function () { + return resolveColorScheme(preference); + }, + normalize: normalizeTheme, + supportedThemes: supportedThemes.slice(), + }); + + applyTheme(preference); +})(window, document); diff --git a/app/static/js/pages/audit_log.js b/app/static/js/pages/audit_log.js new file mode 100644 index 0000000..703a88e --- /dev/null +++ b/app/static/js/pages/audit_log.js @@ -0,0 +1,355 @@ +let auditLogCurrentPage = 1; +let auditLogPageSize = 25; +let auditLogPagination = { + page: 1, + page_size: 25, + total_items: 0, + total_pages: 1, + from: 0, + to: 0, + has_previous: false, + has_next: false, +}; +let auditLogItems = []; +let auditLogSearchTimer = null; + + +function auditLogFilterValue(selector) { + return String($(selector).val() || "").trim(); +} + + +function auditLogBuildUrl() { + const params = new URLSearchParams(); + params.set("page", String(auditLogCurrentPage)); + params.set("page_size", String(auditLogPageSize)); + + const filters = { + search: auditLogFilterValue("#audit-log-search"), + group_id: auditLogFilterValue("#audit-log-group"), + actor_id: auditLogFilterValue("#audit-log-actor"), + action: auditLogFilterValue("#audit-log-action"), + object_type: auditLogFilterValue("#audit-log-object-type"), + date_from: auditLogFilterValue("#audit-log-date-from"), + date_to: auditLogFilterValue("#audit-log-date-to"), + }; + + Object.keys(filters).forEach(function (key) { + if (filters[key]) { + params.set(key, filters[key]); + } + }); + + return "/api/admin/audit-logs?" + params.toString(); +} + + +function loadAuditLog() { + if (!$("#audit-log-table").length) { + return; + } + + apiGet(auditLogBuildUrl(), function (response) { + auditLogItems = asArray(response && response.items); + auditLogPagination = (response && response.pagination) || auditLogPagination; + auditLogCurrentPage = Number(auditLogPagination.page || 1); + auditLogPageSize = Number(auditLogPagination.page_size || auditLogPageSize); + + renderAuditLogSummary((response && response.summary) || {}); + renderAuditLogFilterOptions((response && response.filters) || {}); + renderAuditLogScope((response && response.permissions) || {}); + renderAuditLogTable(auditLogItems); + renderAuditLogPagination(auditLogPagination); + }); +} + + +function renderAuditLogSummary(summary) { + $("#audit-log-total-count").text(Number(summary.total || 0)); + $("#audit-log-actor-count").text(Number(summary.actors || 0)); + $("#audit-log-action-count").text(Number(summary.actions || 0)); + $("#audit-log-group-count").text(Number(summary.groups || 0)); +} + + +function renderAuditLogScope(permissions) { + const key = permissions.is_global_admin + ? "audit.list.global_scope" + : "audit.list.editor_scope"; + $("#audit-log-scope-hint").text(i18n.t(key)); +} + + +function fillAuditLogSelect(selector, items, getValue, getLabel, emptyKey) { + const select = $(selector); + const selected = String(select.val() || ""); + select.empty().append( + $("