diff --git a/CHANGELOG.md b/CHANGELOG.md index e127834..afd7ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [1.2.0] - 2026-05-21 + +### Added + +- **Conditional alert rules** — per-job rules can create alerts from HTTP status thresholds, execution duration thresholds, response body matches and consecutive failure counts. +- **Alert rules API and UI** — CRUD endpoints under `/api/jobs/{job_id}/alert-rules` plus management controls on the job detail page. +- **Alembic migration `c3d4e5f6a7b8`** — creates the `alert_rules` table with job and workspace foreign keys. +- **Workspace-scoped alert dispatch** — alert notifications and escalation policies now dispatch only through channels/policies in the alert's workspace. + +### Changed + +- **Workspace isolation hardening** — job detail/update/delete/run, execution detail and alert rule endpoints now honor `X-Workspace-ID` resource scope. +- **Worker parity** — queued Celery executions now evaluate conditional alert rules after final retry handling. +- **Version alignment** — backend package metadata, `/api/version`, frontend package metadata and lockfile report `1.2.0`. + ## [1.1.0] - 2026-05-20 ### Added diff --git a/README.md b/README.md index 79b5cc7..f809516 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ AutoFlowOps centralises these routines in a single self-hosted platform: - **Executions** — persistent history with status, timings, response previews and masked secrets - **Webhooks** — receive external events, store payloads with token validation, reprocess events - **Alerts** — automatic alerts on job and webhook failures, with acknowledge and resolve workflows +- **Conditional alert rules** — trigger alerts from HTTP status, runtime, response text or consecutive failure thresholds - **Notifications** — send critical alerts to Discord, Telegram, SMTP email and custom webhooks - **Escalation policies** — multi-step escalation with configurable delays per step - **Roles** — admin, operator and viewer roles enforced server-side; audit trail of all sensitive actions @@ -48,6 +49,7 @@ AutoFlowOps centralises these routines in a single self-hosted platform: - Webhook receiver with secret token validation (SHA-256) - Execution history with masked secrets and response previews - Internal alerting system for failed executions +- Per-job conditional alert rules for custom operational thresholds - External notification channels for critical operational alerts (Discord, Telegram, SMTP, custom webhooks) - Notification templates and multi-step escalation policies - Role-based access control (admin / operator / viewer) enforced server-side @@ -130,9 +132,9 @@ The script asks for an image tag (default `latest`), copies `.env.example` to `. To run a specific release: ```bash -IMAGE_TAG=v1.1.0 bash scripts/setup.sh +IMAGE_TAG=v1.2.0 bash scripts/setup.sh # or -IMAGE_TAG=v1.1.0 make registry-up +IMAGE_TAG=v1.2.0 make registry-up ``` ### Option B — Build from source @@ -232,8 +234,8 @@ make test | Suite | Tests | Status | | --- | --- | --- | -| Backend | 216 | Passing | -| Frontend | 75 | Passing | +| Backend | 260 | Passing | +| Frontend | 76 | Passing | --- diff --git a/README.pt-br.md b/README.pt-br.md index 876dbd2..2d80fd4 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -30,6 +30,7 @@ O AutoFlowOps centraliza essas rotinas em uma única plataforma self-hosted: - **Execuções** — histórico persistente com status, tempos, prévia de respostas e segredos mascarados - **Webhooks** — receba eventos externos, armazene payloads com validação por token, reprocesse eventos - **Alertas** — alertas automáticos em falhas de jobs e webhooks, com fluxos de reconhecimento e resolução +- **Regras condicionais de alerta** — dispare alertas por status HTTP, duração, texto da resposta ou limites de falhas consecutivas - **Notificações** — envie alertas críticos para Discord, Telegram, SMTP e webhooks customizados - **Políticas de escalonamento** — escalonamento em múltiplos passos com delays configuráveis por etapa - **Permissões** — roles admin, operator e viewer aplicados server-side; trilha de auditoria de todas as ações sensíveis @@ -48,6 +49,7 @@ O AutoFlowOps centraliza essas rotinas em uma única plataforma self-hosted: - Receptor de webhooks com validação por token secreto (SHA-256) - Histórico de execuções com segredos mascarados e prévia de respostas - Sistema interno de alertas para execuções com falha +- Regras condicionais de alerta por job para limites operacionais customizados - Canais de notificação externos para alertas críticos (Discord, Telegram, SMTP, webhooks customizados) - Templates de notificação e políticas de escalonamento em múltiplos passos - Controle de acesso por roles (admin / operator / viewer) aplicado server-side @@ -115,9 +117,9 @@ O script solicita a tag da imagem (padrão `latest`), copia `.env.example` para Para rodar uma versão específica: ```bash -IMAGE_TAG=v1.1.0 bash scripts/setup.sh +IMAGE_TAG=v1.2.0 bash scripts/setup.sh # ou -IMAGE_TAG=v1.1.0 make registry-up +IMAGE_TAG=v1.2.0 make registry-up ``` ### Opção B — Build a partir do código-fonte @@ -217,8 +219,8 @@ make test | Suite | Testes | Status | | --- | --- | --- | -| Backend | 216 | Passando | -| Frontend | 75 | Passando | +| Backend | 260 | Passando | +| Frontend | 76 | Passando | --- diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_workspaces.py b/backend/alembic/versions/b2c3d4e5f6a7_add_workspaces.py index d9f8f80..03a98ae 100644 --- a/backend/alembic/versions/b2c3d4e5f6a7_add_workspaces.py +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_workspaces.py @@ -84,20 +84,31 @@ def upgrade() -> None: ) for table in _DOMAIN_TABLES: - op.add_column( - table, - sa.Column( - "workspace_id", - sa.Uuid(), - sa.ForeignKey("workspaces.id", ondelete="SET NULL"), - nullable=True, - ), - ) + with op.batch_alter_table(table) as batch_op: + batch_op.add_column( + sa.Column( + "workspace_id", + sa.Uuid(), + nullable=True, + ) + ) + batch_op.create_foreign_key( + f"fk_{table}_workspace_id_workspaces", + "workspaces", + ["workspace_id"], + ["id"], + ondelete="SET NULL", + ) def downgrade() -> None: for table in reversed(_DOMAIN_TABLES): - op.drop_column(table, "workspace_id") + with op.batch_alter_table(table) as batch_op: + batch_op.drop_constraint( + f"fk_{table}_workspace_id_workspaces", + type_="foreignkey", + ) + batch_op.drop_column("workspace_id") op.drop_index( op.f("ix_workspace_memberships_user_id"), table_name="workspace_memberships" diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_alert_rules.py b/backend/alembic/versions/c3d4e5f6a7b8_add_alert_rules.py new file mode 100644 index 0000000..7c384b4 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_alert_rules.py @@ -0,0 +1,54 @@ +"""add_alert_rules + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-05-21 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "alert_rules", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("job_id", sa.Uuid(), nullable=False), + sa.Column("condition_type", sa.String(length=50), nullable=False), + sa.Column("condition_value", sa.String(length=500), nullable=False), + sa.Column("severity", sa.String(length=50), nullable=False), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("is_enabled", sa.Boolean(), nullable=False), + sa.Column("workspace_id", sa.Uuid(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint(["job_id"], ["jobs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_alert_rules_job_id"), "alert_rules", ["job_id"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_alert_rules_job_id"), table_name="alert_rules") + op.drop_table("alert_rules") diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 6849410..c68196d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py new file mode 100644 index 0000000..e2fa369 --- /dev/null +++ b/backend/app/api/alert_rules.py @@ -0,0 +1,176 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import get_active_workspace, get_current_user, require_operator +from app.models.alert_rule import AlertRule +from app.models.job import Job +from app.models.user import User +from app.models.workspace import Workspace +from app.schemas.alert_rule import ( + AlertRuleConditionType, + AlertRuleCreate, + AlertRuleRead, + AlertRuleUpdate, + validate_alert_rule_condition_value, +) +from app.services.audit import client_ip, log_action + +router = APIRouter(prefix="/jobs/{job_id}/alert-rules", tags=["alert-rules"]) + + +async def _get_job_or_404( + session: AsyncSession, + job_id: uuid.UUID, + workspace: Workspace | None = None, +) -> Job: + result = await session.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if job is None or (workspace is not None and job.workspace_id != workspace.id): + raise HTTPException(status_code=404, detail="Job not found") + return job + + +async def _get_rule_or_404( + session: AsyncSession, + job_id: uuid.UUID, + rule_id: uuid.UUID, + workspace: Workspace | None = None, +) -> AlertRule: + stmt = select(AlertRule).where( + AlertRule.id == rule_id, AlertRule.job_id == job_id + ) + if workspace is not None: + stmt = stmt.where(AlertRule.workspace_id == workspace.id) + result = await session.execute(stmt) + rule = result.scalar_one_or_none() + if rule is None: + raise HTTPException(status_code=404, detail="Alert rule not found") + return rule + + +def _ensure_valid_condition_value( + condition_type: AlertRuleConditionType | str, + condition_value: str, +) -> None: + try: + validate_alert_rule_condition_value( + condition_type, # type: ignore[arg-type] + condition_value, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("", response_model=list[AlertRuleRead]) +async def list_alert_rules( + job_id: uuid.UUID, + session: AsyncSession = Depends(get_db), + _user: User = Depends(get_current_user), + workspace: Workspace | None = Depends(get_active_workspace), +) -> list[AlertRuleRead]: + await _get_job_or_404(session, job_id, workspace) + result = await session.execute( + select(AlertRule) + .where(AlertRule.job_id == job_id) + .order_by(AlertRule.created_at.asc()) + ) + rules = result.scalars().all() + return [AlertRuleRead.model_validate(r) for r in rules] + + +@router.post("", response_model=AlertRuleRead, status_code=201) +async def create_alert_rule( + job_id: uuid.UUID, + payload: AlertRuleCreate, + request: Request, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(require_operator), + workspace: Workspace | None = Depends(get_active_workspace), +) -> AlertRuleRead: + job = await _get_job_or_404(session, job_id, workspace) + rule = AlertRule( + job_id=job.id, + condition_type=payload.condition_type, + condition_value=payload.condition_value, + severity=payload.severity, + message=payload.message, + is_enabled=payload.is_enabled, + workspace_id=job.workspace_id, + ) + session.add(rule) + await session.flush() + await log_action( + session, + action="alert_rules.create", + resource_type="alert_rule", + resource_id=str(rule.id), + user_id=current_user.id, + ip_address=client_ip(request), + user_agent=request.headers.get("User-Agent"), + metadata={"job_id": str(job_id), "condition_type": rule.condition_type}, + ) + await session.commit() + await session.refresh(rule) + return AlertRuleRead.model_validate(rule) + + +@router.patch("/{rule_id}", response_model=AlertRuleRead) +async def update_alert_rule( + job_id: uuid.UUID, + rule_id: uuid.UUID, + payload: AlertRuleUpdate, + request: Request, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(require_operator), + workspace: Workspace | None = Depends(get_active_workspace), +) -> AlertRuleRead: + await _get_job_or_404(session, job_id, workspace) + rule = await _get_rule_or_404(session, job_id, rule_id, workspace) + updates = payload.model_dump(exclude_unset=True) + next_condition_type = updates.get("condition_type", rule.condition_type) + next_condition_value = updates.get("condition_value", rule.condition_value) + _ensure_valid_condition_value(next_condition_type, next_condition_value) + for field, value in updates.items(): + setattr(rule, field, value) + await log_action( + session, + action="alert_rules.update", + resource_type="alert_rule", + resource_id=str(rule_id), + user_id=current_user.id, + ip_address=client_ip(request), + user_agent=request.headers.get("User-Agent"), + metadata={"updated_fields": list(updates.keys())}, + ) + await session.commit() + await session.refresh(rule) + return AlertRuleRead.model_validate(rule) + + +@router.delete("/{rule_id}", status_code=204) +async def delete_alert_rule( + job_id: uuid.UUID, + rule_id: uuid.UUID, + request: Request, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(require_operator), + workspace: Workspace | None = Depends(get_active_workspace), +) -> None: + await _get_job_or_404(session, job_id, workspace) + rule = await _get_rule_or_404(session, job_id, rule_id, workspace) + await log_action( + session, + action="alert_rules.delete", + resource_type="alert_rule", + resource_id=str(rule_id), + user_id=current_user.id, + ip_address=client_ip(request), + user_agent=request.headers.get("User-Agent"), + metadata={"job_id": str(job_id)}, + ) + await session.delete(rule) + await session.commit() diff --git a/backend/app/api/executions.py b/backend/app/api/executions.py index 5a71113..33a773c 100644 --- a/backend/app/api/executions.py +++ b/backend/app/api/executions.py @@ -41,10 +41,12 @@ async def get_execution( execution_id: uuid.UUID, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db), + workspace: Workspace | None = Depends(get_active_workspace), ) -> ExecutionRead: - result = await session.execute( - select(Execution).where(Execution.id == execution_id) - ) + stmt = select(Execution).where(Execution.id == execution_id) + if workspace is not None: + stmt = stmt.where(Execution.workspace_id == workspace.id) + result = await session.execute(stmt) execution = result.scalar_one_or_none() if execution is None: raise HTTPException(status_code=404, detail="Execution not found") diff --git a/backend/app/api/jobs.py b/backend/app/api/jobs.py index 3a5b1a8..14f877e 100644 --- a/backend/app/api/jobs.py +++ b/backend/app/api/jobs.py @@ -112,8 +112,9 @@ async def get_job( job_id: uuid.UUID, session: AsyncSession = Depends(get_db), _user: User = Depends(get_current_user), + workspace: Workspace | None = Depends(get_active_workspace), ) -> JobRead: - job = await _get_or_404(session, job_id) + job = await _get_or_404(session, job_id, workspace) return _job_to_read(job) @@ -124,8 +125,9 @@ async def update_job( request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(require_operator), + workspace: Workspace | None = Depends(get_active_workspace), ) -> JobRead: - job = await _get_or_404(session, job_id) + job = await _get_or_404(session, job_id, workspace) updates = payload.model_dump(exclude_unset=True) if "headers" in updates: @@ -165,8 +167,9 @@ async def delete_job( request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(require_operator), + workspace: Workspace | None = Depends(get_active_workspace), ) -> None: - job = await _get_or_404(session, job_id) + job = await _get_or_404(session, job_id, workspace) await log_action( session, action="jobs.delete", @@ -188,8 +191,9 @@ async def run_job( request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(require_operator), + workspace: Workspace | None = Depends(get_active_workspace), ) -> ExecutionRead: - job = await _get_or_404(session, job_id) + job = await _get_or_404(session, job_id, workspace) if job.type != "http": raise HTTPException(status_code=501, detail="Only HTTP jobs are supported") execution = await enqueue_job_execution(job, session, trigger_type="manual") @@ -207,9 +211,13 @@ async def run_job( return ExecutionRead.model_validate(execution) -async def _get_or_404(session: AsyncSession, job_id: uuid.UUID) -> Job: +async def _get_or_404( + session: AsyncSession, + job_id: uuid.UUID, + workspace: Workspace | None = None, +) -> Job: result = await session.execute(select(Job).where(Job.id == job_id)) job = result.scalar_one_or_none() - if job is None: + if job is None or (workspace is not None and job.workspace_id != workspace.id): raise HTTPException(status_code=404, detail="Job not found") return job diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 20fe570..f994a80 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends from app.api import ( + alert_rules, alerts, audit_logs, auth, @@ -29,6 +30,7 @@ # Protected endpoints — require a valid JWT Bearer token _auth = [Depends(get_current_user)] router.include_router(jobs.router, dependencies=_auth) +router.include_router(alert_rules.router, dependencies=_auth) router.include_router(executions.router, dependencies=_auth) router.include_router(stats.router, tags=["stats"], dependencies=_auth) router.include_router(webhooks.router, dependencies=_auth) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 28ff259..cd85ac0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.alert import Alert +from app.models.alert_rule import AlertRule from app.models.audit_log import AuditLog from app.models.base import Base from app.models.escalation import EscalationEvent, EscalationPolicy, EscalationStep @@ -14,6 +15,7 @@ __all__ = [ "Base", "Alert", + "AlertRule", "AuditLog", "EscalationEvent", "EscalationPolicy", diff --git a/backend/app/models/alert_rule.py b/backend/app/models/alert_rule.py new file mode 100644 index 0000000..1b2168a --- /dev/null +++ b/backend/app/models/alert_rule.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class AlertRule(Base): + __tablename__ = "alert_rules" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + job_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("jobs.id", ondelete="CASCADE"), index=True + ) + condition_type: Mapped[str] = mapped_column(String(50)) + condition_value: Mapped[str] = mapped_column(String(500)) + severity: Mapped[str] = mapped_column(String(50), default="warning") + message: Mapped[str | None] = mapped_column(Text) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + workspace_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("workspaces.id", ondelete="SET NULL") + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/backend/app/schemas/alert_rule.py b/backend/app/schemas/alert_rule.py new file mode 100644 index 0000000..0bdb790 --- /dev/null +++ b/backend/app/schemas/alert_rule.py @@ -0,0 +1,72 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +AlertRuleConditionType = Literal[ + "http_status_gte", + "duration_ms_gte", + "response_body_contains", + "consecutive_failures_gte", +] + +AlertRuleSeverity = Literal["error", "warning", "info"] + + +class AlertRuleCreate(BaseModel): + condition_type: AlertRuleConditionType + condition_value: str = Field(min_length=1, max_length=500) + severity: AlertRuleSeverity = "warning" + message: str | None = Field(default=None, max_length=255) + is_enabled: bool = True + + @model_validator(mode="after") + def validate_condition_value(self) -> "AlertRuleCreate": + validate_alert_rule_condition_value( + self.condition_type, self.condition_value + ) + return self + + +class AlertRuleUpdate(BaseModel): + condition_type: AlertRuleConditionType | None = None + condition_value: str | None = Field(default=None, min_length=1, max_length=500) + severity: AlertRuleSeverity | None = None + message: str | None = Field(default=None, max_length=255) + is_enabled: bool | None = None + + +class AlertRuleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + job_id: uuid.UUID + condition_type: AlertRuleConditionType + condition_value: str + severity: AlertRuleSeverity + message: str | None + is_enabled: bool + created_at: datetime + updated_at: datetime + + +def validate_alert_rule_condition_value( + condition_type: AlertRuleConditionType, + condition_value: str, +) -> None: + if condition_type in { + "http_status_gte", + "duration_ms_gte", + "consecutive_failures_gte", + }: + try: + value = int(condition_value) + except ValueError as exc: + raise ValueError("condition_value must be an integer") from exc + if condition_type == "http_status_gte" and not 100 <= value <= 599: + raise ValueError("condition_value must be an HTTP status from 100 to 599") + if condition_type == "duration_ms_gte" and value < 1: + raise ValueError("condition_value must be greater than 0") + if condition_type == "consecutive_failures_gte" and value < 1: + raise ValueError("condition_value must be greater than 0") diff --git a/backend/app/services/escalation.py b/backend/app/services/escalation.py index 9844302..3e483e0 100644 --- a/backend/app/services/escalation.py +++ b/backend/app/services/escalation.py @@ -33,7 +33,10 @@ async def dispatch_via_escalation_policies( """ result = await session.execute( select(EscalationPolicy) - .where(EscalationPolicy.is_active.is_(True)) + .where( + EscalationPolicy.is_active.is_(True), + EscalationPolicy.workspace_id == alert.workspace_id, + ) .order_by(EscalationPolicy.created_at.asc()) ) policies = result.scalars().all() @@ -60,6 +63,7 @@ async def dispatch_via_escalation_policies( select(NotificationChannel).where( NotificationChannel.id == step.channel_id, NotificationChannel.status == "active", + NotificationChannel.workspace_id == alert.workspace_id, ) ) channel = channel_result.scalar_one_or_none() @@ -153,6 +157,7 @@ async def process_pending_escalation_events(session: AsyncSession) -> int: select(NotificationChannel).where( NotificationChannel.id == event.channel_id, NotificationChannel.status == "active", + NotificationChannel.workspace_id == alert.workspace_id, ) ) channel = channel_result.scalar_one_or_none() diff --git a/backend/app/services/http_runner.py b/backend/app/services/http_runner.py index 2d98d04..0bf2b3b 100644 --- a/backend/app/services/http_runner.py +++ b/backend/app/services/http_runner.py @@ -7,10 +7,12 @@ import httpx from fastapi import HTTPException +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.alert import Alert +from app.models.alert_rule import AlertRule from app.models.execution import Execution from app.models.job import Job from app.observability import alerts_created_total, job_executions_total @@ -32,6 +34,16 @@ def _exception_message(exc: Exception) -> str: return str(exc) +def _parse_non_negative_int(raw: str) -> int | None: + try: + value = int(raw) + except ValueError: + return None + if value < 0: + return None + return value + + async def create_queued_execution( job: Job, session: AsyncSession, @@ -41,6 +53,7 @@ async def create_queued_execution( execution = Execution( id=uuid.uuid4(), job_id=job.id, + workspace_id=job.workspace_id, trigger_type=trigger_type, status="queued", started_at=datetime.now(UTC), @@ -55,6 +68,108 @@ async def create_queued_execution( return execution +async def _count_consecutive_failures( + job_id: uuid.UUID, + session: AsyncSession, + limit: int = 50, +) -> int: + """Count consecutive failures among the most-recent completed executions.""" + result = await session.execute( + select(Execution.status) + .where( + Execution.job_id == job_id, + Execution.status.in_(["success", "failure", "timeout"]), + ) + .order_by(Execution.started_at.desc()) + .limit(limit) + ) + statuses = result.scalars().all() + count = 0 + for s in statuses: + if s in FAILED_EXECUTION_STATUSES: + count += 1 + else: + break + return count + + +async def evaluate_alert_rules( + job: Job, + execution: Execution, + session: AsyncSession, +) -> list[Alert]: + """Evaluate enabled alert rules for *job* against the just-completed *execution*. + + Returns a list of Alert objects (not yet added to the session) for every + rule whose condition is satisfied. + """ + result = await session.execute( + select(AlertRule).where( + AlertRule.job_id == job.id, + AlertRule.is_enabled.is_(True), + ) + ) + rules = result.scalars().all() + + triggered: list[Alert] = [] + for rule in rules: + detail: str | None = None + + if rule.condition_type == "http_status_gte": + threshold = _parse_non_negative_int(rule.condition_value) + if threshold is None: + continue + if ( + execution.response_status_code is not None + and execution.response_status_code >= threshold + ): + detail = ( + f"HTTP {execution.response_status_code} >= {threshold}" + ) + + elif rule.condition_type == "duration_ms_gte": + threshold = _parse_non_negative_int(rule.condition_value) + if threshold is None: + continue + if ( + execution.duration_ms is not None + and execution.duration_ms >= threshold + ): + detail = f"Duration {execution.duration_ms} ms >= {threshold} ms" + + elif rule.condition_type == "response_body_contains": + pattern = rule.condition_value + if execution.response_body_preview and ( + pattern in execution.response_body_preview + ): + detail = f'Response body contains "{pattern}"' + + elif rule.condition_type == "consecutive_failures_gte": + threshold = _parse_non_negative_int(rule.condition_value) + if threshold is None: + continue + consecutive = await _count_consecutive_failures(job.id, session) + if consecutive >= threshold: + detail = ( + f"{consecutive} consecutive failures (threshold: {threshold})" + ) + + if detail is not None: + title = rule.message or f'Job "{job.name}": {detail}' + triggered.append( + Alert( + title=title[:255], + message=detail, + severity=rule.severity, + source_type="alert_rule", + source_id=rule.id, + workspace_id=job.workspace_id, + ) + ) + + return triggered + + def create_failure_alert(job: Job, execution: Execution) -> Alert: detail = execution.error_message or f"HTTP {execution.response_status_code}" return Alert( @@ -63,6 +178,7 @@ def create_failure_alert(job: Job, execution: Execution) -> Alert: severity="error", source_type="job_execution", source_id=execution.id, + workspace_id=job.workspace_id, ) @@ -78,9 +194,14 @@ async def run_job_http( body: str | None = job.body_encrypted if execution is None: - execution = Execution(id=uuid.uuid4(), job_id=job.id) + execution = Execution( + id=uuid.uuid4(), + job_id=job.id, + workspace_id=job.workspace_id, + ) execution.trigger_type = trigger_type + execution.workspace_id = job.workspace_id execution.status = "running" execution.started_at = datetime.now(UTC) execution.finished_at = None @@ -159,6 +280,14 @@ async def run_job_http( await session.flush() alerts_created_total.labels(severity=alert.severity).inc() + rule_alerts: list[Alert] = [] + if create_alert: + rule_alerts = await evaluate_alert_rules(job, execution, session) + for ra in rule_alerts: + session.add(ra) + await session.flush() + alerts_created_total.labels(severity=ra.severity).inc() + await session.commit() await publish_event_async( @@ -187,5 +316,18 @@ async def run_job_http( }, ) + for ra in rule_alerts: + await dispatch_alert_notifications(session, ra) + await publish_event_async( + "alert.created", + { + "alert_id": str(ra.id), + "title": ra.title, + "severity": ra.severity, + "status": ra.status, + "source_type": ra.source_type, + }, + ) + await session.refresh(execution) return execution diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index 6e9a81d..4f5173f 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -285,7 +285,10 @@ async def dispatch_alert_notifications( # No active escalation policies — dispatch to all active channels directly result = await session.execute( select(NotificationChannel) - .where(NotificationChannel.status == "active") + .where( + NotificationChannel.status == "active", + NotificationChannel.workspace_id == alert.workspace_id, + ) .order_by(NotificationChannel.created_at.asc()) ) deliveries: list[NotificationDelivery] = [] diff --git a/backend/app/worker/tasks.py b/backend/app/worker/tasks.py index 916b700..7cf5fc6 100644 --- a/backend/app/worker/tasks.py +++ b/backend/app/worker/tasks.py @@ -7,10 +7,12 @@ from app.database import async_session_factory from app.models.execution import Execution from app.models.job import Job +from app.observability import alerts_created_total from app.services.event_publisher import publish_event from app.services.http_runner import ( FAILED_EXECUTION_STATUSES, create_failure_alert, + evaluate_alert_rules, run_job_http, ) from app.services.notifications import dispatch_alert_notifications @@ -90,13 +92,26 @@ async def _execute_http_job( except Retry: raise - alert = None + alerts = [] if job.alert_on_failure and execution.status in FAILED_EXECUTION_STATUSES: alert = create_failure_alert(job, execution) session.add(alert) await session.flush() + alerts_created_total.labels(severity=alert.severity).inc() + alerts.append(alert) + + if execution.status != "retrying": + rule_alerts = await evaluate_alert_rules(job, execution, session) + for rule_alert in rule_alerts: + session.add(rule_alert) + await session.flush() + alerts_created_total.labels(severity=rule_alert.severity).inc() + alerts.append(rule_alert) + + if alerts: await session.commit() - await dispatch_alert_notifications(session, alert) + for alert in alerts: + await dispatch_alert_notifications(session, alert) publish_event( "execution.completed", @@ -111,7 +126,7 @@ async def _execute_http_job( }, ) - if alert is not None: + for alert in alerts: publish_event( "alert.created", { diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 06947a1..a1a332b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "autoflowops-backend" -version = "1.0.0" +version = "1.2.0" description = "AutoFlowOps backend API" requires-python = ">=3.12" dependencies = [ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 306fe08..31105ac 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,12 @@ +# ruff: noqa: E402 +import os import uuid +from pathlib import Path + +_TEST_DB_PATH = Path(".pytest-autoflowops.db") +_TEST_DB_PATH.unlink(missing_ok=True) +os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///./{_TEST_DB_PATH}" +os.environ["REDIS_URL"] = "redis://localhost:0/0" import pytest from fastapi.testclient import TestClient @@ -13,6 +21,10 @@ from app.services.scheduler import get_scheduler +def pytest_sessionfinish(session, exitstatus): # noqa: ANN001 + _TEST_DB_PATH.unlink(missing_ok=True) + + @pytest.fixture(autouse=True) def clear_scheduler(): yield diff --git a/backend/tests/test_alert_rules.py b/backend/tests/test_alert_rules.py new file mode 100644 index 0000000..6846369 --- /dev/null +++ b/backend/tests/test_alert_rules.py @@ -0,0 +1,288 @@ +"""Integration tests for conditional alert rules.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.alert import Alert +from app.models.alert_rule import AlertRule +from app.models.execution import Execution +from app.models.job import Job +from app.models.workspace import Workspace, WorkspaceMembership +from app.services.http_runner import run_job_http +from app.worker import tasks +from tests.conftest import _FAKE_OPERATOR + +_JOB_PAYLOAD = { + "name": "Rule Target", + "url": "http://example.com/health", + "method": "GET", + "alert_on_failure": False, +} + + +class _FakeRequest: + retries = 0 + + +class _FakeTask: + request = _FakeRequest() + + +async def _create_job(async_client: AsyncClient) -> str: + response = await async_client.post("/api/jobs", json=_JOB_PAYLOAD) + assert response.status_code == 201 + return response.json()["id"] + + +async def test_create_and_list_alert_rule(async_client: AsyncClient) -> None: + job_id = await _create_job(async_client) + + create = await async_client.post( + f"/api/jobs/{job_id}/alert-rules", + json={ + "condition_type": "http_status_gte", + "condition_value": "500", + "severity": "error", + "message": "High HTTP status", + }, + ) + assert create.status_code == 201 + created = create.json() + assert created["job_id"] == job_id + assert created["condition_type"] == "http_status_gte" + assert created["condition_value"] == "500" + assert created["severity"] == "error" + + listed = await async_client.get(f"/api/jobs/{job_id}/alert-rules") + assert listed.status_code == 200 + assert [rule["id"] for rule in listed.json()] == [created["id"]] + + +async def test_update_and_delete_alert_rule(async_client: AsyncClient) -> None: + job_id = await _create_job(async_client) + create = await async_client.post( + f"/api/jobs/{job_id}/alert-rules", + json={ + "condition_type": "duration_ms_gte", + "condition_value": "1000", + }, + ) + rule_id = create.json()["id"] + + update = await async_client.patch( + f"/api/jobs/{job_id}/alert-rules/{rule_id}", + json={"condition_value": "2500", "is_enabled": False}, + ) + assert update.status_code == 200 + assert update.json()["condition_value"] == "2500" + assert update.json()["is_enabled"] is False + + delete = await async_client.delete(f"/api/jobs/{job_id}/alert-rules/{rule_id}") + assert delete.status_code == 204 + + listed = await async_client.get(f"/api/jobs/{job_id}/alert-rules") + assert listed.status_code == 200 + assert listed.json() == [] + + +async def test_alert_rule_requires_numeric_threshold( + async_client: AsyncClient, +) -> None: + job_id = await _create_job(async_client) + + response = await async_client.post( + f"/api/jobs/{job_id}/alert-rules", + json={ + "condition_type": "http_status_gte", + "condition_value": "not-a-number", + }, + ) + assert response.status_code == 422 + + +async def test_viewer_can_read_but_not_create_alert_rules( + viewer_client: AsyncClient, + db_session: AsyncSession, +) -> None: + job = Job( + id=uuid.uuid4(), + name="Viewer target", + type="http", + method="GET", + url="https://example.com", + schedule_type="manual", + timeout_seconds=30, + retry_count=0, + retry_delay_seconds=0, + alert_on_failure=False, + ) + db_session.add(job) + await db_session.commit() + + read = await viewer_client.get(f"/api/jobs/{job.id}/alert-rules") + assert read.status_code == 200 + + write = await viewer_client.post( + f"/api/jobs/{job.id}/alert-rules", + json={"condition_type": "http_status_gte", "condition_value": "500"}, + ) + assert write.status_code == 403 + + +async def test_alert_rules_respect_workspace_header( + operator_client: AsyncClient, + db_session: AsyncSession, +) -> None: + allowed_ws = Workspace(name="Allowed Rules", slug="allowed-rules") + other_ws = Workspace(name="Other Rules", slug="other-rules") + db_session.add_all([allowed_ws, other_ws]) + await db_session.flush() + db_session.add( + WorkspaceMembership( + workspace_id=allowed_ws.id, + user_id=_FAKE_OPERATOR.id, + role="member", + ) + ) + job = Job( + name="Other workspace job", + type="http", + method="GET", + url="https://example.com", + schedule_type="manual", + timeout_seconds=30, + retry_count=0, + retry_delay_seconds=0, + alert_on_failure=False, + workspace_id=other_ws.id, + ) + db_session.add(job) + await db_session.commit() + + response = await operator_client.get( + f"/api/jobs/{job.id}/alert-rules", + headers={"X-Workspace-ID": str(allowed_ws.id)}, + ) + assert response.status_code == 404 + + +def _mock_httpx_response(status_code: int, text: str, is_success: bool): + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.is_success = is_success + mock_resp.text = text + return mock_resp + + +async def test_http_runner_creates_alert_from_matching_rule( + db_session: AsyncSession, +) -> None: + job = Job( + id=uuid.uuid4(), + name="Runner Rule Target", + type="http", + method="GET", + url="http://example.com/fail", + timeout_seconds=10, + retry_count=0, + retry_delay_seconds=60, + alert_on_failure=False, + headers_encrypted=None, + body_encrypted=None, + status="active", + schedule_type="manual", + ) + db_session.add(job) + await db_session.flush() + rule = AlertRule( + job_id=job.id, + condition_type="http_status_gte", + condition_value="500", + severity="error", + message="Rule triggered", + ) + db_session.add(rule) + await db_session.commit() + + mock_resp = _mock_httpx_response(503, "Service Unavailable", False) + with patch("httpx.AsyncClient") as mock_cls: + inst = AsyncMock() + inst.__aenter__ = AsyncMock(return_value=inst) + inst.__aexit__ = AsyncMock(return_value=None) + inst.request = AsyncMock(return_value=mock_resp) + mock_cls.return_value = inst + + await run_job_http(job, db_session) + + result = await db_session.execute(select(Alert).where(Alert.source_id == rule.id)) + alert = result.scalar_one() + assert alert.title == "Rule triggered" + assert alert.message == "HTTP 503 >= 500" + assert alert.severity == "error" + assert alert.source_type == "alert_rule" + + +async def test_worker_creates_alert_from_matching_rule( + db_session: AsyncSession, + monkeypatch, +) -> None: + job = Job( + id=uuid.uuid4(), + name="Worker Rule Target", + type="http", + method="GET", + url="http://example.com/fail", + timeout_seconds=10, + retry_count=0, + retry_delay_seconds=60, + alert_on_failure=False, + headers_encrypted=None, + body_encrypted=None, + status="active", + schedule_type="manual", + ) + execution = Execution( + id=uuid.uuid4(), + job_id=job.id, + trigger_type="manual", + status="queued", + ) + rule = AlertRule( + job_id=job.id, + condition_type="http_status_gte", + condition_value="500", + severity="error", + message="Worker rule triggered", + ) + db_session.add_all([job, execution, rule]) + await db_session.commit() + + from contextlib import asynccontextmanager + + @asynccontextmanager + async def factory(): + yield db_session + + monkeypatch.setattr(tasks, "async_session_factory", factory) + + mock_resp = _mock_httpx_response(503, "Service Unavailable", False) + with patch("httpx.AsyncClient") as mock_cls: + inst = AsyncMock() + inst.__aenter__ = AsyncMock(return_value=inst) + inst.__aexit__ = AsyncMock(return_value=None) + inst.request = AsyncMock(return_value=mock_resp) + mock_cls.return_value = inst + + result = await tasks._execute_http_job( + _FakeTask(), job.id, execution.id, "manual" + ) + + assert result["status"] == "failure" + alerts = (await db_session.execute(select(Alert))).scalars().all() + assert len(alerts) == 1 + assert alerts[0].title == "Worker rule triggered" + assert alerts[0].source_id == rule.id diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 2d7037e..9e12997 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -8,6 +8,7 @@ from app.models import ( Alert, + AlertRule, AuditLog, Base, Execution, @@ -54,6 +55,10 @@ def test_alert_table_name(): assert Alert.__tablename__ == "alerts" +def test_alert_rule_table_name(): + assert AlertRule.__tablename__ == "alert_rules" + + def test_report_table_name(): assert Report.__tablename__ == "reports" @@ -79,6 +84,7 @@ def test_all_tables_registered(): "webhooks", "webhook_events", "alerts", + "alert_rules", "reports", "notification_channels", "notification_deliveries", @@ -105,6 +111,7 @@ async def test_create_all_tables(sqlite_engine): "webhooks", "webhook_events", "alerts", + "alert_rules", "reports", "notification_channels", "notification_deliveries", diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index ae97ffc..6f267a7 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -4,6 +4,7 @@ from app.models.alert import Alert from app.models.notification import NotificationDelivery +from app.models.workspace import Workspace from app.services.notifications import ( _send_custom_webhook, _send_opsgenie, @@ -247,6 +248,48 @@ async def fake_send(channel_type, config, payload): assert deliveries[0].status == "success" +async def test_dispatch_alert_notifications_respects_workspace( + db_session: AsyncSession, + async_client: AsyncClient, + monkeypatch, +) -> None: + sent: list[str] = [] + + async def fake_send(channel_type, config, payload): + sent.append(payload["alert_id"]) + + monkeypatch.setattr("app.services.notifications._send_channel", fake_send) + ws_a = Workspace(name="Notify A", slug="notify-a") + ws_b = Workspace(name="Notify B", slug="notify-b") + db_session.add_all([ws_a, ws_b]) + await db_session.commit() + await db_session.refresh(ws_a) + await db_session.refresh(ws_b) + + await async_client.post( + "/api/notification-channels", + json={ + "name": "Workspace A channel", + "type": "discord_webhook", + "config": {"webhook_url": "https://discord.com/api/webhooks/123/secret"}, + }, + headers={"X-Workspace-ID": str(ws_a.id)}, + ) + alert = Alert( + title="Workspace B alert", + message="boom", + severity="error", + workspace_id=ws_b.id, + ) + db_session.add(alert) + await db_session.commit() + await db_session.refresh(alert) + + deliveries = await dispatch_alert_notifications(db_session, alert) + assert deliveries == [] + assert sent == [] + + async def test_dispatch_alert_notifications_records_masked_failure( db_session: AsyncSession, async_client: AsyncClient, diff --git a/docs/api-reference.md b/docs/api-reference.md index 148742a..15e33bf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -44,7 +44,7 @@ Response `200 OK`: ```json { - "version": "1.0.0", + "version": "1.2.0", "app": "AutoFlowOps" } ``` @@ -204,6 +204,80 @@ The worker updates the same execution to `running`, then to `success`, `failure` --- +## Alert Rules + +Alert rules are scoped to a job and are evaluated after each execution completes. They can create alerts from custom operational conditions beyond the default "job failed" alert. + +### GET /api/jobs/{job_id}/alert-rules + +List alert rules for a job. + +```bash +curl http://localhost:8000/api/jobs/550e8400-e29b-41d4-a716-446655440000/alert-rules +``` + +Response `200 OK` — array of rule objects. + +### POST /api/jobs/{job_id}/alert-rules + +Create an alert rule. Requires operator or admin access. + +```bash +curl -X POST http://localhost:8000/api/jobs/550e8400-e29b-41d4-a716-446655440000/alert-rules \ + -H "Content-Type: application/json" \ + -d '{ + "condition_type": "http_status_gte", + "condition_value": "500", + "severity": "error", + "message": "Backend returned an error" + }' +``` + +Supported `condition_type` values: + +| Value | Meaning | +| --- | --- | +| `http_status_gte` | Response status code is greater than or equal to an HTTP status from `100` to `599` | +| `duration_ms_gte` | Execution duration is greater than or equal to a positive millisecond threshold | +| `response_body_contains` | Response body preview contains the configured text | +| `consecutive_failures_gte` | Most recent completed executions contain at least this many consecutive failures/timeouts | + +Request body fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `condition_type` | string | Yes | One of the supported condition types | +| `condition_value` | string | Yes | Threshold or text pattern | +| `severity` | string | No | `error`, `warning` or `info`; default: `warning` | +| `message` | string | No | Optional alert title override | +| `is_enabled` | boolean | No | Default: `true` | + +Response `201 Created` — the created rule object. + +### PATCH /api/jobs/{job_id}/alert-rules/{rule_id} + +Update an alert rule. Requires operator or admin access. + +```bash +curl -X PATCH http://localhost:8000/api/jobs/550e8400-e29b-41d4-a716-446655440000/alert-rules/770e8400-e29b-41d4-a716-446655440002 \ + -H "Content-Type: application/json" \ + -d '{"is_enabled": false}' +``` + +Response `200 OK` — the updated rule object. + +### DELETE /api/jobs/{job_id}/alert-rules/{rule_id} + +Delete an alert rule. Requires operator or admin access. + +```bash +curl -X DELETE http://localhost:8000/api/jobs/550e8400-e29b-41d4-a716-446655440000/alert-rules/770e8400-e29b-41d4-a716-446655440002 +``` + +Response `204 No Content`. + +--- + ## Webhooks ### GET /api/webhooks diff --git a/docs/architecture.md b/docs/architecture.md index b49421b..b7505f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,6 +76,7 @@ The Alembic schema creates these tables: | `webhooks` | Inbound webhook definitions: slug, hashed token, status | | `webhook_events` | Individual events received at each webhook endpoint | | `alerts` | Internal operational alerts generated by failures or created manually | +| `alert_rules` | Per-job conditional alert rules for status, duration, response body and consecutive failure thresholds | | `notification_channels` | External notification destinations and masked channel configuration | | `notification_deliveries` | Per-alert delivery outcomes for configured notification channels | | `reports` | Saved canonical report content (JSON) and metadata | diff --git a/docs/deployment.md b/docs/deployment.md index 2c3eecf..84d4f09 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -19,7 +19,7 @@ AutoFlowOps publishes versioned Docker images to GHCR on every release tag. No l | Backend | `ghcr.io/btcneves/autoflowops-backend` | | Frontend | `ghcr.io/btcneves/autoflowops-frontend` | -Available tags: `latest`, `vX.Y.Z`, `X.Y.Z`, `X.Y` (e.g. `v1.0.0`, `1.0.0`, `1.0`). +Available tags: `latest`, `vX.Y.Z`, `X.Y.Z`, `X.Y` (e.g. `v1.2.0`, `1.2.0`, `1.2`). ### Quick setup (interactive) @@ -42,7 +42,7 @@ The script: ### Non-interactive (CI / scripted environments) ```bash -IMAGE_TAG=v1.1.0 bash scripts/setup.sh +IMAGE_TAG=v1.2.0 bash scripts/setup.sh ``` ### Makefile shortcuts @@ -52,7 +52,7 @@ IMAGE_TAG=v1.1.0 bash scripts/setup.sh make pull # Start stack using GHCR images -IMAGE_TAG=v1.1.0 make registry-up +IMAGE_TAG=v1.2.0 make registry-up # Stop make registry-down @@ -64,15 +64,15 @@ make registry-logs ### Pinning a specific version ```bash -IMAGE_TAG=v1.1.0 docker compose -f docker-compose.registry.yml up -d +IMAGE_TAG=v1.2.0 docker compose -f docker-compose.registry.yml up -d ``` ### Updating to a new version ```bash -IMAGE_TAG=v1.1.0 make pull -IMAGE_TAG=v1.1.0 make registry-down -IMAGE_TAG=v1.1.0 make registry-up +IMAGE_TAG=v1.2.0 make pull +IMAGE_TAG=v1.2.0 make registry-down +IMAGE_TAG=v1.2.0 make registry-up ``` The backend container runs `alembic upgrade head` on startup and applies any pending migrations automatically. @@ -402,7 +402,7 @@ docker compose -f docker-compose.prod.yml ps | WS badge stays "Connecting…" | Backend unavailable or wrong `VITE_API_BASE_URL` | Verify `VITE_API_BASE_URL` is set correctly; check browser console for WS errors | | WS events not received | Redis not running | `logs redis`; verify `REDIS_URL`; backend logs show "Redis WS subscriber exited" if disconnected | | `docker pull` fails with 403 | GHCR package is private | Go to repository → Packages → make package public, or `docker login ghcr.io` with a token | -| Stack starts but shows old version | IMAGE_TAG not set or `latest` cached | Run `make pull IMAGE_TAG=v1.1.0` then `make registry-up IMAGE_TAG=v1.1.0` | +| Stack starts but shows old version | IMAGE_TAG not set or `latest` cached | Run `make pull IMAGE_TAG=v1.2.0` then `make registry-up IMAGE_TAG=v1.2.0` | ### Exec into a container diff --git a/docs/pt-br/architecture.md b/docs/pt-br/architecture.md index 06de159..bf5f0d9 100644 --- a/docs/pt-br/architecture.md +++ b/docs/pt-br/architecture.md @@ -76,6 +76,7 @@ O schema Alembic cria as seguintes tabelas: | `webhooks` | Definições de webhooks de entrada: slug, token com hash, status | | `webhook_events` | Eventos individuais recebidos em cada endpoint de webhook | | `alerts` | Alertas operacionais internos gerados por falhas ou criados manualmente | +| `alert_rules` | Regras condicionais de alerta por job para status, duração, corpo da resposta e limites de falhas consecutivas | | `notification_channels` | Destinos externos de notificação e configuração mascarada do canal | | `notification_deliveries` | Resultados de entrega por alerta para os canais de notificação configurados | | `reports` | Conteúdo canônico salvo do relatório (JSON) e metadados | diff --git a/docs/pt-br/deployment.md b/docs/pt-br/deployment.md index 0a955f9..e71d4ad 100644 --- a/docs/pt-br/deployment.md +++ b/docs/pt-br/deployment.md @@ -19,7 +19,7 @@ O AutoFlowOps publica imagens Docker versionadas no GHCR a cada tag de release. | Backend | `ghcr.io/btcneves/autoflowops-backend` | | Frontend | `ghcr.io/btcneves/autoflowops-frontend` | -Tags disponíveis: `latest`, `vX.Y.Z`, `X.Y.Z`, `X.Y` (ex: `v1.0.0`, `1.0.0`, `1.0`). +Tags disponíveis: `latest`, `vX.Y.Z`, `X.Y.Z`, `X.Y` (ex: `v1.2.0`, `1.2.0`, `1.2`). ### Setup rápido (interativo) @@ -42,7 +42,7 @@ O script: ### Não-interativo (CI / ambientes com script) ```bash -IMAGE_TAG=v1.0.0 bash scripts/setup.sh +IMAGE_TAG=v1.2.0 bash scripts/setup.sh ``` ### Atalhos do Makefile @@ -52,7 +52,7 @@ IMAGE_TAG=v1.0.0 bash scripts/setup.sh make pull # Iniciar stack usando imagens GHCR -IMAGE_TAG=v1.0.0 make registry-up +IMAGE_TAG=v1.2.0 make registry-up # Parar make registry-down @@ -64,15 +64,15 @@ make registry-logs ### Fixar uma versão específica ```bash -IMAGE_TAG=v1.0.0 docker compose -f docker-compose.registry.yml up -d +IMAGE_TAG=v1.2.0 docker compose -f docker-compose.registry.yml up -d ``` ### Atualizar para uma nova versão ```bash -IMAGE_TAG=v1.0.0 make pull -IMAGE_TAG=v1.0.0 make registry-down -IMAGE_TAG=v1.0.0 make registry-up +IMAGE_TAG=v1.2.0 make pull +IMAGE_TAG=v1.2.0 make registry-down +IMAGE_TAG=v1.2.0 make registry-up ``` O container do backend executa `alembic upgrade head` na inicialização e aplica qualquer migração pendente automaticamente. @@ -399,7 +399,7 @@ docker compose -f docker-compose.prod.yml ps | Badge WS fica em "Conectando…" | Backend indisponível ou `VITE_API_BASE_URL` incorreto | Verifique se `VITE_API_BASE_URL` está configurado corretamente; verifique console do browser para erros WS | | Eventos WS não recebidos | Redis não está rodando | `logs redis`; verifique `REDIS_URL`; logs do backend mostram "Redis WS subscriber exited" se desconectado | | `docker pull` falha com 403 | Pacote GHCR é privado | Vá para repositório → Packages → torne o pacote público, ou faça `docker login ghcr.io` com um token | -| Stack inicia mas mostra versão antiga | `IMAGE_TAG` não definido ou `latest` em cache | Execute `make pull IMAGE_TAG=v1.0.0` depois `make registry-up IMAGE_TAG=v1.0.0` | +| Stack inicia mas mostra versão antiga | `IMAGE_TAG` não definido ou `latest` em cache | Execute `make pull IMAGE_TAG=v1.2.0` depois `make registry-up IMAGE_TAG=v1.2.0` | ### Entrar em um container diff --git a/docs/pt-br/roadmap.md b/docs/pt-br/roadmap.md index 1b5ab61..68debce 100644 --- a/docs/pt-br/roadmap.md +++ b/docs/pt-br/roadmap.md @@ -8,6 +8,7 @@ Este documento acompanha o status das funcionalidades do AutoFlowOps entre as fa | Funcionalidade | Detalhes | | --- | --- | +| **Regras condicionais de alerta** | Regras por job disparam alertas por limites de status HTTP, duração da execução, texto na resposta e contagem de falhas consecutivas; API CRUD, UI no detalhe do job e testes backend/frontend | | **Validação de membership em workspace** | Usuários não-admin recebem `403 Forbidden` ao usar um `X-Workspace-ID` do qual não são membros; role admin ignora a verificação; 5 novos testes | | **Documentação da chave de criptografia** | `NOTIFICATION_ENCRYPTION_KEY` adicionada à tabela de referência de variáveis de ambiente e checklist de produção em `docs/deployment.md`; guia de backup e rotação adicionado em `docs/security.md` | | **Métricas Prometheus** | Endpoint `/metrics` em formato Prometheus via `prometheus-fastapi-instrumentator`; histograma HTTP auto-instrumentado; contadores de negócio `autoflowops_job_executions_total` e `autoflowops_alerts_created_total` | diff --git a/docs/release-notes-v1.2.0.md b/docs/release-notes-v1.2.0.md index 04dfdc9..553d231 100644 --- a/docs/release-notes-v1.2.0.md +++ b/docs/release-notes-v1.2.0.md @@ -4,7 +4,7 @@ ## Overview -v1.2.0 introduces a security fix that enforces workspace membership on all workspace-scoped requests, expands the deployment documentation for the notification encryption key, and ships a production-ready observability stack (Prometheus metrics, structured JSON logging, and an optional Prometheus + Grafana compose stack with a pre-built dashboard). +v1.2.0 introduces conditional alert rules for per-job operational thresholds, hardens workspace isolation on resource-specific endpoints and notification dispatch, expands the deployment documentation for the notification encryption key, and ships a production-ready observability stack (Prometheus metrics, structured JSON logging, and an optional Prometheus + Grafana compose stack with a pre-built dashboard). All changes are backward-compatible. Deployments that do not use the `X-Workspace-ID` header are unaffected by the membership enforcement. @@ -12,6 +12,17 @@ All changes are backward-compatible. Deployments that do not use the `X-Workspac ## What's New +### Conditional alert rules + +Jobs can now define enabled/disabled alert rules that create internal alerts from: + +- HTTP status thresholds (`http_status_gte`) +- Execution duration thresholds (`duration_ms_gte`) +- Response body text matches (`response_body_contains`) +- Consecutive failure counts (`consecutive_failures_gte`) + +Rules are managed through `GET`, `POST`, `PATCH` and `DELETE /api/jobs/{job_id}/alert-rules`, and the job detail page includes a rules section for operators and admins. The Celery worker evaluates these rules after final retry handling so queued/scheduled jobs behave the same as inline executions. + ### Workspace membership enforcement (security) Prior to this release, any authenticated user could access another workspace's data by supplying an arbitrary workspace UUID in the `X-Workspace-ID` header. Starting with v1.2.0, the `get_active_workspace` dependency validates that the requesting user is a member of the target workspace before returning any data. @@ -64,7 +75,12 @@ A ready-to-run observability compose stack is provided in `docker-compose.observ ## Upgrade Steps -No database migration is required for this release. +Run the database migration included in this release. It creates the `alert_rules` table. + +```bash +cd backend +alembic upgrade head +``` ### Pull from registry @@ -106,9 +122,9 @@ All items from previous releases apply. Additional considerations for v1.2.0: ## Validation Plan - Backend lint: `cd backend && ruff check .` -- Backend tests: `cd backend && PYTHONPATH=. pytest` (239 tests) +- Backend tests: `cd backend && PYTHONPATH=. pytest` (260 tests) - Frontend lint: `cd frontend && npm run lint` -- Frontend tests: `cd frontend && npm test` +- Frontend tests: `cd frontend && npm test` (76 tests) - Frontend build: `cd frontend && npm run build` - Full local lint/test: `make lint && make test` - Local Docker build: `docker compose build` diff --git a/docs/roadmap.md b/docs/roadmap.md index def2b96..95ef923 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,6 +8,7 @@ This document tracks the feature status of AutoFlowOps across completed, planned | Feature | Details | | --- | --- | +| **Conditional alert rules** | Per-job rules trigger alerts from HTTP status thresholds, execution duration thresholds, response body matching and consecutive failure counts; CRUD API, job detail UI and backend/frontend tests | | **Workspace membership enforcement** | Non-admin users receive `403 Forbidden` when supplying an `X-Workspace-ID` they are not a member of; admin role bypasses the check; 5 new tests | | **Encryption key documentation** | `NOTIFICATION_ENCRYPTION_KEY` added to `docs/deployment.md` env reference and production checklist; backup and rotation guide added to `docs/security.md` | | **Prometheus metrics** | `/metrics` endpoint in Prometheus text format via `prometheus-fastapi-instrumentator`; HTTP histogram auto-instrumented; `autoflowops_job_executions_total` and `autoflowops_alerts_created_total` business counters | @@ -199,7 +200,6 @@ These features are planned but not yet in active development. | Feature | Description | | --- | --- | -| **Conditional alert rules** | Trigger alerts based on custom thresholds or field conditions beyond job failure | | **Notification provider extensions** | Richer provider-specific delivery options (PagerDuty dedup keys, OpsGenie alias, custom payload templates) | | **Log aggregation integration** | First-class Loki or Elastic shipping configuration with documented label schema | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3ebe3cb..dc34bf9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "autoflowops-frontend", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autoflowops-frontend", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@tanstack/react-query": "^5.59.20", "i18next": "^26.2.0", diff --git a/frontend/package.json b/frontend/package.json index eac6a64..28facb8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "autoflowops-frontend", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/alert_rules.ts b/frontend/src/api/alert_rules.ts new file mode 100644 index 0000000..27e9d62 --- /dev/null +++ b/frontend/src/api/alert_rules.ts @@ -0,0 +1,33 @@ +import { apiFetch } from './client' +import type { AlertRuleCreate, AlertRuleRead, AlertRuleUpdate } from '../types' + +export function getAlertRules(jobId: string): Promise { + return apiFetch(`/api/jobs/${jobId}/alert-rules`) +} + +export function createAlertRule( + jobId: string, + payload: AlertRuleCreate, +): Promise { + return apiFetch(`/api/jobs/${jobId}/alert-rules`, { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function updateAlertRule( + jobId: string, + ruleId: string, + payload: AlertRuleUpdate, +): Promise { + return apiFetch(`/api/jobs/${jobId}/alert-rules/${ruleId}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export function deleteAlertRule(jobId: string, ruleId: string): Promise { + return apiFetch(`/api/jobs/${jobId}/alert-rules/${ruleId}`, { + method: 'DELETE', + }) +} diff --git a/frontend/src/hooks/useAlertRules.ts b/frontend/src/hooks/useAlertRules.ts new file mode 100644 index 0000000..f42e0df --- /dev/null +++ b/frontend/src/hooks/useAlertRules.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + createAlertRule, + deleteAlertRule, + getAlertRules, + updateAlertRule, +} from '../api/alert_rules' +import type { AlertRuleCreate, AlertRuleUpdate } from '../types' + +export function useAlertRules(jobId: string) { + return useQuery({ + queryKey: ['alert-rules', jobId], + queryFn: () => getAlertRules(jobId), + }) +} + +export function useCreateAlertRule(jobId: string) { + const client = useQueryClient() + return useMutation({ + mutationFn: (payload: AlertRuleCreate) => createAlertRule(jobId, payload), + onSuccess: () => { + client.invalidateQueries({ queryKey: ['alert-rules', jobId] }) + client.invalidateQueries({ queryKey: ['alerts'] }) + }, + }) +} + +export function useUpdateAlertRule(jobId: string) { + const client = useQueryClient() + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: AlertRuleUpdate }) => + updateAlertRule(jobId, id, payload), + onSuccess: () => client.invalidateQueries({ queryKey: ['alert-rules', jobId] }), + }) +} + +export function useDeleteAlertRule(jobId: string) { + const client = useQueryClient() + return useMutation({ + mutationFn: (id: string) => deleteAlertRule(jobId, id), + onSuccess: () => client.invalidateQueries({ queryKey: ['alert-rules', jobId] }), + }) +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 406da49..a713bb4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -107,7 +107,51 @@ "colDuration": "Duration", "colHttp": "HTTP", "colError": "Error", - "details": "Details" + "details": "Details", + "alertRules": "Alert Rules", + "alertRulesSubtitle": "Trigger alerts from HTTP status, runtime, response text or repeated failures.", + "alertRuleConditionLabel": "Condition", + "alertRuleValueLabel": "Value", + "alertRuleSeverityLabel": "Severity", + "alertRuleMessageLabel": "Message", + "alertRuleMessagePlaceholder": "Optional alert title", + "alertRuleValueRequired": "Condition value is required.", + "alertRuleValueInteger": "Use a positive integer for this condition.", + "alertRuleValueHttpStatus": "HTTP status must be between 100 and 599.", + "alertRuleSaving": "Saving…", + "alertRuleCreate": "Add rule", + "alertRulesLoading": "Loading alert rules…", + "alertRulesError": "Could not load alert rules.", + "alertRulesEmpty": "No alert rules configured.", + "alertRuleColCondition": "Condition", + "alertRuleColValue": "Value", + "alertRuleColSeverity": "Severity", + "alertRuleColMessage": "Message", + "alertRuleColStatus": "Status", + "alertRuleColActions": "Actions", + "alertRuleEnabled": "Enabled", + "alertRuleDisabled": "Disabled", + "alertRuleDelete": "Delete", + "alertRuleConfirmDelete": "Delete this alert rule? This cannot be undone.", + "alertRuleToggleLabel": "Toggle alert rule {{message}}", + "alertRuleDeleteLabel": "Delete alert rule {{message}}", + "alertRuleCondition": { + "http_status_gte": "HTTP status >=", + "duration_ms_gte": "Duration >=", + "response_body_contains": "Response contains", + "consecutive_failures_gte": "Consecutive failures >=" + }, + "alertRulePlaceholder": { + "http_status_gte": "500", + "duration_ms_gte": "2000", + "response_body_contains": "error", + "consecutive_failures_gte": "3" + }, + "alertRuleSeverity": { + "error": "Error", + "warning": "Warning", + "info": "Info" + } }, "jobForm": { "titleEdit": "Edit Job", diff --git a/frontend/src/i18n/locales/pt-br.json b/frontend/src/i18n/locales/pt-br.json index da4725a..b22221f 100644 --- a/frontend/src/i18n/locales/pt-br.json +++ b/frontend/src/i18n/locales/pt-br.json @@ -107,7 +107,51 @@ "colDuration": "Duração", "colHttp": "HTTP", "colError": "Erro", - "details": "Detalhes" + "details": "Detalhes", + "alertRules": "Regras de Alerta", + "alertRulesSubtitle": "Dispare alertas por status HTTP, duração, texto da resposta ou falhas repetidas.", + "alertRuleConditionLabel": "Condição", + "alertRuleValueLabel": "Valor", + "alertRuleSeverityLabel": "Severidade", + "alertRuleMessageLabel": "Mensagem", + "alertRuleMessagePlaceholder": "Título opcional do alerta", + "alertRuleValueRequired": "O valor da condição é obrigatório.", + "alertRuleValueInteger": "Use um número inteiro positivo para esta condição.", + "alertRuleValueHttpStatus": "O status HTTP deve estar entre 100 e 599.", + "alertRuleSaving": "Salvando…", + "alertRuleCreate": "Adicionar regra", + "alertRulesLoading": "Carregando regras de alerta…", + "alertRulesError": "Não foi possível carregar as regras de alerta.", + "alertRulesEmpty": "Nenhuma regra de alerta configurada.", + "alertRuleColCondition": "Condição", + "alertRuleColValue": "Valor", + "alertRuleColSeverity": "Severidade", + "alertRuleColMessage": "Mensagem", + "alertRuleColStatus": "Status", + "alertRuleColActions": "Ações", + "alertRuleEnabled": "Ativa", + "alertRuleDisabled": "Inativa", + "alertRuleDelete": "Excluir", + "alertRuleConfirmDelete": "Excluir esta regra de alerta? Esta ação não pode ser desfeita.", + "alertRuleToggleLabel": "Alternar regra de alerta {{message}}", + "alertRuleDeleteLabel": "Excluir regra de alerta {{message}}", + "alertRuleCondition": { + "http_status_gte": "Status HTTP >=", + "duration_ms_gte": "Duração >=", + "response_body_contains": "Resposta contém", + "consecutive_failures_gte": "Falhas consecutivas >=" + }, + "alertRulePlaceholder": { + "http_status_gte": "500", + "duration_ms_gte": "2000", + "response_body_contains": "erro", + "consecutive_failures_gte": "3" + }, + "alertRuleSeverity": { + "error": "Erro", + "warning": "Aviso", + "info": "Info" + } }, "jobForm": { "titleEdit": "Editar Job", diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx index ea17f03..7503ca4 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -1,8 +1,21 @@ +import { FormEvent, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useDeleteJob, useJob, useRunJob, useUpdateJob } from '../hooks/useJobs' import { useExecutions } from '../hooks/useExecutions' -import type { ExecutionRead } from '../types' +import { + useAlertRules, + useCreateAlertRule, + useDeleteAlertRule, + useUpdateAlertRule, +} from '../hooks/useAlertRules' +import type { + AlertRuleConditionType, + AlertRuleRead, + AlertRuleSeverity, + ExecutionRead, +} from '../types' +import { useAuth } from '../contexts/AuthContext' function formatDate(iso: string | null): string { if (!iso) return '—' @@ -55,6 +68,253 @@ function ExecutionRow({ exc }: { exc: ExecutionRead }) { ) } +const CONDITION_TYPES: AlertRuleConditionType[] = [ + 'http_status_gte', + 'duration_ms_gte', + 'response_body_contains', + 'consecutive_failures_gte', +] + +const SEVERITIES: AlertRuleSeverity[] = ['warning', 'error', 'info'] + +function AlertRuleRow({ + jobId, + rule, + canManage, +}: { + jobId: string + rule: AlertRuleRead + canManage: boolean +}) { + const { t } = useTranslation() + const update = useUpdateAlertRule(jobId) + const remove = useDeleteAlertRule(jobId) + + function handleDelete() { + if (!confirm(t('jobDetail.alertRuleConfirmDelete'))) return + remove.mutate(rule.id) + } + + return ( + + + {t(`jobDetail.alertRuleCondition.${rule.condition_type}`)} + + {rule.condition_value} + + {t(`jobDetail.alertRuleSeverity.${rule.severity}`)} + + + {rule.message || '—'} + + + {canManage ? ( + + ) : ( + + {rule.is_enabled ? t('jobDetail.alertRuleEnabled') : t('jobDetail.alertRuleDisabled')} + + )} + + + {canManage ? ( + + ) : ( + — + )} + + + ) +} + +function AlertRulesSection({ jobId }: { jobId: string }) { + const { t } = useTranslation() + const { isOperator } = useAuth() + const { data: rules, isLoading, isError } = useAlertRules(jobId) + const create = useCreateAlertRule(jobId) + const [conditionType, setConditionType] = useState('http_status_gte') + const [conditionValue, setConditionValue] = useState('') + const [severity, setSeverity] = useState('warning') + const [message, setMessage] = useState('') + const [error, setError] = useState(null) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + setError(null) + if (!conditionValue.trim()) { + setError(t('jobDetail.alertRuleValueRequired')) + return + } + if ( + conditionType !== 'response_body_contains' && + (!/^\d+$/.test(conditionValue.trim()) || Number(conditionValue.trim()) < 1) + ) { + setError(t('jobDetail.alertRuleValueInteger')) + return + } + if ( + conditionType === 'http_status_gte' && + (Number(conditionValue.trim()) < 100 || Number(conditionValue.trim()) > 599) + ) { + setError(t('jobDetail.alertRuleValueHttpStatus')) + return + } + const payload = { + condition_type: conditionType, + condition_value: conditionValue.trim(), + severity, + message: message.trim() || null, + is_enabled: true, + } + void create + .mutateAsync(payload) + .then(() => { + setConditionValue('') + setMessage('') + }) + .catch((err: Error) => setError(err.message)) + } + + return ( +
+
+

