feat(awooop): surface cicd rollout evidence
All checks were successful
CD Pipeline / tests (push) Successful in 4m1s
Code Review / ai-code-review (push) Successful in 17s
CD Pipeline / build-and-deploy (push) Successful in 3m27s
CD Pipeline / post-deploy-checks (push) Successful in 1m49s

This commit is contained in:
Your Name
2026-05-21 20:06:26 +08:00
parent 0c59a1aafd
commit 4bdb012caa
7 changed files with 468 additions and 2 deletions

View File

@@ -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 filterrunning/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,

View File

@@ -2441,6 +2441,7 @@ async def alertmanager_webhook(
# (2026-04-08 Claude Sonnet 4.6 Asia/TaipeiADR-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,
},
)

View File

@@ -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 tableCI/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]:

View File

@@ -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 == "TELEGRAMCI/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",

View File

@@ -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",

View File

@@ -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": "說明",

View File

@@ -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<string, string> = {
up: '#22C55E',
healthy: '#22C55E',
@@ -38,9 +62,37 @@ const STATUS_COLOR: Record<string, string> = {
unreachable: '#87867f',
}
const CICD_STATUS_STYLE: Record<string, { border: string; background: string; color: string }> = {
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<string, string> = {
failed: 'cicd.status.failed',
pending: 'cicd.status.pending',
running: 'cicd.status.running',
success: 'cicd.status.success',
}
const CICD_STAGE_LABEL_KEYS: Record<string, string> = {
'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<Host[]>([])
const [cicdEvents, setCicdEvents] = useState<CicdEvent[]>([])
const [cicdLoading, setCicdLoading] = useState(true)
const [cicdError, setCicdError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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() {
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<section style={{ background: '#fff', border: '0.5px solid #e0ddd4', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<RefreshCw size={16} color="#d97757" aria-hidden="true" />
<div>
<h2 style={{ fontSize: 14, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('cicd.title')}</h2>
<p style={{ fontSize: 11, color: '#87867f', margin: '2px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('cicd.subtitle')}</p>
</div>
</div>
<span style={{ border: '0.5px solid #d8d3c7', background: '#fff', color: '#5f5b52', padding: '3px 8px', fontSize: 11, fontWeight: 600, fontFamily: 'var(--font-body), monospace' }}>
{t('cicd.visibleCount', { count: cicdEvents.length })}
</span>
</div>
{cicdLoading ? (
<div style={{ padding: '22px 14px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 12 }}>{t('cicd.loading')}</div>
) : cicdError ? (
<div style={{ padding: '22px 14px', color: '#9f2f25', fontFamily: 'var(--font-body), monospace', fontSize: 12 }}>{t('cicd.error')}</div>
) : cicdEvents.length === 0 ? (
<div style={{ padding: '22px 14px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 12 }}>{t('cicd.empty')}</div>
) : (
<div style={{ display: 'grid', gap: 0 }}>
{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 (
<article key={event.id} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, padding: '11px 14px', borderTop: '0.5px solid #f0ede4', alignItems: 'start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, border: `0.5px solid ${statusStyle.border}`, background: statusStyle.background, color: statusStyle.color, flex: '0 0 auto' }}>
<StatusIcon size={14} aria-hidden="true" />
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>
{statusKey ? t(statusKey as never) : status}
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
{stageKey ? t(stageKey as never) : stage}
</div>
</div>
</div>
<div style={{ minWidth: 0, fontFamily: 'var(--font-body), monospace' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#5f5b52' }}>
<GitCommit size={13} aria-hidden="true" />
<span>{shortCommit(event.commit_sha) ?? t('cicd.emptyValue')}</span>
<span style={{ color: '#b8b2a6' }}>/</span>
<span>{event.triggered_by ?? t('cicd.emptyValue')}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 5, fontSize: 11, color: '#87867f' }}>
<Clock3 size={13} aria-hidden="true" />
<span>{event.created_at ? new Date(event.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }) : t('cicd.emptyValue')}</span>
<span style={{ color: '#b8b2a6' }}>/</span>
<span>{event.duration_seconds > 0 ? t('cicd.durationSeconds', { seconds: event.duration_seconds }) : t('cicd.durationNotRecorded')}</span>
</div>
</div>
<div style={{ minWidth: 0, fontFamily: 'var(--font-body), monospace' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#141413', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{event.summary || event.action_detail || event.alertname}
</div>
<div style={{ marginTop: 4, fontSize: 11, lineHeight: 1.55, color: '#5f5b52', overflowWrap: 'anywhere' }}>
{event.description || event.alertname}
</div>
{event.workflow_url && (
<a href={event.workflow_url} target="_blank" rel="noreferrer" style={{ display: 'inline-flex', marginTop: 6, fontSize: 11, fontWeight: 600, color: '#1f5b9b', textDecoration: 'none' }}>
{t('cicd.openWorkflow')}
</a>
)}
</div>
</article>
)
})}
</div>
)}
</section>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
) : error ? (