feat(awooop): preview recurrence repair work items
This commit is contained in:
@@ -10,13 +10,17 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.services.channel_event_dossier_service import (
|
from src.services.channel_event_dossier_service import (
|
||||||
|
RecurrenceWorkItemMode,
|
||||||
|
RecurrenceWorkItemNotFoundError,
|
||||||
fetch_channel_event_dossier,
|
fetch_channel_event_dossier,
|
||||||
fetch_channel_event_dossier_coverage,
|
fetch_channel_event_dossier_coverage,
|
||||||
fetch_channel_event_dossier_recurrence,
|
fetch_channel_event_dossier_recurrence,
|
||||||
|
fetch_recurrence_work_item_dry_run,
|
||||||
|
fetch_recurrence_work_item_preview,
|
||||||
)
|
)
|
||||||
from src.services.platform_operator_service import list_recent_channel_events
|
from src.services.platform_operator_service import list_recent_channel_events
|
||||||
|
|
||||||
@@ -170,6 +174,16 @@ class ChannelEventRecurrenceResponse(BaseModel):
|
|||||||
items: list[ChannelEventRecurrenceItem]
|
items: list[ChannelEventRecurrenceItem]
|
||||||
|
|
||||||
|
|
||||||
|
class RecurrenceWorkItemDryRunRequest(BaseModel):
|
||||||
|
"""AwoooP recurrence work item dry-run request."""
|
||||||
|
|
||||||
|
project_id: str | None = Field(default=None, min_length=1)
|
||||||
|
work_item_id: str = Field(min_length=1)
|
||||||
|
mode: RecurrenceWorkItemMode = "auto"
|
||||||
|
provider: str | None = Field(default=None, min_length=1)
|
||||||
|
limit: int = Field(default=300, ge=1, le=300)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events/dossier",
|
"/events/dossier",
|
||||||
response_model=ChannelEventDossierResponse,
|
response_model=ChannelEventDossierResponse,
|
||||||
@@ -241,6 +255,64 @@ async def get_event_dossier_recurrence(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/events/dossier/recurrence/work-item/preview",
|
||||||
|
summary="預覽重複告警工作項的安全處理計畫",
|
||||||
|
description=(
|
||||||
|
"依 recurrence read model 找出指定 work_item,返回下一步、pre-flight checks "
|
||||||
|
"與 read-only / no-write 保證;不修改 incident、auto-repair 或 ticket 狀態。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def preview_event_recurrence_work_item(
|
||||||
|
work_item_id: str = Query(..., min_length=1, description="recurrence work_item_id"),
|
||||||
|
project_id: str | None = Query(None, description="租戶 ID(可選)"),
|
||||||
|
provider: str | None = Query(
|
||||||
|
None, description="provider(可選,如 alertmanager / sentry / signoz)"
|
||||||
|
),
|
||||||
|
mode: RecurrenceWorkItemMode = Query("auto", description="預覽模式"),
|
||||||
|
limit: int = Query(300, ge=1, le=300, description="最多納入統計筆數"),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await fetch_recurrence_work_item_preview(
|
||||||
|
project_id=project_id,
|
||||||
|
work_item_id=work_item_id,
|
||||||
|
mode=mode,
|
||||||
|
provider=provider,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except RecurrenceWorkItemNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="recurrence_work_item_not_found",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/events/dossier/recurrence/work-item/dry-run",
|
||||||
|
summary="乾跑重複告警工作項的安全處理流程",
|
||||||
|
description=(
|
||||||
|
"依 recurrence read model 產生 dry-run 結果並寫入 pre-flight history,"
|
||||||
|
"但不修改 incident、auto-repair 或 ticket 狀態。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def dry_run_event_recurrence_work_item(
|
||||||
|
request: RecurrenceWorkItemDryRunRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await fetch_recurrence_work_item_dry_run(
|
||||||
|
project_id=request.project_id,
|
||||||
|
work_item_id=request.work_item_id,
|
||||||
|
mode=request.mode,
|
||||||
|
provider=request.provider,
|
||||||
|
limit=request.limit,
|
||||||
|
)
|
||||||
|
except RecurrenceWorkItemNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="recurrence_work_item_not_found",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events/recent",
|
"/events/recent",
|
||||||
response_model=RecentEventsResponse,
|
response_model=RecentEventsResponse,
|
||||||
|
|||||||
@@ -8,19 +8,27 @@ automation state.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import structlog
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.db.base import get_db_context
|
from src.db.base import get_db_context
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
_MAX_DOSSIER_EVENTS = 50
|
_MAX_DOSSIER_EVENTS = 50
|
||||||
_MAX_COVERAGE_EVENTS = 200
|
_MAX_COVERAGE_EVENTS = 200
|
||||||
_MAX_RECURRENCE_EVENTS = 300
|
_MAX_RECURRENCE_EVENTS = 300
|
||||||
_MAX_REPAIR_INCIDENTS = 200
|
_MAX_REPAIR_INCIDENTS = 200
|
||||||
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
||||||
|
RecurrenceWorkItemMode = Literal["auto", "ticket", "reverify", "approval_review", "observe"]
|
||||||
|
|
||||||
|
|
||||||
|
class RecurrenceWorkItemNotFoundError(LookupError):
|
||||||
|
"""Requested recurrence work item is not in the current read model."""
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(value: Any) -> dict[str, Any]:
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
@@ -390,6 +398,386 @@ def _attach_work_item_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_work_item_target(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"recurrence_key": item.get("recurrence_key"),
|
||||||
|
"provider": item.get("provider"),
|
||||||
|
"alertname": item.get("alertname"),
|
||||||
|
"severity": item.get("severity"),
|
||||||
|
"namespace": item.get("namespace"),
|
||||||
|
"target_resource": item.get("target_resource"),
|
||||||
|
"fingerprint": item.get("fingerprint"),
|
||||||
|
"latest_event_id": item.get("latest_event_id"),
|
||||||
|
"latest_provider_event_id": item.get("latest_provider_event_id"),
|
||||||
|
"latest_run_id": item.get("latest_run_id"),
|
||||||
|
"latest_run_state": item.get("latest_run_state"),
|
||||||
|
"latest_agent_id": item.get("latest_agent_id"),
|
||||||
|
"latest_incident_id": item.get("latest_incident_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_recurrence_mode(
|
||||||
|
work_item: dict[str, Any],
|
||||||
|
requested_mode: RecurrenceWorkItemMode,
|
||||||
|
) -> str:
|
||||||
|
if requested_mode != "auto":
|
||||||
|
return requested_mode
|
||||||
|
|
||||||
|
next_step = str(work_item.get("next_step") or "")
|
||||||
|
if next_step == "create_repair_ticket":
|
||||||
|
return "ticket"
|
||||||
|
if next_step in {
|
||||||
|
"run_post_verification",
|
||||||
|
"triage_failed_repair",
|
||||||
|
"review_repair_record",
|
||||||
|
"triage_missing_repair_record",
|
||||||
|
}:
|
||||||
|
return "reverify"
|
||||||
|
if next_step == "review_approval":
|
||||||
|
return "approval_review"
|
||||||
|
return "observe"
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_work_item_checks(
|
||||||
|
item: dict[str, Any],
|
||||||
|
work_item: dict[str, Any],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
repair_summary = _as_dict(item.get("repair_summary"))
|
||||||
|
source_ref_total = int(item.get("source_ref_total") or 0)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "work_item_open",
|
||||||
|
"passed": work_item.get("status") == "open",
|
||||||
|
"detail": str(work_item.get("status") or "unknown"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "incident_linked",
|
||||||
|
"passed": bool(work_item.get("incident_id")),
|
||||||
|
"detail": str(work_item.get("incident_id") or "missing incident_id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "known_next_step",
|
||||||
|
"passed": str(work_item.get("next_step") or "none") != "none",
|
||||||
|
"detail": str(work_item.get("next_step") or "none"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_refs_present",
|
||||||
|
"passed": source_ref_total > 0,
|
||||||
|
"detail": str(source_ref_total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_destructive_writes",
|
||||||
|
"passed": True,
|
||||||
|
"detail": "preview_and_dry_run_only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "repair_status_visible",
|
||||||
|
"passed": bool(repair_summary.get("status")),
|
||||||
|
"detail": str(repair_summary.get("status") or "unknown"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_plan(
|
||||||
|
item: dict[str, Any],
|
||||||
|
work_item: dict[str, Any],
|
||||||
|
mode: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
route_by_mode = {
|
||||||
|
"ticket": {
|
||||||
|
"step": "prepare_repair_ticket_preview",
|
||||||
|
"flywheel_node": "work_item_to_ticket",
|
||||||
|
"agent_id": "awooop_recurrence_coordinator",
|
||||||
|
},
|
||||||
|
"reverify": {
|
||||||
|
"step": "collect_read_model_and_prepare_reverification",
|
||||||
|
"flywheel_node": "verify",
|
||||||
|
"agent_id": "post_execution_verifier",
|
||||||
|
},
|
||||||
|
"approval_review": {
|
||||||
|
"step": "route_to_approval_review",
|
||||||
|
"flywheel_node": "approval",
|
||||||
|
"agent_id": "awooop_approval_coordinator",
|
||||||
|
},
|
||||||
|
"observe": {
|
||||||
|
"step": "observe_until_run_or_repair_state_changes",
|
||||||
|
"flywheel_node": "observe",
|
||||||
|
"agent_id": "awooop_recurrence_coordinator",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
route = route_by_mode.get(mode, route_by_mode["observe"])
|
||||||
|
return {
|
||||||
|
**route,
|
||||||
|
"required_scope": "read",
|
||||||
|
"writes": [],
|
||||||
|
"target_action": work_item.get("next_step"),
|
||||||
|
"reason": work_item.get("reason"),
|
||||||
|
"target": _recurrence_work_item_target(item),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_preview(item: dict[str, Any], work_item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
alertname = str(item.get("alertname") or item.get("provider") or "recurrence")
|
||||||
|
incident_id = str(work_item.get("incident_id") or item.get("latest_incident_id") or "")
|
||||||
|
kind = str(work_item.get("kind") or "recurrence")
|
||||||
|
title = f"[AwoooP] {alertname} recurrence work item: {incident_id or 'unlinked'}"
|
||||||
|
labels = ["awooop", "recurrence", kind]
|
||||||
|
body_lines = [
|
||||||
|
f"Incident: {incident_id or '--'}",
|
||||||
|
f"Alert: {alertname}",
|
||||||
|
f"Namespace/Target: {item.get('namespace') or '--'} / {item.get('target_resource') or '--'}",
|
||||||
|
f"Occurrences: {item.get('occurrence_total') or 0}",
|
||||||
|
f"Duplicates: {item.get('duplicate_total') or 0}",
|
||||||
|
f"Latest run: {item.get('latest_run_id') or '--'} ({item.get('latest_run_state') or '--'})",
|
||||||
|
f"Repair status: {_as_dict(item.get('repair_summary')).get('status') or '--'}",
|
||||||
|
f"Next step: {work_item.get('next_step') or '--'}",
|
||||||
|
"Writes: none in preview/dry-run; ticket creation requires a later explicit apply path.",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"would_create": False,
|
||||||
|
"title": title[:180],
|
||||||
|
"labels": labels,
|
||||||
|
"body_preview": "\n".join(body_lines)[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_current_state_summary(
|
||||||
|
item: dict[str, Any],
|
||||||
|
work_item: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
repair_summary = _as_dict(item.get("repair_summary"))
|
||||||
|
return {
|
||||||
|
"work_item_status": work_item.get("status"),
|
||||||
|
"work_item_kind": work_item.get("kind"),
|
||||||
|
"work_item_next_step": work_item.get("next_step"),
|
||||||
|
"work_item_reason": work_item.get("reason"),
|
||||||
|
"occurrence_total": int(item.get("occurrence_total") or 0),
|
||||||
|
"duplicate_total": int(item.get("duplicate_total") or 0),
|
||||||
|
"linked_run_total": int(item.get("linked_run_total") or 0),
|
||||||
|
"run_state_counts": item.get("run_state_counts") or {},
|
||||||
|
"latest_run_state": item.get("latest_run_state"),
|
||||||
|
"latest_run_id": item.get("latest_run_id"),
|
||||||
|
"repair_status": repair_summary.get("status"),
|
||||||
|
"latest_auto_repair_id": repair_summary.get("latest_auto_repair_id"),
|
||||||
|
"latest_verification_result": repair_summary.get("latest_verification_result"),
|
||||||
|
"auto_repair_total": int(repair_summary.get("auto_repair_total") or 0),
|
||||||
|
"source_ref_total": int(item.get("source_ref_total") or 0),
|
||||||
|
"sentry_ref_total": int(item.get("sentry_ref_total") or 0),
|
||||||
|
"signoz_ref_total": int(item.get("signoz_ref_total") or 0),
|
||||||
|
"alert_ref_total": int(item.get("alert_ref_total") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verification_result_preview(mode: str, allowed: bool) -> str:
|
||||||
|
if not allowed:
|
||||||
|
return "blocked"
|
||||||
|
return {
|
||||||
|
"ticket": "ticket_preview_ready",
|
||||||
|
"reverify": "reverify_preview_ready",
|
||||||
|
"approval_review": "approval_review_required",
|
||||||
|
"observe": "observe_only",
|
||||||
|
}.get(mode, "observe_only")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_recurrence_work_item(
|
||||||
|
recurrence: dict[str, Any],
|
||||||
|
work_item_id: str,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
for item in recurrence.get("items") or []:
|
||||||
|
work_item = _as_dict(item.get("work_item"))
|
||||||
|
if work_item.get("work_item_id") == work_item_id:
|
||||||
|
return item, work_item
|
||||||
|
raise RecurrenceWorkItemNotFoundError(work_item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def build_recurrence_work_item_preview(
|
||||||
|
recurrence: dict[str, Any],
|
||||||
|
*,
|
||||||
|
work_item_id: str,
|
||||||
|
mode: RecurrenceWorkItemMode = "auto",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a read-only plan for a recurrence work item."""
|
||||||
|
|
||||||
|
item, work_item = _find_recurrence_work_item(recurrence, work_item_id)
|
||||||
|
selected_mode = _selected_recurrence_mode(work_item, mode)
|
||||||
|
checks = _recurrence_work_item_checks(item, work_item)
|
||||||
|
allowed = all(check["passed"] for check in checks)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": "awooop_recurrence_work_item_preview_v1",
|
||||||
|
"source": "channel_event_dossier.recurrence",
|
||||||
|
"project_id": recurrence.get("project_id"),
|
||||||
|
"work_item_id": work_item.get("work_item_id"),
|
||||||
|
"incident_id": work_item.get("incident_id"),
|
||||||
|
"auto_repair_id": work_item.get("auto_repair_id"),
|
||||||
|
"mode": selected_mode,
|
||||||
|
"requested_mode": mode,
|
||||||
|
"allowed": allowed,
|
||||||
|
"safety_level": "read_only",
|
||||||
|
"writes_incident_state": False,
|
||||||
|
"writes_auto_repair_result": False,
|
||||||
|
"writes_ticket": False,
|
||||||
|
"checks": checks,
|
||||||
|
"plan": _recurrence_plan(item, work_item, selected_mode),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_recurrence_work_item_dry_run(
|
||||||
|
recurrence: dict[str, Any],
|
||||||
|
*,
|
||||||
|
work_item_id: str,
|
||||||
|
mode: RecurrenceWorkItemMode = "auto",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a read-only dry-run result for a recurrence work item."""
|
||||||
|
|
||||||
|
item, work_item = _find_recurrence_work_item(recurrence, work_item_id)
|
||||||
|
selected_mode = _selected_recurrence_mode(work_item, mode)
|
||||||
|
checks = _recurrence_work_item_checks(item, work_item)
|
||||||
|
allowed = all(check["passed"] for check in checks)
|
||||||
|
payload = {
|
||||||
|
"schema_version": "awooop_recurrence_work_item_dry_run_v1",
|
||||||
|
"source": "channel_event_dossier.recurrence",
|
||||||
|
"project_id": recurrence.get("project_id"),
|
||||||
|
"work_item_id": work_item.get("work_item_id"),
|
||||||
|
"incident_id": work_item.get("incident_id"),
|
||||||
|
"auto_repair_id": work_item.get("auto_repair_id"),
|
||||||
|
"mode": selected_mode,
|
||||||
|
"requested_mode": mode,
|
||||||
|
"allowed": allowed,
|
||||||
|
"executed": allowed,
|
||||||
|
"safety_level": "read_only",
|
||||||
|
"writes_incident_state": False,
|
||||||
|
"writes_auto_repair_result": False,
|
||||||
|
"writes_ticket": False,
|
||||||
|
"checks": checks,
|
||||||
|
"verification_result_preview": _verification_result_preview(
|
||||||
|
selected_mode,
|
||||||
|
allowed,
|
||||||
|
),
|
||||||
|
"current_state_summary": _recurrence_current_state_summary(item, work_item),
|
||||||
|
"ticket_preview": _ticket_preview(item, work_item),
|
||||||
|
"plan": _recurrence_plan(item, work_item, selected_mode),
|
||||||
|
"read_model_route": {
|
||||||
|
"agent_id": "awooop_recurrence_coordinator",
|
||||||
|
"tool_name": "channel_event_dossier.recurrence",
|
||||||
|
"required_scope": "read",
|
||||||
|
"is_shadow": True,
|
||||||
|
"flywheel_node": "work_item",
|
||||||
|
},
|
||||||
|
"next_step": work_item.get("next_step"),
|
||||||
|
}
|
||||||
|
if not allowed:
|
||||||
|
payload["executed"] = False
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_history_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "awooop_recurrence_work_item_dry_run_history_v1",
|
||||||
|
"source": payload.get("source"),
|
||||||
|
"project_id": payload.get("project_id"),
|
||||||
|
"work_item_id": payload.get("work_item_id"),
|
||||||
|
"incident_id": payload.get("incident_id"),
|
||||||
|
"auto_repair_id": payload.get("auto_repair_id"),
|
||||||
|
"mode": payload.get("mode"),
|
||||||
|
"requested_mode": payload.get("requested_mode"),
|
||||||
|
"allowed": payload.get("allowed"),
|
||||||
|
"executed": payload.get("executed"),
|
||||||
|
"safety_level": payload.get("safety_level"),
|
||||||
|
"writes_incident_state": payload.get("writes_incident_state"),
|
||||||
|
"writes_auto_repair_result": payload.get("writes_auto_repair_result"),
|
||||||
|
"writes_ticket": payload.get("writes_ticket"),
|
||||||
|
"verification_result_preview": payload.get("verification_result_preview"),
|
||||||
|
"current_state_summary": payload.get("current_state_summary"),
|
||||||
|
"ticket_preview": payload.get("ticket_preview"),
|
||||||
|
"read_model_route": payload.get("read_model_route"),
|
||||||
|
"checks": payload.get("checks"),
|
||||||
|
"next_step": payload.get("next_step"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_recurrence_work_item_dry_run_history(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
incident_id = str(payload.get("incident_id") or "")
|
||||||
|
if not incident_id:
|
||||||
|
return {"recorded": False, "reason": "missing_incident_id"}
|
||||||
|
|
||||||
|
history: dict[str, Any] = {
|
||||||
|
"recorded": False,
|
||||||
|
"alert_operation_id": None,
|
||||||
|
"timeline_event_id": None,
|
||||||
|
}
|
||||||
|
context = _recurrence_history_context(payload)
|
||||||
|
allowed = bool(payload.get("allowed"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.repositories.alert_operation_log_repository import (
|
||||||
|
get_alert_operation_log_repository,
|
||||||
|
)
|
||||||
|
|
||||||
|
record = await get_alert_operation_log_repository().append(
|
||||||
|
"PRE_FLIGHT_PASSED" if allowed else "PRE_FLIGHT_FAILED",
|
||||||
|
incident_id=incident_id,
|
||||||
|
auto_repair_id=str(payload.get("auto_repair_id") or "") or None,
|
||||||
|
actor="awooop_recurrence_work_item_service",
|
||||||
|
action_detail=f"recurrence_work_item_dry_run:{payload.get('mode')}"[:200],
|
||||||
|
success=allowed,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
if record is not None:
|
||||||
|
history["alert_operation_id"] = getattr(record, "id", None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"awooop_recurrence_work_item_alert_operation_history_failed",
|
||||||
|
incident_id=incident_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.services.approval_db import get_timeline_service
|
||||||
|
|
||||||
|
event = await get_timeline_service().add_event(
|
||||||
|
event_type="verifier",
|
||||||
|
status="success" if allowed else "warning",
|
||||||
|
title="AwoooP recurrence work item dry-run",
|
||||||
|
description=_recurrence_history_description(context),
|
||||||
|
actor="awooop_recurrence_work_item_service",
|
||||||
|
actor_role=str(payload.get("mode") or "dry_run"),
|
||||||
|
incident_id=incident_id,
|
||||||
|
)
|
||||||
|
if event:
|
||||||
|
history["timeline_event_id"] = event.get("id")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"awooop_recurrence_work_item_timeline_history_failed",
|
||||||
|
incident_id=incident_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
history["recorded"] = bool(
|
||||||
|
history.get("alert_operation_id") or history.get("timeline_event_id")
|
||||||
|
)
|
||||||
|
if not history["recorded"]:
|
||||||
|
history["reason"] = "history_sink_unavailable"
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_history_description(context: dict[str, Any]) -> str:
|
||||||
|
state = context.get("current_state_summary") or {}
|
||||||
|
route = context.get("read_model_route") or {}
|
||||||
|
return (
|
||||||
|
f"mode={context.get('mode')} "
|
||||||
|
f"preview={context.get('verification_result_preview')} "
|
||||||
|
f"occurrences={state.get('occurrence_total')} "
|
||||||
|
f"repair_status={state.get('repair_status')} "
|
||||||
|
f"route={route.get('agent_id')}/{route.get('tool_name')} "
|
||||||
|
f"writes_incident={context.get('writes_incident_state')} "
|
||||||
|
f"writes_auto_repair={context.get('writes_auto_repair_result')} "
|
||||||
|
f"writes_ticket={context.get('writes_ticket')}"
|
||||||
|
)[:500]
|
||||||
|
|
||||||
|
|
||||||
def build_dossier_coverage(
|
def build_dossier_coverage(
|
||||||
rows: list[dict[str, Any]],
|
rows: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
@@ -813,3 +1201,49 @@ async def fetch_channel_event_dossier_recurrence(
|
|||||||
limit=safe_limit,
|
limit=safe_limit,
|
||||||
repair_summaries_by_incident=repair_summaries,
|
repair_summaries_by_incident=repair_summaries,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_recurrence_work_item_preview(
|
||||||
|
*,
|
||||||
|
project_id: str | None,
|
||||||
|
work_item_id: str,
|
||||||
|
mode: RecurrenceWorkItemMode = "auto",
|
||||||
|
provider: str | None = None,
|
||||||
|
limit: int = _MAX_RECURRENCE_EVENTS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch a read-only preview for a recurrence work item."""
|
||||||
|
|
||||||
|
recurrence = await fetch_channel_event_dossier_recurrence(
|
||||||
|
project_id=project_id,
|
||||||
|
provider=provider,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return build_recurrence_work_item_preview(
|
||||||
|
recurrence,
|
||||||
|
work_item_id=work_item_id,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_recurrence_work_item_dry_run(
|
||||||
|
*,
|
||||||
|
project_id: str | None,
|
||||||
|
work_item_id: str,
|
||||||
|
mode: RecurrenceWorkItemMode = "auto",
|
||||||
|
provider: str | None = None,
|
||||||
|
limit: int = _MAX_RECURRENCE_EVENTS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch and record a safe read-only dry-run for a recurrence work item."""
|
||||||
|
|
||||||
|
recurrence = await fetch_channel_event_dossier_recurrence(
|
||||||
|
project_id=project_id,
|
||||||
|
provider=provider,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
payload = build_recurrence_work_item_dry_run(
|
||||||
|
recurrence,
|
||||||
|
work_item_id=work_item_id,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
payload["history"] = await _record_recurrence_work_item_dry_run_history(payload)
|
||||||
|
return payload
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ from fastapi import HTTPException
|
|||||||
from src.api.v1.platform.events import ChannelEventRecurrenceResponse
|
from src.api.v1.platform.events import ChannelEventRecurrenceResponse
|
||||||
from src.services import channel_event_dossier_service
|
from src.services import channel_event_dossier_service
|
||||||
from src.services.channel_event_dossier_service import (
|
from src.services.channel_event_dossier_service import (
|
||||||
|
RecurrenceWorkItemNotFoundError,
|
||||||
build_dossier_coverage,
|
build_dossier_coverage,
|
||||||
build_dossier_event,
|
build_dossier_event,
|
||||||
build_dossier_recurrence,
|
build_dossier_recurrence,
|
||||||
|
build_recurrence_work_item_dry_run,
|
||||||
|
build_recurrence_work_item_preview,
|
||||||
fetch_channel_event_dossier,
|
fetch_channel_event_dossier,
|
||||||
fetch_channel_event_dossier_coverage,
|
fetch_channel_event_dossier_coverage,
|
||||||
fetch_channel_event_dossier_recurrence,
|
fetch_channel_event_dossier_recurrence,
|
||||||
@@ -345,6 +348,133 @@ def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repa
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_recurrence_work_item_preview_selects_ticket_mode() -> None:
|
||||||
|
recurrence = build_dossier_recurrence(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event_id": "event-1",
|
||||||
|
"project_id": "awoooi",
|
||||||
|
"channel_type": "internal",
|
||||||
|
"provider_event_id": "alertmanager:received:1",
|
||||||
|
"content_hash": "a" * 64,
|
||||||
|
"content_preview": "Docker container unhealthy",
|
||||||
|
"content_redacted": "Docker container unhealthy",
|
||||||
|
"redaction_version": "audit_sink_v1",
|
||||||
|
"source_envelope": {
|
||||||
|
"provider": "alertmanager",
|
||||||
|
"source_refs": {
|
||||||
|
"alert_ids": ["alert-1"],
|
||||||
|
"incident_ids": ["INC-20260517-F25B4A"],
|
||||||
|
"fingerprints": ["fp-container-unhealthy"],
|
||||||
|
},
|
||||||
|
"log_correlation": {
|
||||||
|
"alertname": "DockerContainerUnhealthy",
|
||||||
|
"severity": "warning",
|
||||||
|
"namespace": "momo",
|
||||||
|
"target_resource": "bitan-pharmacy-bitan-1",
|
||||||
|
"fingerprint": "fp-container-unhealthy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"is_duplicate": True,
|
||||||
|
"provider_ts": None,
|
||||||
|
"received_at": "2026-05-17T23:47:00",
|
||||||
|
"run_id": UUID("33333333-3333-4333-8333-333333333333"),
|
||||||
|
"run_state": "completed",
|
||||||
|
"run_agent_id": "openclaw",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
project_id="awoooi",
|
||||||
|
limit=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
preview = build_recurrence_work_item_preview(
|
||||||
|
recurrence,
|
||||||
|
work_item_id="incident:INC-20260517-F25B4A",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert preview["schema_version"] == "awooop_recurrence_work_item_preview_v1"
|
||||||
|
assert preview["source"] == "channel_event_dossier.recurrence"
|
||||||
|
assert preview["mode"] == "ticket"
|
||||||
|
assert preview["allowed"] is True
|
||||||
|
assert preview["writes_incident_state"] is False
|
||||||
|
assert preview["writes_auto_repair_result"] is False
|
||||||
|
assert preview["writes_ticket"] is False
|
||||||
|
assert preview["plan"]["step"] == "prepare_repair_ticket_preview"
|
||||||
|
assert preview["plan"]["target"]["alertname"] == "DockerContainerUnhealthy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_recurrence_work_item_dry_run_returns_ticket_preview_without_writes() -> None:
|
||||||
|
recurrence = build_dossier_recurrence(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event_id": "event-1",
|
||||||
|
"project_id": "awoooi",
|
||||||
|
"channel_type": "internal",
|
||||||
|
"provider_event_id": "alertmanager:received:1",
|
||||||
|
"content_hash": "a" * 64,
|
||||||
|
"content_preview": "Docker container unhealthy",
|
||||||
|
"content_redacted": "Docker container unhealthy",
|
||||||
|
"redaction_version": "audit_sink_v1",
|
||||||
|
"source_envelope": {
|
||||||
|
"provider": "alertmanager",
|
||||||
|
"source_refs": {
|
||||||
|
"alert_ids": ["alert-1"],
|
||||||
|
"incident_ids": ["INC-20260517-F25B4A"],
|
||||||
|
"fingerprints": ["fp-container-unhealthy"],
|
||||||
|
},
|
||||||
|
"log_correlation": {
|
||||||
|
"alertname": "DockerContainerUnhealthy",
|
||||||
|
"severity": "warning",
|
||||||
|
"namespace": "momo",
|
||||||
|
"target_resource": "bitan-pharmacy-bitan-1",
|
||||||
|
"fingerprint": "fp-container-unhealthy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"is_duplicate": True,
|
||||||
|
"provider_ts": None,
|
||||||
|
"received_at": "2026-05-17T23:47:00",
|
||||||
|
"run_id": UUID("33333333-3333-4333-8333-333333333333"),
|
||||||
|
"run_state": "completed",
|
||||||
|
"run_agent_id": "openclaw",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
project_id="awoooi",
|
||||||
|
limit=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
dry_run = build_recurrence_work_item_dry_run(
|
||||||
|
recurrence,
|
||||||
|
work_item_id="incident:INC-20260517-F25B4A",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert dry_run["schema_version"] == "awooop_recurrence_work_item_dry_run_v1"
|
||||||
|
assert dry_run["mode"] == "ticket"
|
||||||
|
assert dry_run["allowed"] is True
|
||||||
|
assert dry_run["executed"] is True
|
||||||
|
assert dry_run["writes_incident_state"] is False
|
||||||
|
assert dry_run["writes_auto_repair_result"] is False
|
||||||
|
assert dry_run["writes_ticket"] is False
|
||||||
|
assert dry_run["verification_result_preview"] == "ticket_preview_ready"
|
||||||
|
assert dry_run["ticket_preview"]["would_create"] is False
|
||||||
|
assert "DockerContainerUnhealthy" in dry_run["ticket_preview"]["title"]
|
||||||
|
assert dry_run["current_state_summary"]["repair_status"] == "run_completed_no_repair"
|
||||||
|
assert dry_run["read_model_route"]["required_scope"] == "read"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_recurrence_work_item_preview_raises_for_missing_item() -> None:
|
||||||
|
recurrence = build_dossier_recurrence(
|
||||||
|
[],
|
||||||
|
project_id="awoooi",
|
||||||
|
limit=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RecurrenceWorkItemNotFoundError):
|
||||||
|
build_recurrence_work_item_preview(
|
||||||
|
recurrence,
|
||||||
|
work_item_id="incident:INC-20260517-MISSING",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_recurrence_response_model_preserves_repair_work_item_fields() -> None:
|
def test_recurrence_response_model_preserves_repair_work_item_fields() -> None:
|
||||||
response = ChannelEventRecurrenceResponse.model_validate(
|
response = ChannelEventRecurrenceResponse.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1841,6 +1841,36 @@
|
|||||||
"nextStep": "Next: {step}",
|
"nextStep": "Next: {step}",
|
||||||
"openRun": "Open Run",
|
"openRun": "Open Run",
|
||||||
"openRuns": "Back to Runs",
|
"openRuns": "Back to Runs",
|
||||||
|
"actions": {
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewing": "Previewing",
|
||||||
|
"dryRun": "Dry-run",
|
||||||
|
"dryRunning": "Dry-running",
|
||||||
|
"failed": "The safe preview / dry-run API did not respond, so the next step cannot be claimed.",
|
||||||
|
"allowed": "Safety gate passed",
|
||||||
|
"blocked": "Safety gate blocked",
|
||||||
|
"mode": "Mode: {mode}",
|
||||||
|
"previewResult": "Result: {result}",
|
||||||
|
"writes": "Writes: incident={incident}; autoRepair={autoRepair}; ticket={ticket}",
|
||||||
|
"history": "Dry-run stored: {recorded}",
|
||||||
|
"ticket": "Ticket preview: {title}",
|
||||||
|
"modes": {
|
||||||
|
"auto": "Auto select",
|
||||||
|
"ticket": "Ticket preview",
|
||||||
|
"reverify": "Reverify",
|
||||||
|
"approval_review": "Approval review",
|
||||||
|
"observe": "Observe",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"previews": {
|
||||||
|
"ticket_preview_ready": "Ticket preview ready",
|
||||||
|
"reverify_preview_ready": "Reverify preview ready",
|
||||||
|
"approval_review_required": "Approval review required",
|
||||||
|
"observe_only": "Observe only",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
}
|
||||||
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"auto_repair_verified": "Verified repair",
|
"auto_repair_verified": "Verified repair",
|
||||||
"auto_repair_succeeded_unverified": "Repair needs verification",
|
"auto_repair_succeeded_unverified": "Repair needs verification",
|
||||||
|
|||||||
@@ -1842,6 +1842,36 @@
|
|||||||
"nextStep": "下一步:{step}",
|
"nextStep": "下一步:{step}",
|
||||||
"openRun": "開啟 Run",
|
"openRun": "開啟 Run",
|
||||||
"openRuns": "回 Run 監控",
|
"openRuns": "回 Run 監控",
|
||||||
|
"actions": {
|
||||||
|
"preview": "預覽",
|
||||||
|
"previewing": "預覽中",
|
||||||
|
"dryRun": "乾跑",
|
||||||
|
"dryRunning": "乾跑中",
|
||||||
|
"failed": "安全預覽 / 乾跑 API 未回應,不能判定下一步。",
|
||||||
|
"allowed": "安全閘門通過",
|
||||||
|
"blocked": "安全閘門阻塞",
|
||||||
|
"mode": "模式:{mode}",
|
||||||
|
"previewResult": "結果:{result}",
|
||||||
|
"writes": "寫入:incident={incident};autoRepair={autoRepair};ticket={ticket}",
|
||||||
|
"history": "試跑入庫:{recorded}",
|
||||||
|
"ticket": "Ticket 預覽:{title}",
|
||||||
|
"modes": {
|
||||||
|
"auto": "自動選擇",
|
||||||
|
"ticket": "Ticket 預覽",
|
||||||
|
"reverify": "重新驗證",
|
||||||
|
"approval_review": "審批檢查",
|
||||||
|
"observe": "觀察",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
|
"previews": {
|
||||||
|
"ticket_preview_ready": "Ticket 預覽已就緒",
|
||||||
|
"reverify_preview_ready": "重新驗證預覽已就緒",
|
||||||
|
"approval_review_required": "需進審批檢查",
|
||||||
|
"observe_only": "僅觀察",
|
||||||
|
"blocked": "已阻塞",
|
||||||
|
"unknown": "未知"
|
||||||
|
}
|
||||||
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"auto_repair_verified": "已驗證修復",
|
"auto_repair_verified": "已驗證修復",
|
||||||
"auto_repair_succeeded_unverified": "修復待驗證",
|
"auto_repair_succeeded_unverified": "修復待驗證",
|
||||||
|
|||||||
@@ -104,6 +104,60 @@ type RecurrenceResponse = {
|
|||||||
items: RecurrenceItem[];
|
items: RecurrenceItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecurrenceWorkItemActionResult = {
|
||||||
|
schema_version?: string;
|
||||||
|
work_item_id?: string | null;
|
||||||
|
incident_id?: string | null;
|
||||||
|
mode?: string | null;
|
||||||
|
requested_mode?: string | null;
|
||||||
|
allowed?: boolean | null;
|
||||||
|
executed?: boolean | null;
|
||||||
|
safety_level?: string | null;
|
||||||
|
writes_incident_state?: boolean | null;
|
||||||
|
writes_auto_repair_result?: boolean | null;
|
||||||
|
writes_ticket?: boolean | null;
|
||||||
|
verification_result_preview?: string | null;
|
||||||
|
next_step?: string | null;
|
||||||
|
checks?: Array<{ name?: string | null; passed?: boolean | null; detail?: string | null }>;
|
||||||
|
current_state_summary?: {
|
||||||
|
repair_status?: string | null;
|
||||||
|
occurrence_total?: number | null;
|
||||||
|
duplicate_total?: number | null;
|
||||||
|
linked_run_total?: number | null;
|
||||||
|
} | null;
|
||||||
|
ticket_preview?: {
|
||||||
|
would_create?: boolean | null;
|
||||||
|
title?: string | null;
|
||||||
|
labels?: string[] | null;
|
||||||
|
body_preview?: string | null;
|
||||||
|
} | null;
|
||||||
|
plan?: {
|
||||||
|
step?: string | null;
|
||||||
|
flywheel_node?: string | null;
|
||||||
|
agent_id?: string | null;
|
||||||
|
required_scope?: string | null;
|
||||||
|
target_action?: string | null;
|
||||||
|
} | null;
|
||||||
|
read_model_route?: {
|
||||||
|
agent_id?: string | null;
|
||||||
|
tool_name?: string | null;
|
||||||
|
required_scope?: string | null;
|
||||||
|
flywheel_node?: string | null;
|
||||||
|
} | null;
|
||||||
|
history?: {
|
||||||
|
recorded?: boolean | null;
|
||||||
|
reason?: string | null;
|
||||||
|
alert_operation_id?: string | null;
|
||||||
|
timeline_event_id?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecurrenceWorkItemActionState = {
|
||||||
|
loading?: "preview" | "dryRun" | null;
|
||||||
|
result?: RecurrenceWorkItemActionResult | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type SloResponse = {
|
type SloResponse = {
|
||||||
adr100?: {
|
adr100?: {
|
||||||
verification_coverage?: {
|
verification_coverage?: {
|
||||||
@@ -194,6 +248,30 @@ async function fetchJson<T>(url: string, timeoutMs = 8000): Promise<T | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(
|
||||||
|
url: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
timeoutMs = 8000
|
||||||
|
): Promise<T | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasGateFailure(summary: AutomationQualitySummary | null, gate: string) {
|
function hasGateFailure(summary: AutomationQualitySummary | null, gate: string) {
|
||||||
return Boolean(summary?.gate_failures?.some((row) => row.gate === gate && row.total > 0));
|
return Boolean(summary?.gate_failures?.some((row) => row.gate === gate && row.total > 0));
|
||||||
}
|
}
|
||||||
@@ -227,6 +305,32 @@ function recurrenceRepairStatusKey(status?: string | null) {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recurrenceActionModeKey(mode?: string | null) {
|
||||||
|
if (
|
||||||
|
mode === "auto" ||
|
||||||
|
mode === "ticket" ||
|
||||||
|
mode === "reverify" ||
|
||||||
|
mode === "approval_review" ||
|
||||||
|
mode === "observe"
|
||||||
|
) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recurrencePreviewKey(preview?: string | null) {
|
||||||
|
if (
|
||||||
|
preview === "ticket_preview_ready" ||
|
||||||
|
preview === "reverify_preview_ready" ||
|
||||||
|
preview === "approval_review_required" ||
|
||||||
|
preview === "observe_only" ||
|
||||||
|
preview === "blocked"
|
||||||
|
) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorkItems(
|
function buildWorkItems(
|
||||||
telemetry: Telemetry,
|
telemetry: Telemetry,
|
||||||
t: ReturnType<typeof useTranslations>
|
t: ReturnType<typeof useTranslations>
|
||||||
@@ -538,6 +642,7 @@ function RecurrenceWorkQueuePanel({
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("awooop.workItems.recurrence");
|
const t = useTranslations("awooop.workItems.recurrence");
|
||||||
|
const [actionState, setActionState] = useState<Record<string, RecurrenceWorkItemActionState>>({});
|
||||||
const openItems = recurrenceOpenItems(recurrence);
|
const openItems = recurrenceOpenItems(recurrence);
|
||||||
const focusedItem = focusedWorkItemId
|
const focusedItem = focusedWorkItemId
|
||||||
? openItems.find((item) => item.work_item?.work_item_id === focusedWorkItemId)
|
? openItems.find((item) => item.work_item?.work_item_id === focusedWorkItemId)
|
||||||
@@ -546,6 +651,42 @@ function RecurrenceWorkQueuePanel({
|
|||||||
? [focusedItem, ...openItems.filter((item) => item !== focusedItem).slice(0, 5)]
|
? [focusedItem, ...openItems.filter((item) => item !== focusedItem).slice(0, 5)]
|
||||||
: openItems.slice(0, 6);
|
: openItems.slice(0, 6);
|
||||||
const summary = recurrence?.summary;
|
const summary = recurrence?.summary;
|
||||||
|
const runWorkItemAction = useCallback(async (
|
||||||
|
workItemId: string,
|
||||||
|
action: "preview" | "dryRun"
|
||||||
|
) => {
|
||||||
|
setActionState((current) => ({
|
||||||
|
...current,
|
||||||
|
[workItemId]: { ...current[workItemId], loading: action, error: null },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const encodedProjectId = encodeURIComponent(projectId);
|
||||||
|
const encodedWorkItemId = encodeURIComponent(workItemId);
|
||||||
|
const result = action === "preview"
|
||||||
|
? await fetchJson<RecurrenceWorkItemActionResult>(
|
||||||
|
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`,
|
||||||
|
12000
|
||||||
|
)
|
||||||
|
: await postJson<RecurrenceWorkItemActionResult>(
|
||||||
|
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`,
|
||||||
|
{
|
||||||
|
project_id: projectId,
|
||||||
|
work_item_id: workItemId,
|
||||||
|
mode: "auto",
|
||||||
|
limit: 300,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
);
|
||||||
|
|
||||||
|
setActionState((current) => ({
|
||||||
|
...current,
|
||||||
|
[workItemId]: {
|
||||||
|
loading: null,
|
||||||
|
result,
|
||||||
|
error: result ? null : t("actions.failed"),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [projectId, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border border-[#e0ddd4] bg-white">
|
<section className="border border-[#e0ddd4] bg-white">
|
||||||
@@ -582,17 +723,23 @@ function RecurrenceWorkQueuePanel({
|
|||||||
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-3">
|
||||||
{visibleItems.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
const workItem = item.work_item;
|
const workItem = item.work_item;
|
||||||
|
const workItemId = workItem?.work_item_id ?? "";
|
||||||
const isFocused = Boolean(
|
const isFocused = Boolean(
|
||||||
focusedWorkItemId && workItem?.work_item_id === focusedWorkItemId
|
focusedWorkItemId && workItemId === focusedWorkItemId
|
||||||
);
|
);
|
||||||
const repairStatusKey = recurrenceRepairStatusKey(item.repair_summary?.status);
|
const repairStatusKey = recurrenceRepairStatusKey(item.repair_summary?.status);
|
||||||
const runHref = item.latest_run_id
|
const runHref = item.latest_run_id
|
||||||
? `/awooop/runs/${item.latest_run_id}?project_id=${encodeURIComponent(projectId)}`
|
? `/awooop/runs/${item.latest_run_id}?project_id=${encodeURIComponent(projectId)}`
|
||||||
: null;
|
: null;
|
||||||
|
const currentAction = workItemId ? actionState[workItemId] : null;
|
||||||
|
const actionResult = currentAction?.result;
|
||||||
|
const actionAllowed = actionResult?.allowed === true;
|
||||||
|
const actionModeKey = recurrenceActionModeKey(actionResult?.mode);
|
||||||
|
const previewKey = recurrencePreviewKey(actionResult?.verification_result_preview);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={workItem?.work_item_id ?? item.recurrence_key}
|
key={workItemId || item.recurrence_key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white px-4 py-3",
|
"bg-white px-4 py-3",
|
||||||
isFocused && "outline outline-2 outline-[#d97757]"
|
isFocused && "outline outline-2 outline-[#d97757]"
|
||||||
@@ -631,6 +778,32 @@ function RecurrenceWorkQueuePanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{workItemId ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runWorkItemAction(workItemId, "preview")}
|
||||||
|
disabled={currentAction?.loading === "preview"}
|
||||||
|
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{currentAction?.loading === "preview"
|
||||||
|
? t("actions.previewing")
|
||||||
|
: t("actions.preview")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runWorkItemAction(workItemId, "dryRun")}
|
||||||
|
disabled={currentAction?.loading === "dryRun"}
|
||||||
|
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d97757] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Gauge className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{currentAction?.loading === "dryRun"
|
||||||
|
? t("actions.dryRunning")
|
||||||
|
: t("actions.dryRun")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{runHref ? (
|
{runHref ? (
|
||||||
<Link
|
<Link
|
||||||
href={runHref as never}
|
href={runHref as never}
|
||||||
@@ -648,6 +821,58 @@ function RecurrenceWorkQueuePanel({
|
|||||||
{t("openRuns")}
|
{t("openRuns")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{currentAction?.error ? (
|
||||||
|
<div className="mt-3 border border-[#e2a29b] bg-[#fff0ef] px-3 py-2 text-xs leading-5 text-[#9f2f25]">
|
||||||
|
{currentAction.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{actionResult ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-3 border px-3 py-2 text-xs leading-5",
|
||||||
|
actionAllowed
|
||||||
|
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
|
||||||
|
: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{actionAllowed ? t("actions.allowed") : t("actions.blocked")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{t("actions.mode", {
|
||||||
|
mode: t(`actions.modes.${actionModeKey}` as never),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid gap-1 text-[#5f5b52]">
|
||||||
|
<p>
|
||||||
|
{t("actions.previewResult", {
|
||||||
|
result: t(`actions.previews.${previewKey}` as never),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t("actions.writes", {
|
||||||
|
incident: String(actionResult.writes_incident_state ?? false),
|
||||||
|
autoRepair: String(actionResult.writes_auto_repair_result ?? false),
|
||||||
|
ticket: String(actionResult.writes_ticket ?? false),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t("actions.history", {
|
||||||
|
recorded: String(actionResult.history?.recorded ?? false),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{actionResult.ticket_preview?.title ? (
|
||||||
|
<p className="truncate">
|
||||||
|
{t("actions.ticket", {
|
||||||
|
title: actionResult.ticket_preview.title,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user