{t('jobDetail.alertRules')}

+

{t('jobDetail.alertRulesSubtitle')}

+
+ + {isOperator && ( +
+
+ + + + +
+ {error && ( +

+ {error} +

+ )} + +
+ )} + + {isLoading &&

{t('jobDetail.alertRulesLoading')}

} + {isError && ( +
+ {t('jobDetail.alertRulesError')} +
+ )} + {!isLoading && !isError && rules && rules.length === 0 && ( +

{t('jobDetail.alertRulesEmpty')}

+ )} + {!isLoading && !isError && rules && rules.length > 0 && ( +
+ + + + + + + + + + + + + {rules.map((rule) => ( + + ))} + +
{t('jobDetail.alertRuleColCondition')}{t('jobDetail.alertRuleColValue')}{t('jobDetail.alertRuleColSeverity')}{t('jobDetail.alertRuleColMessage')}{t('jobDetail.alertRuleColStatus')}{t('jobDetail.alertRuleColActions')}
+
+ )} +
+ ) +} + export function JobDetailPage() { const { t } = useTranslation() const { id } = useParams<{ id: string }>() @@ -166,6 +426,8 @@ export function JobDetailPage() { + + {/* Recent executions */}
diff --git a/frontend/src/tests/JobDetailPage.test.tsx b/frontend/src/tests/JobDetailPage.test.tsx new file mode 100644 index 0000000..2655127 --- /dev/null +++ b/frontend/src/tests/JobDetailPage.test.tsx @@ -0,0 +1,110 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { JobDetailPage } from '../pages/JobDetailPage' + +vi.mock('../hooks/useJobs', () => ({ + useJob: vi.fn(), + useRunJob: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useUpdateJob: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + useDeleteJob: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), +})) + +vi.mock('../hooks/useExecutions', () => ({ + useExecutions: vi.fn(), +})) + +vi.mock('../hooks/useAlertRules', () => ({ + useAlertRules: vi.fn(), + useCreateAlertRule: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + useUpdateAlertRule: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useDeleteAlertRule: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})) + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: vi.fn(() => ({ isOperator: true })), +})) + +import { useExecutions } from '../hooks/useExecutions' +import { useAlertRules } from '../hooks/useAlertRules' +import { useJob } from '../hooks/useJobs' + +const SAMPLE_JOB = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'Health Check', + description: 'Daily API health check', + type: 'http', + status: 'active', + schedule_type: 'interval', + schedule_expression: '300', + method: 'GET', + url: 'https://api.example.com/health', + headers_masked: null, + timeout_seconds: 30, + retry_count: 0, + retry_delay_seconds: 60, + alert_on_failure: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + last_run_at: null, + next_run_at: null, +} + +const SAMPLE_RULE = { + id: 'rule-001', + job_id: SAMPLE_JOB.id, + condition_type: 'http_status_gte', + condition_value: '500', + severity: 'error', + message: 'Backend is failing', + is_enabled: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +} + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + } /> + + + + ) +} + +describe('JobDetailPage', () => { + beforeEach(() => { + vi.mocked(useJob).mockReturnValue({ + data: SAMPLE_JOB, + isLoading: false, + isError: false, + } as unknown as ReturnType) + vi.mocked(useExecutions).mockReturnValue({ + data: [], + isLoading: false, + isError: false, + } as unknown as ReturnType) + vi.mocked(useAlertRules).mockReturnValue({ + data: [SAMPLE_RULE], + isLoading: false, + isError: false, + } as unknown as ReturnType) + }) + + it('renders alert rules on the job detail page', () => { + renderPage() + + expect(screen.getByText('Alert Rules')).toBeInTheDocument() + expect(screen.getAllByText('HTTP status >=').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.getByText('Backend is failing')).toBeInTheDocument() + expect(screen.getByText('Enabled')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2a52b9b..149f3ca 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -334,6 +334,43 @@ export interface WorkspaceMemberCreate { role?: string } +// Alert rules +export type AlertRuleConditionType = + | 'http_status_gte' + | 'duration_ms_gte' + | 'response_body_contains' + | 'consecutive_failures_gte' + +export type AlertRuleSeverity = 'error' | 'warning' | 'info' + +export interface AlertRuleRead { + id: string + job_id: string + condition_type: AlertRuleConditionType + condition_value: string + severity: AlertRuleSeverity + message: string | null + is_enabled: boolean + created_at: string + updated_at: string +} + +export interface AlertRuleCreate { + condition_type: AlertRuleConditionType + condition_value: string + severity?: AlertRuleSeverity + message?: string | null + is_enabled?: boolean +} + +export interface AlertRuleUpdate { + condition_type?: AlertRuleConditionType + condition_value?: string + severity?: AlertRuleSeverity + message?: string | null + is_enabled?: boolean +} + // Audit logs export interface AuditLogRead { id: string