diff --git a/apps/api/src/api/v1/alert_operation_logs.py b/apps/api/src/api/v1/alert_operation_logs.py new file mode 100644 index 00000000..b5d5013c --- /dev/null +++ b/apps/api/src/api/v1/alert_operation_logs.py @@ -0,0 +1,100 @@ +""" +Alert Operation Log API Endpoints +================================== +告警操作日誌 API — 提供 alert_operation_log 的查詢介面 + +Endpoints: +- GET /api/v1/alert-operation-logs - 分頁列表(最新優先) +- GET /api/v1/alert-operation-logs/stats - 統計(24h 事件分佈) + +2026-04-09 Claude Sonnet 4.6 Asia/Taipei (Sprint 5.2) +""" + +from typing import Any + +from fastapi import APIRouter, Query +from pydantic import BaseModel + +from src.core.logging import get_logger +from src.repositories.alert_operation_log_repository import get_alert_operation_log_repository + +router = APIRouter(prefix="/alert-operation-logs", tags=["Alert Operation Logs"]) +logger = get_logger("awoooi.alert_op_log") + + +# ============================================================================= +# Response Models +# ============================================================================= + +class AlertOperationLogResponse(BaseModel): + id: str + incident_id: str | None + approval_id: str | None + audit_log_id: str | None + auto_repair_id: str | None + event_type: str + actor: str | None + action_detail: str | None + success: bool | None + error_message: str | None + context: dict[str, Any] + created_at: str + + model_config = {"from_attributes": True} + + +class AlertOperationLogListResponse(BaseModel): + items: list[AlertOperationLogResponse] + total: int + limit: int + offset: int + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@router.get("/stats", summary="取得告警操作事件統計") +async def get_stats( + since_hours: int = Query(default=24, ge=1, le=168), +) -> dict[str, Any]: + repo = get_alert_operation_log_repository() + return await repo.get_stats(since_hours=since_hours) + + +@router.get("", response_model=AlertOperationLogListResponse, summary="取得告警操作日誌列表") +async def list_logs( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + event_type: str | None = Query(default=None), + incident_id: str | None = Query(default=None), +) -> AlertOperationLogListResponse: + repo = get_alert_operation_log_repository() + items, total = await repo.list_recent( + limit=limit, + offset=offset, + event_type=event_type, + incident_id=incident_id, + ) + return AlertOperationLogListResponse( + items=[ + AlertOperationLogResponse( + id=str(item.id), + incident_id=item.incident_id, + approval_id=item.approval_id, + audit_log_id=item.audit_log_id, + auto_repair_id=item.auto_repair_id, + event_type=str(item.event_type), + actor=item.actor, + action_detail=item.action_detail, + success=item.success, + error_message=item.error_message, + context=item.context or {}, + created_at=item.created_at.isoformat(), + ) + for item in items + ], + total=total, + limit=limit, + offset=offset, + ) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 8d27ad01..a9d54d8a 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -34,6 +34,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration from src.api.v1 import agents as agents_v1 # Phase 9.5: Agent Teams API from src.api.v1 import ai as ai_v1 from src.api.v1 import approvals as approvals_v1 +from src.api.v1 import alert_operation_logs as alert_operation_logs_v1 from src.api.v1 import audit_logs as audit_logs_v1 from src.api.v1 import auto_repair as auto_repair_v1 # #8: 自動升級決策 from src.api.v1 import csrf as csrf_v1 # Phase 20: CSRF Protection @@ -442,6 +443,8 @@ app.include_router(ai_v1.router, prefix="/api/v1", tags=["AI Decision"]) app.include_router(webhooks_v1.router, prefix="/api/v1", tags=["Webhooks"]) app.include_router(timeline_v1.router, prefix="/api/v1", tags=["Timeline"]) app.include_router(audit_logs_v1.router, prefix="/api/v1", tags=["Audit Logs"]) +# 2026-04-09 Claude Sonnet 4.6: alert_operation_log 查詢 API (Sprint 5.2) +app.include_router(alert_operation_logs_v1.router, prefix="/api/v1", tags=["Alert Operation Logs"]) app.include_router( telegram_v1.router, prefix="/api/v1", tags=["Telegram Gateway"] ) # Phase 5.4 diff --git a/apps/api/src/repositories/alert_operation_log_repository.py b/apps/api/src/repositories/alert_operation_log_repository.py index b6f0aaec..956bf7c2 100644 --- a/apps/api/src/repositories/alert_operation_log_repository.py +++ b/apps/api/src/repositories/alert_operation_log_repository.py @@ -156,6 +156,37 @@ class AlertOperationLogRepository: ) return list(result.scalars().all()) + async def list_recent( + self, + limit: int = 50, + offset: int = 0, + event_type: str | None = None, + incident_id: str | None = None, + ) -> tuple[list[AlertOperationLog], int]: + """通用分頁列表(最新優先)""" + from sqlalchemy import func as sa_func + + async with get_db_context() as db: + query = select(AlertOperationLog) + count_query = select(sa_func.count()).select_from(AlertOperationLog) + + if event_type: + query = query.where(AlertOperationLog.event_type == event_type) + count_query = count_query.where(AlertOperationLog.event_type == event_type) + if incident_id: + query = query.where(AlertOperationLog.incident_id == incident_id) + count_query = count_query.where(AlertOperationLog.incident_id == incident_id) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(AlertOperationLog.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + async def get_stats(self, since_hours: int = 24) -> dict[str, Any]: """統計最近 N 小時的事件分佈""" from datetime import timedelta diff --git a/apps/web/src/app/[locale]/alert-operation-logs/page.tsx b/apps/web/src/app/[locale]/alert-operation-logs/page.tsx new file mode 100644 index 00000000..b3e23271 --- /dev/null +++ b/apps/web/src/app/[locale]/alert-operation-logs/page.tsx @@ -0,0 +1,319 @@ +'use client' + +/** + * Alert Operation Log Page — 告警操作事件日誌 + * ============================================= + * Sprint 5.2: 展示 alert_operation_log 完整事件流 + * + * Features: + * - GET /api/v1/alert-operation-logs (分頁、篩選) + * - GET /api/v1/alert-operation-logs/stats (24h 統計) + * - event_type 篩選、incident_id 篩選 + * - 18 種事件類型顏色標記 + * + * i18n: 介面文字直接使用中文(此頁為內部工具,非公開 i18n) + * 變更: 2026-04-09 Claude Sonnet 4.6 Asia/Taipei + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import { AppLayout } from '@/components/layout' +import { cn } from '@/lib/utils' +import { + Activity, + ChevronLeft, + ChevronRight, + RefreshCw, + Shield, + AlertTriangle, + CheckCircle2, + XCircle, + Clock, + Zap, + Database, +} from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +interface AlertOpLog { + id: string + incident_id: string | null + approval_id: string | null + audit_log_id: string | null + auto_repair_id: string | null + event_type: string + actor: string | null + action_detail: string | null + success: boolean | null + error_message: string | null + context: Record + created_at: string +} + +interface LogsResponse { + items: AlertOpLog[] + total: number + limit: number + offset: number +} + +interface StatsResponse { + total: number + since_hours: number + by_event_type: Record +} + +// ============================================================================= +// Constants +// ============================================================================= + +const EVENT_TYPE_CONFIG: Record = { + ALERT_RECEIVED: { label: '告警收到', color: 'text-blue-400 bg-blue-900/30', icon: }, + TELEGRAM_SENT: { label: 'TG 通知', color: 'text-sky-400 bg-sky-900/30', icon: }, + USER_ACTION: { label: '用戶操作', color: 'text-purple-400 bg-purple-900/30', icon: }, + AUTO_REPAIR_TRIGGERED: { label: '自動修復', color: 'text-yellow-400 bg-yellow-900/30', icon: }, + EXECUTION_STARTED: { label: '執行開始', color: 'text-orange-400 bg-orange-900/30', icon: }, + EXECUTION_COMPLETED: { label: '執行完成', color: 'text-green-400 bg-green-900/30', icon: }, + TELEGRAM_RESULT_SENT: { label: 'TG 結果', color: 'text-sky-300 bg-sky-900/20', icon: }, + RESOLVED: { label: '已解決', color: 'text-green-500 bg-green-900/40', icon: }, + SILENCED: { label: '已靜音', color: 'text-gray-400 bg-gray-900/30', icon: }, + ESCALATED: { label: '已升級', color: 'text-red-400 bg-red-900/30', icon: }, + GUARDRAIL_BLOCKED: { label: '護欄攔截', color: 'text-red-500 bg-red-900/40', icon: }, + PRE_FLIGHT_PASSED: { label: '預檢通過', color: 'text-green-400 bg-green-900/30', icon: }, + PRE_FLIGHT_FAILED: { label: '預檢失敗', color: 'text-red-400 bg-red-900/30', icon: }, + BACKUP_TRIGGERED: { label: '備份觸發', color: 'text-blue-300 bg-blue-900/20', icon: }, + BACKUP_COMPLETED: { label: '備份完成', color: 'text-green-400 bg-green-900/30', icon: }, + BACKUP_FAILED: { label: '備份失敗', color: 'text-red-400 bg-red-900/30', icon: }, + APPROVAL_ESCALATED: { label: '審批升級', color: 'text-orange-400 bg-orange-900/30', icon: }, + CHANGE_APPLIED: { label: '變更套用', color: 'text-teal-400 bg-teal-900/30', icon: }, +} + +const PAGE_SIZE = 50 +const ALL_EVENT_TYPES = Object.keys(EVENT_TYPE_CONFIG) + +// ============================================================================= +// Component +// ============================================================================= + +export default function AlertOperationLogsPage() { + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [filterEventType, setFilterEventType] = useState('') + const [filterIncidentId, setFilterIncidentId] = useState('') + const abortRef = useRef(null) + + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? '' + + const fetchLogs = useCallback(async (pageNum: number, evType: string, incId: string) => { + abortRef.current?.abort() + abortRef.current = new AbortController() + setLoading(true) + setError(null) + + try { + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + offset: String((pageNum - 1) * PAGE_SIZE), + }) + if (evType) params.set('event_type', evType) + if (incId.trim()) params.set('incident_id', incId.trim()) + + const res = await fetch(`${apiBase}/api/v1/alert-operation-logs?${params}`, { + signal: abortRef.current.signal, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: LogsResponse = await res.json() + setLogs(data.items) + setTotal(data.total) + } catch (err) { + if ((err as Error).name !== 'AbortError') { + setError('載入失敗,請重試') + } + } finally { + setLoading(false) + } + }, [apiBase]) + + const fetchStats = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/v1/alert-operation-logs/stats`) + if (res.ok) setStats(await res.json()) + } catch { /* non-critical */ } + }, [apiBase]) + + useEffect(() => { + fetchLogs(page, filterEventType, filterIncidentId) + }, [page, filterEventType, filterIncidentId, fetchLogs]) + + useEffect(() => { + fetchStats() + return () => abortRef.current?.abort() + }, [fetchStats]) + + const totalPages = Math.ceil(total / PAGE_SIZE) + + const formatTime = (iso: string) => { + const d = new Date(iso) + return d.toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', hour12: false }) + } + + const getEventConfig = (et: string) => + EVENT_TYPE_CONFIG[et] ?? { label: et, color: 'text-gray-400 bg-gray-900/30', icon: } + + return ( + +
+ + {/* Header */} +
+
+

