fix(web): QA 掃描 — alert-operation-logs i18n + classic emoji→icon + knowledge 載入中
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m28s

- alert-operation-logs: 30+ 處硬編碼中文改 useTranslations (18 event types + UI)
- classic: 告警 badge + 等待確認 + TOOL_EMOJI → Lucide icon
- knowledge: 載入中 → common.loading
- 新增 alertOpLogs i18n section (zh-TW + en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 13:58:04 +08:00
parent 1d88b7cd9d
commit 73ef9c6b12
5 changed files with 156 additions and 63 deletions

View File

@@ -1137,5 +1137,44 @@
"dispositionManual": "Manual Resolved",
"dispositionCold": "Cold Start Trust",
"autoRateLabel": "Automation Rate"
},
"alertOpLogs": {
"title": "Alert Operation Logs",
"subtitle": "alert_operation_log · Full event stream",
"refresh": "Refresh",
"totalEvents24h": "24h Total Events",
"allEventTypes": "All Event Types",
"incidentIdFilter": "Filter by Incident ID...",
"totalCount": "{count} total",
"colTime": "Time",
"colEventType": "Event Type",
"colIncident": "Incident",
"colActor": "Actor",
"colDetail": "Detail",
"colResult": "Result",
"loading": "Loading...",
"noRecords": "No records",
"loadError": "Failed to load, please retry",
"pageInfo": "Page {page} / {total}",
"prevPage": "Previous",
"nextPage": "Next",
"eventAlertReceived": "Alert Received",
"eventTelegramSent": "TG Notified",
"eventUserAction": "User Action",
"eventAutoRepairTriggered": "Auto Repair",
"eventExecutionStarted": "Execution Started",
"eventExecutionCompleted": "Execution Completed",
"eventTelegramResultSent": "TG Result",
"eventResolved": "Resolved",
"eventSilenced": "Silenced",
"eventEscalated": "Escalated",
"eventGuardrailBlocked": "Guardrail Blocked",
"eventPreFlightPassed": "Pre-flight Passed",
"eventPreFlightFailed": "Pre-flight Failed",
"eventBackupTriggered": "Backup Triggered",
"eventBackupCompleted": "Backup Completed",
"eventBackupFailed": "Backup Failed",
"eventApprovalEscalated": "Approval Escalated",
"eventChangeApplied": "Change Applied"
}
}

View File

@@ -1138,5 +1138,44 @@
"dispositionManual": "手動處理",
"dispositionCold": "冷啟動信任",
"autoRateLabel": "自動化率"
},
"alertOpLogs": {
"title": "告警操作日誌",
"subtitle": "alert_operation_log · 全事件流追蹤",
"refresh": "重新整理",
"totalEvents24h": "24h 總事件",
"allEventTypes": "全部事件類型",
"incidentIdFilter": "Incident ID 篩選...",
"totalCount": "共 {count} 筆",
"colTime": "時間",
"colEventType": "事件類型",
"colIncident": "Incident",
"colActor": "操作者",
"colDetail": "說明",
"colResult": "結果",
"loading": "載入中...",
"noRecords": "無記錄",
"loadError": "載入失敗,請重試",
"pageInfo": "第 {page} / {total} 頁",
"prevPage": "上一頁",
"nextPage": "下一頁",
"eventAlertReceived": "告警收到",
"eventTelegramSent": "TG 通知",
"eventUserAction": "用戶操作",
"eventAutoRepairTriggered": "自動修復",
"eventExecutionStarted": "執行開始",
"eventExecutionCompleted": "執行完成",
"eventTelegramResultSent": "TG 結果",
"eventResolved": "已解決",
"eventSilenced": "已靜音",
"eventEscalated": "已升級",
"eventGuardrailBlocked": "護欄攔截",
"eventPreFlightPassed": "預檢通過",
"eventPreFlightFailed": "預檢失敗",
"eventBackupTriggered": "備份觸發",
"eventBackupCompleted": "備份完成",
"eventBackupFailed": "備份失敗",
"eventApprovalEscalated": "審批升級",
"eventChangeApplied": "變更套用"
}
}

