diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index e305f2ab..60972270 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -34,6 +34,9 @@ from src.services.platform_operator_service import ( from src.services.platform_operator_service import ( get_run_detail as get_run_detail_svc, ) +from src.services.platform_operator_service import ( + list_cicd_events as list_cicd_events_svc, +) from src.services.platform_operator_service import ( list_approvals as list_approvals_svc, ) @@ -102,6 +105,32 @@ class ListCallbackRepliesResponse(BaseModel): per_page: int +class CicdEventItem(BaseModel): + id: str + project_id: str + alertname: str + stage: str | None = None + status: str | None = None + severity: str | None = None + commit_sha: str | None = None + triggered_by: str | None = None + duration_seconds: int = 0 + summary: str | None = None + description: str | None = None + workflow_url: str | None = None + alert_id: str | None = None + source: str | None = None + action_detail: str | None = None + needs_attention: bool = False + created_at: datetime + + +class ListCicdEventsResponse(BaseModel): + items: list[CicdEventItem] + total: int + limit: int + + class AiRouteStatusResponse(BaseModel): schema_version: str workload_type: str @@ -216,6 +245,29 @@ async def list_callback_replies( ) +@router.get( + "/cicd/events", + response_model=ListCicdEventsResponse, + summary="列出 CI/CD evidence events", + description=( + "從 alert_operation_log 讀取 CI/CD notification evidence,供 AwoooP " + "Deployments / Run Console 顯示 rollout-risk、success、failed 等階段狀態。" + ), +) +async def list_cicd_events( + project_id: str | None = Query(None, description="租戶 ID(目前支援 awoooi)"), + stage: str | None = Query(None, description="CI/CD stage filter(可選)"), + status: str | None = Query(None, description="CI/CD status filter(running/success/failed/pending)"), + limit: int = Query(12, ge=1, le=50, description="最多返回筆數"), +) -> dict[str, Any]: + return await list_cicd_events_svc( + project_id=project_id, + stage=stage, + status_filter=status, + limit=limit, + ) + + @router.get( "/ai-route-status", response_model=AiRouteStatusResponse, diff --git a/apps/api/src/api/v1/webhooks.py b/apps/api/src/api/v1/webhooks.py index 17899ec2..1fe6bf81 100644 --- a/apps/api/src/api/v1/webhooks.py +++ b/apps/api/src/api/v1/webhooks.py @@ -2441,6 +2441,7 @@ async def alertmanager_webhook( # (2026-04-08 Claude Sonnet 4.6 Asia/Taipei,ADR-062 Q9) # ========================================================================== _alert_labels = alert.labels or {} + _alert_annotations = alert.annotations or {} _alertname_for_log = _alert_labels.get("alertname", "UnknownAlert") # Q9: auto_repair flag — Rule=false 強制 HITL(不觸發自動修復背景任務) _can_auto_repair_by_rule = _alert_labels.get("auto_repair", "true").lower() == "true" @@ -2456,6 +2457,7 @@ async def alertmanager_webhook( "alert_id": alert_id, "alertname": _alertname_for_log, "labels": _alert_labels, + "annotations": _alert_annotations, "auto_repair_flag": _can_auto_repair_by_rule, }, ) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 28edb2a4..cdf4490e 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -85,6 +85,8 @@ _CALLBACK_REPLY_RAW_STATUS_BY_FILTER = { "failed": "callback_reply_failed", } _CALLBACK_REPLY_ACTION_RE = re.compile(r"^[a-z0-9_:-]{1,64}$", re.IGNORECASE) +_CICD_STATUS_FILTERS = {"running", "success", "failed", "pending"} +_CICD_STAGE_RE = re.compile(r"^[a-z0-9_:-]{1,64}$", re.IGNORECASE) _AI_ROUTE_STATUS_SCHEMA_VERSION = "awooop_ai_route_status_v1" _AI_ROUTE_WORKLOADS = set(get_args(OllamaWorkloadType)) _SOURCE_CORRELATION_SCHEMA_VERSION = "source_provider_correlation_v1" @@ -414,6 +416,84 @@ async def list_callback_replies( } +async def list_cicd_events( + *, + project_id: str | None, + stage: str | None, + status_filter: str | None, + limit: int, +) -> dict[str, Any]: + """列出 CI/CD notification evidence,來源是 alert_operation_log。""" + safe_limit = max(1, min(limit, 50)) + normalized_stage = _validate_cicd_stage_filter(stage) + normalized_status = _validate_cicd_status_filter(status_filter) + + # alert_operation_log 目前是 legacy/global evidence table,CI/CD notification + # 只屬於 AWOOOI production;非 awoooi project filter 回空集合,避免誤導多租戶 UI。 + if project_id and project_id != "awoooi": + return {"items": [], "total": 0, "limit": safe_limit} + + where_clauses = [ + "event_type = 'ALERT_RECEIVED'", + "actor = 'alertmanager'", + """ + COALESCE( + context #>> '{labels,alertname}', + context ->> 'alertname', + '' + ) LIKE 'CI_%' + """, + ] + params: dict[str, Any] = {"limit": safe_limit} + if normalized_stage: + where_clauses.append( + "LOWER(COALESCE(context #>> '{labels,stage}', '')) = :stage" + ) + params["stage"] = normalized_stage + if normalized_status: + where_clauses.append( + "LOWER(COALESCE(context #>> '{labels,status}', '')) = :status" + ) + params["status"] = normalized_status + + where_sql = " AND ".join(where_clauses) + sql = text(f""" + SELECT + id, + action_detail, + success, + created_at, + context, + COALESCE( + context #>> '{{labels,alertname}}', + context ->> 'alertname', + '' + ) AS alertname, + context #>> '{{labels,stage}}' AS stage, + context #>> '{{labels,status}}' AS status, + context #>> '{{labels,severity}}' AS severity, + context #>> '{{labels,commit}}' AS commit_sha, + context #>> '{{labels,triggered_by}}' AS triggered_by, + context #>> '{{labels,duration_seconds}}' AS duration_seconds, + context #>> '{{annotations,summary}}' AS summary, + context #>> '{{annotations,description}}' AS description, + context #>> '{{annotations,workflow_url}}' AS workflow_url, + context ->> 'alert_id' AS alert_id, + context ->> 'source' AS source + FROM alert_operation_log + WHERE {where_sql} + ORDER BY created_at DESC, id DESC + LIMIT :limit + """) + + async with get_db_context("awoooi") as db: + result = await db.execute(sql, params) + rows = list(result.mappings().all()) + + items = [_cicd_event_item_from_row(row, project_id=project_id or "awoooi") for row in rows] + return {"items": items, "total": len(items), "limit": safe_limit} + + async def get_ai_route_status( workload_type: str | None = None, ) -> dict[str, Any]: @@ -766,6 +846,89 @@ def _outbound_timeline_metadata( return metadata +def _validate_cicd_stage_filter(value: str | None) -> str | None: + """Normalize a CI/CD stage filter without allowing arbitrary SQL fragments.""" + if value is None: + return None + stage = value.strip().lower() + if not stage: + return None + if not _CICD_STAGE_RE.fullmatch(stage): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="stage 格式錯誤,僅允許 a-z、0-9、底線、冒號與短橫線", + ) + return stage + + +def _validate_cicd_status_filter(value: str | None) -> str | None: + """Normalize and validate CI/CD status filter.""" + if value is None: + return None + status_value = value.strip().lower() + if not status_value: + return None + if status_value not in _CICD_STATUS_FILTERS: + allowed = ", ".join(sorted(_CICD_STATUS_FILTERS)) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"status 必須是: {allowed}", + ) + return status_value + + +def _cicd_duration_seconds(value: Any) -> int: + """Coerce Alertmanager duration_seconds label into a non-negative integer.""" + try: + duration = int(str(value or "0")) + except (TypeError, ValueError): + return 0 + return max(duration, 0) + + +def _cicd_event_needs_attention(status_value: str | None, severity: str | None) -> bool: + """Return whether a CI/CD evidence row should be highlighted for operators.""" + normalized_status = str(status_value or "").lower() + normalized_severity = str(severity or "").lower() + return normalized_status in {"failed", "pending"} or normalized_severity in { + "critical", + "warning", + } + + +def _cicd_event_item_from_row(row: Mapping[str, Any], *, project_id: str) -> dict[str, Any]: + """Convert one alert_operation_log CI/CD row into an operator-facing item.""" + context = _as_dict(row.get("context")) + labels = _as_dict(context.get("labels")) + annotations = _as_dict(context.get("annotations")) + status_value = str(row.get("status") or labels.get("status") or "").lower() or None + severity = str(row.get("severity") or labels.get("severity") or "").lower() or None + summary = row.get("summary") or annotations.get("summary") + description = row.get("description") or annotations.get("description") + workflow_url = row.get("workflow_url") or annotations.get("workflow_url") + return { + "id": str(row.get("id") or ""), + "project_id": project_id, + "alertname": str(row.get("alertname") or labels.get("alertname") or ""), + "stage": row.get("stage") or labels.get("stage"), + "status": status_value, + "severity": severity, + "commit_sha": row.get("commit_sha") or labels.get("commit"), + "triggered_by": row.get("triggered_by") or labels.get("triggered_by"), + "duration_seconds": _cicd_duration_seconds( + row.get("duration_seconds") or labels.get("duration_seconds") + ), + "summary": str(summary).strip() if summary else None, + "description": str(description).strip() if description else None, + "workflow_url": str(workflow_url).strip() if workflow_url else None, + "alert_id": row.get("alert_id") or context.get("alert_id"), + "source": row.get("source") or context.get("source"), + "action_detail": row.get("action_detail"), + "needs_attention": _cicd_event_needs_attention(status_value, severity), + "created_at": row.get("created_at"), + } + + def _run_callback_reply_summary( outbound_messages: list[AwoooPOutboundMessage], ) -> dict[str, Any]: diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index c3b96cfb..477022c4 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -8,6 +8,7 @@ from fastapi import HTTPException from src.api.v1.platform.operator_runs import ( AiRouteStatusResponse, + ListCicdEventsResponse, ListApprovalsResponse, ListCallbackRepliesResponse, ListRunsResponse, @@ -20,6 +21,8 @@ from src.services.platform_operator_service import ( _build_awooop_status_chain, _callback_reply_event_item, _callback_reply_summary_matches_status, + _cicd_event_item_from_row, + _cicd_duration_seconds, _collect_run_incident_ids, _is_source_correlation_applied_link, _legacy_mcp_timeline_status, @@ -39,6 +42,8 @@ from src.services.platform_operator_service import ( _validate_ai_route_workload, _validate_callback_reply_action_filter, _validate_callback_reply_status_filter, + _validate_cicd_stage_filter, + _validate_cicd_status_filter, ) @@ -72,6 +77,56 @@ def test_outbound_timeline_title_labels_cicd_status() -> None: assert title == "TELEGRAM:CI/CD 狀態通知" +def test_cicd_event_item_preserves_rollout_risk_summary() -> None: + item = _cicd_event_item_from_row( + { + "id": "1da1af11-fd3e-4073-ac85-fd304dbd2dc3", + "action_detail": "收到告警: CI_rollout_risk_pending", + "created_at": datetime(2026, 5, 21, 11, 46, 33), + "context": { + "source": "alertmanager", + "alert_id": "alert-20260521194633", + "labels": { + "alertname": "CI_rollout_risk_pending", + "stage": "rollout-risk", + "status": "pending", + "severity": "warning", + "commit": "8e68dc1e3595a2667831143f76794512bcb302be", + "triggered_by": "wooo", + "duration_seconds": "0", + }, + "annotations": { + "summary": "AWOOOI 部署風險已恢復", + "description": "public_health_argocd_wait_http=curl_error_28", + "workflow_url": "http://192.168.0.110:3001/wooo/awoooi/actions/runs/2827", + }, + }, + }, + project_id="awoooi", + ) + + assert item["stage"] == "rollout-risk" + assert item["status"] == "pending" + assert item["needs_attention"] is True + assert item["summary"] == "AWOOOI 部署風險已恢復" + assert "curl_error_28" in item["description"] + assert item["commit_sha"].startswith("8e68dc1e") + assert ListCicdEventsResponse(items=[item], total=1, limit=1).items[0].stage == ( + "rollout-risk" + ) + + +def test_cicd_event_filter_validation_and_duration_safety() -> None: + assert _validate_cicd_stage_filter("Rollout-Risk") == "rollout-risk" + assert _validate_cicd_status_filter("PENDING") == "pending" + assert _cicd_duration_seconds("-3") == 0 + assert _cicd_duration_seconds("bad") == 0 + with pytest.raises(HTTPException): + _validate_cicd_stage_filter("rollout risk;drop") + with pytest.raises(HTTPException): + _validate_cicd_status_filter("ignored") + + def test_outbound_timeline_title_labels_auto_repair_handoff() -> None: title = _outbound_timeline_title( "telegram", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 3ab25685..624c5147 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1052,7 +1052,31 @@ "noDeployments": "No deployment data", "name": "Service Name", "version": "Version", - "time": "Time" + "time": "Time", + "cicd": { + "title": "CI/CD Deployment Evidence", + "subtitle": "Deployment, test, and rollout-risk status from AwoooP audit data", + "visibleCount": "{count} items", + "loading": "Loading CI/CD evidence...", + "error": "Failed to load CI/CD evidence", + "empty": "No CI/CD evidence yet", + "emptyValue": "--", + "durationSeconds": "{seconds}s", + "durationNotRecorded": "Duration not recorded", + "openWorkflow": "Open workflow", + "status": { + "failed": "Failed", + "pending": "Needs attention", + "running": "Running", + "success": "Success" + }, + "stage": { + "codeReview": "Code review", + "postDeploy": "Post deploy", + "rolloutRisk": "Rollout risk recovered", + "tests": "Tests" + } + } }, "help": { "title": "Help", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 664355fe..9202f8c4 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1053,7 +1053,31 @@ "noDeployments": "無部署資料", "name": "服務名稱", "version": "版本", - "time": "時間" + "time": "時間", + "cicd": { + "title": "CI/CD 部署證據", + "subtitle": "從 AwoooP 稽核資料讀取部署、測試與 rollout-risk 狀態", + "visibleCount": "{count} 筆", + "loading": "載入 CI/CD 證據中...", + "error": "無法載入 CI/CD 證據", + "empty": "尚無 CI/CD 證據", + "emptyValue": "--", + "durationSeconds": "{seconds} 秒", + "durationNotRecorded": "未記錄耗時", + "openWorkflow": "查看 workflow", + "status": { + "failed": "失敗", + "pending": "需注意", + "running": "執行中", + "success": "成功" + }, + "stage": { + "codeReview": "程式碼審查", + "postDeploy": "部署後驗證", + "rolloutRisk": "部署風險已恢復", + "tests": "測試" + } + } }, "help": { "title": "說明", diff --git a/apps/web/src/components/panels/DeploymentsPanel.tsx b/apps/web/src/components/panels/DeploymentsPanel.tsx index 594eca3f..9aa22f59 100644 --- a/apps/web/src/components/panels/DeploymentsPanel.tsx +++ b/apps/web/src/components/panels/DeploymentsPanel.tsx @@ -11,6 +11,7 @@ import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' +import { AlertTriangle, CheckCircle2, Clock3, GitCommit, RefreshCw } from 'lucide-react' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -30,6 +31,29 @@ interface Host { last_check: string } +interface CicdEvent { + id: string + alertname: string + stage: string | null + status: string | null + severity: string | null + commit_sha: string | null + triggered_by: string | null + duration_seconds: number + summary: string | null + description: string | null + workflow_url: string | null + action_detail: string | null + needs_attention: boolean + created_at: string +} + +interface CicdEventsResponse { + items?: CicdEvent[] + total: number + limit: number +} + const STATUS_COLOR: Record = { up: '#22C55E', healthy: '#22C55E', @@ -38,9 +62,37 @@ const STATUS_COLOR: Record = { unreachable: '#87867f', } +const CICD_STATUS_STYLE: Record = { + success: { border: '#9bc7a4', background: '#f0faf2', color: '#17602a' }, + running: { border: '#9bb6d9', background: '#eef5ff', color: '#1f5b9b' }, + pending: { border: '#d9b36f', background: '#fff7e8', color: '#8a5a08' }, + failed: { border: '#e2a29b', background: '#fff0ef', color: '#9f2f25' }, +} + +const CICD_STATUS_LABEL_KEYS: Record = { + failed: 'cicd.status.failed', + pending: 'cicd.status.pending', + running: 'cicd.status.running', + success: 'cicd.status.success', +} + +const CICD_STAGE_LABEL_KEYS: Record = { + 'code-review': 'cicd.stage.codeReview', + 'post-deploy': 'cicd.stage.postDeploy', + 'rollout-risk': 'cicd.stage.rolloutRisk', + tests: 'cicd.stage.tests', +} + +function shortCommit(value: string | null | undefined) { + return value ? value.slice(0, 8) : null +} + export function DeploymentsPanel() { const t = useTranslations('deployments') const [hosts, setHosts] = useState([]) + const [cicdEvents, setCicdEvents] = useState([]) + const [cicdLoading, setCicdLoading] = useState(true) + const [cicdError, setCicdError] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -51,6 +103,24 @@ export function DeploymentsPanel() { .catch(err => { setError(String(err)); setLoading(false) }) }, []) + useEffect(() => { + fetch(`${API_BASE}/api/v1/platform/cicd/events?project_id=awoooi&limit=12`) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }) + .then((data: CicdEventsResponse) => { + setCicdEvents(Array.isArray(data.items) ? data.items : []) + setCicdError(null) + setCicdLoading(false) + }) + .catch(err => { + setCicdError(String(err)) + setCicdEvents([]) + setCicdLoading(false) + }) + }, []) + const k3sHosts = hosts.filter(h => h.role === 'k3s' || h.ip.includes('120')) const displayHosts = k3sHosts.length > 0 ? k3sHosts : hosts @@ -60,6 +130,82 @@ export function DeploymentsPanel() {

{t('title')}

{t('subtitle')}

+
+
+
+
+ + {t('cicd.visibleCount', { count: cicdEvents.length })} + +
+ {cicdLoading ? ( +
{t('cicd.loading')}
+ ) : cicdError ? ( +
{t('cicd.error')}
+ ) : cicdEvents.length === 0 ? ( +
{t('cicd.empty')}
+ ) : ( +
+ {cicdEvents.map(event => { + const status = event.status ?? 'unknown' + const stage = event.stage ?? 'unknown' + const statusStyle = CICD_STATUS_STYLE[status] ?? { border: '#d8d3c7', background: '#fff', color: '#5f5b52' } + const statusKey = CICD_STATUS_LABEL_KEYS[status] + const stageKey = CICD_STAGE_LABEL_KEYS[stage] + const StatusIcon = event.needs_attention ? AlertTriangle : CheckCircle2 + return ( +
+
+ + +
+
+ {statusKey ? t(statusKey as never) : status} +
+
+ {stageKey ? t(stageKey as never) : stage} +
+
+
+
+
+
+
+
+
+
+
+ {event.summary || event.action_detail || event.alertname} +
+
+ {event.description || event.alertname} +
+ {event.workflow_url && ( + + {t('cicd.openWorkflow')} + + )} +
+
+ ) + })} +
+ )} +
{loading ? (
{t('loading')}
) : error ? (