告警操作日誌

+

alert_operation_log · 全事件流追蹤

+
+ +
+ + {/* Stats */} + {stats && ( +
+
+
24h 總事件
+
{stats.total.toLocaleString()}
+
+ {['ALERT_RECEIVED', 'GUARDRAIL_BLOCKED', 'AUTO_REPAIR_TRIGGERED', 'RESOLVED'].map(et => ( +
+
{getEventConfig(et).label}
+
+ {(stats.by_event_type[et] ?? 0).toLocaleString()} +
+
+ ))} +
+ )} + + {/* Filters */} +
+ + { setFilterIncidentId(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-xs font-mono bg-gray-900 border border-gray-700 text-gray-300 rounded focus:border-gray-500 outline-none w-52" + /> + + 共 {total.toLocaleString()} 筆 + +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} +
+
+ + + + + + + + + + + + + {loading && ( + + )} + {!loading && logs.length === 0 && ( + + )} + {!loading && logs.map(log => { + const cfg = getEventConfig(log.event_type) + return ( + + + + + + + + + ) + })} + +
時間事件類型Incident操作者說明結果
載入中...
無記錄
+ {formatTime(log.created_at)} + + + {cfg.icon} + {cfg.label} + + + {log.incident_id ? ( + {log.incident_id.slice(-8)} + ) : ( + + )} + {log.actor ?? '—'} + {log.action_detail ?? '—'} + + {log.success === true && } + {log.success === false && } + {log.success === null && } +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ 第 {page} / {totalPages} 頁 +
+ + +
+
+ )} +
+
+ ) +}