View File

@@ -11,11 +11,11 @@
* - event_type 篩選、incident_id 篩選
* - 18 種事件類型顏色標記
*
* i18n: 介面文字直接使用中文(此頁為內部工具,非公開 i18n
* 變更: 2026-04-09 Claude Sonnet 4.6 Asia/Taipei
* @updated 2026-04-09 Claude Opus 4.6 — i18n 全面改用 useTranslations
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { cn } from '@/lib/utils'
import {
@@ -68,35 +68,36 @@ interface StatsResponse {
// 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 EVENT_TYPE_STYLE: Record<string, { i18nKey: string; color: string; icon: React.ReactNode }> = {
ALERT_RECEIVED: { i18nKey: 'eventAlertReceived', color: 'text-blue-400 bg-blue-900/30', icon: <Activity className="h-3 w-3" /> },
TELEGRAM_SENT: { i18nKey: 'eventTelegramSent', color: 'text-sky-400 bg-sky-900/30', icon: <Zap className="h-3 w-3" /> },
USER_ACTION: { i18nKey: 'eventUserAction', color: 'text-purple-400 bg-purple-900/30', icon: <Shield className="h-3 w-3" /> },
AUTO_REPAIR_TRIGGERED: { i18nKey: 'eventAutoRepairTriggered', color: 'text-yellow-400 bg-yellow-900/30', icon: <Zap className="h-3 w-3" /> },
EXECUTION_STARTED: { i18nKey: 'eventExecutionStarted', color: 'text-orange-400 bg-orange-900/30', icon: <Clock className="h-3 w-3" /> },
EXECUTION_COMPLETED: { i18nKey: 'eventExecutionCompleted', color: 'text-green-400 bg-green-900/30', icon: <CheckCircle2 className="h-3 w-3" /> },
TELEGRAM_RESULT_SENT: { i18nKey: 'eventTelegramResultSent', color: 'text-sky-300 bg-sky-900/20', icon: <Zap className="h-3 w-3" /> },
RESOLVED: { i18nKey: 'eventResolved', color: 'text-green-500 bg-green-900/40', icon: <CheckCircle2 className="h-3 w-3" /> },
SILENCED: { i18nKey: 'eventSilenced', color: 'text-gray-400 bg-gray-900/30', icon: <Shield className="h-3 w-3" /> },
ESCALATED: { i18nKey: 'eventEscalated', color: 'text-red-400 bg-red-900/30', icon: <AlertTriangle className="h-3 w-3" /> },
GUARDRAIL_BLOCKED: { i18nKey: 'eventGuardrailBlocked', color: 'text-red-500 bg-red-900/40', icon: <Shield className="h-3 w-3" /> },
PRE_FLIGHT_PASSED: { i18nKey: 'eventPreFlightPassed', color: 'text-green-400 bg-green-900/30', icon: <CheckCircle2 className="h-3 w-3" /> },
PRE_FLIGHT_FAILED: { i18nKey: 'eventPreFlightFailed', color: 'text-red-400 bg-red-900/30', icon: <XCircle className="h-3 w-3" /> },
BACKUP_TRIGGERED: { i18nKey: 'eventBackupTriggered', color: 'text-blue-300 bg-blue-900/20', icon: <Database className="h-3 w-3" /> },
BACKUP_COMPLETED: { i18nKey: 'eventBackupCompleted', color: 'text-green-400 bg-green-900/30', icon: <Database className="h-3 w-3" /> },
BACKUP_FAILED: { i18nKey: 'eventBackupFailed', color: 'text-red-400 bg-red-900/30', icon: <Database className="h-3 w-3" /> },
APPROVAL_ESCALATED: { i18nKey: 'eventApprovalEscalated', color: 'text-orange-400 bg-orange-900/30', icon: <AlertTriangle className="h-3 w-3" /> },
CHANGE_APPLIED: { i18nKey: 'eventChangeApplied', 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)
const ALL_EVENT_TYPES = Object.keys(EVENT_TYPE_STYLE)
// =============================================================================
// Component
// =============================================================================
export default function AlertOperationLogsPage() {
export default function AlertOperationLogsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('alertOpLogs')
const [logs, setLogs] = useState<AlertOpLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
@@ -109,6 +110,20 @@ export default function AlertOperationLogsPage() {
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''
const getEventLabel = (et: string) => {
const style = EVENT_TYPE_STYLE[et]
return style ? t(style.i18nKey as any) : et
}
const getEventConfig = (et: string) => {
const style = EVENT_TYPE_STYLE[et]
return {
label: style ? t(style.i18nKey as any) : et,
color: style?.color ?? 'text-gray-400 bg-gray-900/30',
icon: style?.icon ?? <Activity className="h-3 w-3" />,
}
}
const fetchLogs = useCallback(async (pageNum: number, evType: string, incId: string) => {
abortRef.current?.abort()
abortRef.current = new AbortController()
@@ -132,12 +147,12 @@ export default function AlertOperationLogsPage() {
setTotal(data.total)
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setError('載入失敗,請重試')
setError(t('loadError'))
}
} finally {
setLoading(false)
}
}, [apiBase])
}, [apiBase, t])
const fetchStats = useCallback(async () => {
try {
@@ -162,25 +177,22 @@ export default function AlertOperationLogsPage() {
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>
<AppLayout locale={params.locale}>
<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>
<h1 className="text-xl font-mono font-semibold text-white">{t('title')}</h1>
<p className="text-xs text-gray-500 mt-0.5 font-mono">{t('subtitle')}</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')} />
{t('refresh')}
</button>
</div>
@@ -188,12 +200,12 @@ export default function AlertOperationLogsPage() {
{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-xs text-gray-500 font-mono">{t('totalEvents24h')}</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-xs text-gray-500 font-mono">{getEventLabel(et)}</div>
<div className="text-2xl font-mono font-semibold text-white mt-1">
{(stats.by_event_type[et] ?? 0).toLocaleString()}
</div>
@@ -209,20 +221,20 @@ export default function AlertOperationLogsPage() {
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>
<option value="">{t('allEventTypes')}</option>
{ALL_EVENT_TYPES.map(et => (
<option key={et} value={et}>{EVENT_TYPE_CONFIG[et]?.label ?? et}</option>
<option key={et} value={et}>{getEventLabel(et)}</option>
))}
</select>
<input
type="text"
placeholder="Incident ID 篩選..."
placeholder={t('incidentIdFilter')}
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()}
{t('totalCount', { count: total.toLocaleString() })}
</span>
</div>
@@ -239,20 +251,20 @@ export default function AlertOperationLogsPage() {
<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>
<th className="text-left px-4 py-2.5">{t('colTime')}</th>
<th className="text-left px-4 py-2.5">{t('colEventType')}</th>
<th className="text-left px-4 py-2.5">{t('colIncident')}</th>
<th className="text-left px-4 py-2.5">{t('colActor')}</th>
<th className="text-left px-4 py-2.5">{t('colDetail')}</th>
<th className="text-left px-4 py-2.5">{t('colResult')}</th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-600">...</td></tr>
<tr><td colSpan={6} className="text-center py-8 text-gray-600">{t('loading')}</td></tr>
)}
{!loading && logs.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-600"></td></tr>
<tr><td colSpan={6} className="text-center py-8 text-gray-600">{t('noRecords')}</td></tr>
)}
{!loading && logs.map(log => {
const cfg = getEventConfig(log.event_type)
@@ -294,21 +306,21 @@ export default function AlertOperationLogsPage() {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-xs font-mono text-gray-500">
<span> {page} / {totalPages} </span>
<span>{t('pageInfo', { page, total: 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" />
<ChevronLeft className="h-3 w-3" />{t('prevPage')}
</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" />
{t('nextPage')}<ChevronRight className="h-3 w-3" />
</button>
</div>
</div>

View File

@@ -21,6 +21,7 @@
import React from 'react'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { BarChart3, Flame, Telescope, FlaskConical, Activity, GitBranch } from 'lucide-react'
import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics'
import { useIncidents } from '@/hooks/useIncidents'
import { useHosts, useDashboardStore } from '@/stores/dashboard.store'
@@ -93,14 +94,14 @@ const TOOL_ACCENT_COLOR: Record<string, string> = {
Gitea: '#22C55E',
}
// 圖示 emoji
const TOOL_EMOJI: Record<string, string> = {
Grafana: '📊',
Prometheus: '🔥',
Sentry: '🔭',
Langfuse: '🧪',
SigNoz: '🔭',
Gitea: '🐙',
// 圖示 Lucide icon (feedback_no_emoji_use_icons.md)
const TOOL_ICON: Record<string, React.ReactNode> = {
Grafana: <BarChart3 size={16} />,
Prometheus: <Flame size={16} />,
Sentry: <Telescope size={16} />,
Langfuse: <FlaskConical size={16} />,
SigNoz: <Activity size={16} />,
Gitea: <GitBranch size={16} />,
}
function MonitoringTools() {
@@ -136,7 +137,7 @@ function MonitoringTools() {
const statusColor = isUp ? (hasFiring ? '#F59E0B' : '#22C55E') : '#cc2200'
const statusText = isUp ? (hasFiring ? `${tool.firing_count} ${tDash('monitoringStatus.firing')}` : tDash('monitoringStatus.up')) : tDash('monitoringStatus.down')
const accentColor = TOOL_ACCENT_COLOR[tool.name] ?? '#b0ad9f'
const emoji = TOOL_EMOJI[tool.name] ?? '🔧'
const icon = TOOL_ICON[tool.name] ?? <Activity size={16} />
const link = tool.url ?? '#'
const timeStr = (() => {
try { return new Date(tool.checked_at).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei', hour: '2-digit', minute: '2-digit' }) }
@@ -174,7 +175,7 @@ function MonitoringTools() {
{/* 主行 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
<span style={{ fontSize: 18 }}>{emoji}</span>
<span style={{ display: 'inline-flex', color: accentColor }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#141413', marginBottom: 2 }}>
{tool.name}
@@ -192,7 +193,7 @@ function MonitoringTools() {
fontSize: 10, padding: '1px 7px', borderRadius: 8, fontWeight: 600,
background: 'rgba(245,158,11,0.12)', color: '#F59E0B',
}}>
{tool.firing_count}
{tDash('alertBadge', { count: tool.firing_count })}
</span>
) : (
<span style={{
@@ -200,7 +201,7 @@ function MonitoringTools() {
fontSize: 10, padding: '1px 7px', borderRadius: 8, fontWeight: 600,
background: 'rgba(34,197,94,0.1)', color: '#22C55E',
}}>
0
{tDash('alertBadgeZero')}
</span>
)}
</div>
@@ -502,7 +503,7 @@ export default function Home({ params }: { params: { locale: string } }) {
label: tDashboard('pendingApprovals'),
value: pendingApprovals ?? '--',
sub: hasPendingApprovals ? undefined : tDashboard('stable'),
badge: hasPendingApprovals ? { text: `⏳ 等待確認`, color: '#F59E0B', bg: 'rgba(245,158,11,0.08)' } : undefined,
badge: hasPendingApprovals ? { text: tDashboard('awaitingConfirm'), color: '#F59E0B', bg: 'rgba(245,158,11,0.08)' } : undefined,
valueColor: hasPendingApprovals ? '#F59E0B' : undefined,
},
{

View File

@@ -8,14 +8,16 @@
*/
import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
const KnowledgeBaseContent = lazy(() => import('@/app/[locale]/knowledge-base/page'))
export default function KnowledgePage({ params }: { params: { locale: string } }) {
const t = useTranslations('common')
return (
<AppLayout locale={params.locale}>
<Suspense fallback={<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>...</div>}>
<Suspense fallback={<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>{t('loading')}</div>}>
<KnowledgeBaseContent params={params} />
</Suspense>
</AppLayout>