feat: alert_operation_log 查詢 API + 前端頁面 (Sprint 5.2)
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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:
100
apps/api/src/api/v1/alert_operation_logs.py
Normal file
100
apps/api/src/api/v1/alert_operation_logs.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
319
apps/web/src/app/[locale]/alert-operation-logs/page.tsx
Normal file
319
apps/web/src/app/[locale]/alert-operation-logs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user