feat: alert_operation_log 查詢 API + 前端頁面 (Sprint 5.2)
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

後端:
- 新增 list_recent() 分頁方法 (alert_operation_log_repository)
- 新增 /api/v1/alert-operation-logs GET + /stats 端點
- main.py 註冊 alert_operation_logs_v1.router

前端:
- /alert-operation-logs 頁面,18 種 event_type 顏色標記
- 分頁、event_type 篩選、incident_id 篩選
- 24h 統計卡片 (總數/護欄攔截/自動修復/已解決)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 10:57:40 +08:00
parent 428e66c111
commit 5ea6c3fb91
4 changed files with 453 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>
created_at: string
}
interface LogsResponse {
items: AlertOpLog[]
total: number
limit: number
offset: number
}
interface StatsResponse {
total: number
since_hours: number
by_event_type: Record<string, number>
}
// =============================================================================
// Constants
// =============================================================================
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
ALERT_RECEIVED: { label: '告警收到', color: 'text-blue-400 bg-blue-900/30', icon: <Activity className="h-3 w-3" /> },
TELEGRAM_SENT: { label: 'TG 通知', color: 'text-sky-400 bg-sky-900/30', icon: <Zap className="h-3 w-3" /> },
USER_ACTION: { label: '用戶操作', color: 'text-purple-400 bg-purple-900/30', icon: <Shield className="h-3 w-3" /> },
AUTO_REPAIR_TRIGGERED: { label: '自動修復', color: 'text-yellow-400 bg-yellow-900/30', icon: <Zap className="h-3 w-3" /> },
EXECUTION_STARTED: { label: '執行開始', color: 'text-orange-400 bg-orange-900/30', icon: <Clock className="h-3 w-3" /> },
EXECUTION_COMPLETED: { label: '執行完成', color: 'text-green-400 bg-green-900/30', icon: <CheckCircle2 className="h-3 w-3" /> },
TELEGRAM_RESULT_SENT: { label: 'TG 結果', color: 'text-sky-300 bg-sky-900/20', icon: <Zap className="h-3 w-3" /> },
RESOLVED: { label: '已解決', color: 'text-green-500 bg-green-900/40', icon: <CheckCircle2 className="h-3 w-3" /> },
SILENCED: { label: '已靜音', color: 'text-gray-400 bg-gray-900/30', icon: <Shield className="h-3 w-3" /> },
ESCALATED: { label: '已升級', color: 'text-red-400 bg-red-900/30', icon: <AlertTriangle className="h-3 w-3" /> },
GUARDRAIL_BLOCKED: { label: '護欄攔截', color: 'text-red-500 bg-red-900/40', icon: <Shield className="h-3 w-3" /> },
PRE_FLIGHT_PASSED: { label: '預檢通過', color: 'text-green-400 bg-green-900/30', icon: <CheckCircle2 className="h-3 w-3" /> },
PRE_FLIGHT_FAILED: { label: '預檢失敗', color: 'text-red-400 bg-red-900/30', icon: <XCircle className="h-3 w-3" /> },
BACKUP_TRIGGERED: { label: '備份觸發', color: 'text-blue-300 bg-blue-900/20', icon: <Database className="h-3 w-3" /> },
BACKUP_COMPLETED: { label: '備份完成', color: 'text-green-400 bg-green-900/30', icon: <Database className="h-3 w-3" /> },
BACKUP_FAILED: { label: '備份失敗', color: 'text-red-400 bg-red-900/30', icon: <Database className="h-3 w-3" /> },
APPROVAL_ESCALATED: { label: '審批升級', color: 'text-orange-400 bg-orange-900/30', icon: <AlertTriangle className="h-3 w-3" /> },
CHANGE_APPLIED: { label: '變更套用', color: 'text-teal-400 bg-teal-900/30', icon: <CheckCircle2 className="h-3 w-3" /> },
}
const PAGE_SIZE = 50
const ALL_EVENT_TYPES = Object.keys(EVENT_TYPE_CONFIG)
// =============================================================================
// Component
// =============================================================================
export default function AlertOperationLogsPage() {
const [logs, setLogs] = useState<AlertOpLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [stats, setStats] = useState<StatsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterEventType, setFilterEventType] = useState<string>('')
const [filterIncidentId, setFilterIncidentId] = useState<string>('')
const abortRef = useRef<AbortController | null>(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: <Activity className="h-3 w-3" /> }
return (
<AppLayout>
<div className="p-6 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-mono font-semibold text-white"></h1>
<p className="text-xs text-gray-500 mt-0.5 font-mono">alert_operation_log · </p>
</div>
<button
onClick={() => { fetchLogs(page, filterEventType, filterIncidentId); fetchStats() }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 rounded transition-colors"
>
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} />
</button>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-gray-900 border border-gray-800 rounded p-3">
<div className="text-xs text-gray-500 font-mono">24h </div>
<div className="text-2xl font-mono font-semibold text-white mt-1">{stats.total.toLocaleString()}</div>
</div>
{['ALERT_RECEIVED', 'GUARDRAIL_BLOCKED', 'AUTO_REPAIR_TRIGGERED', 'RESOLVED'].map(et => (
<div key={et} className="bg-gray-900 border border-gray-800 rounded p-3">
<div className="text-xs text-gray-500 font-mono">{getEventConfig(et).label}</div>
<div className="text-2xl font-mono font-semibold text-white mt-1">
{(stats.by_event_type[et] ?? 0).toLocaleString()}
</div>
</div>
))}
</div>
)}
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<select
value={filterEventType}
onChange={e => { setFilterEventType(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"
>
<option value=""></option>
{ALL_EVENT_TYPES.map(et => (
<option key={et} value={et}>{EVENT_TYPE_CONFIG[et]?.label ?? et}</option>
))}
</select>
<input
type="text"
placeholder="Incident ID 篩選..."
value={filterIncidentId}
onChange={e => { 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"
/>
<span className="text-xs font-mono text-gray-500 self-center">
{total.toLocaleString()}
</span>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-red-400 text-xs font-mono">
<XCircle className="h-4 w-4" />{error}
</div>
)}
{/* Table */}
<div className="bg-gray-900 border border-gray-800 rounded overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500">
<th className="text-left px-4 py-2.5"></th>
<th className="text-left px-4 py-2.5"></th>
<th className="text-left px-4 py-2.5">Incident</th>
<th className="text-left px-4 py-2.5"></th>
<th className="text-left px-4 py-2.5"></th>
<th className="text-left px-4 py-2.5"></th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-600">...</td></tr>
)}
{!loading && logs.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-600"></td></tr>
)}
{!loading && logs.map(log => {
const cfg = getEventConfig(log.event_type)
return (
<tr key={log.id} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="px-4 py-2.5 text-gray-400 whitespace-nowrap">
{formatTime(log.created_at)}
</td>
<td className="px-4 py-2.5">
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs', cfg.color)}>
{cfg.icon}
{cfg.label}
</span>
</td>
<td className="px-4 py-2.5 text-gray-400">
{log.incident_id ? (
<span className="text-blue-400">{log.incident_id.slice(-8)}</span>
) : (
<span className="text-gray-700"></span>
)}
</td>
<td className="px-4 py-2.5 text-gray-400">{log.actor ?? '—'}</td>
<td className="px-4 py-2.5 text-gray-300 max-w-xs truncate">
{log.action_detail ?? '—'}
</td>
<td className="px-4 py-2.5">
{log.success === true && <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />}
{log.success === false && <XCircle className="h-3.5 w-3.5 text-red-500" />}
{log.success === null && <span className="text-gray-700"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-xs font-mono text-gray-500">
<span> {page} / {totalPages} </span>
<div className="flex gap-2">
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
className="flex items-center gap-1 px-3 py-1.5 border border-gray-700 rounded disabled:opacity-30 hover:border-gray-500 transition-colors"
>
<ChevronLeft className="h-3 w-3" />
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
className="flex items-center gap-1 px-3 py-1.5 border border-gray-700 rounded disabled:opacity-30 hover:border-gray-500 transition-colors"
>
<ChevronRight className="h-3 w-3" />
</button>
</div>
</div>
)}
</div>
</AppLayout>
)
}