fix(review): 架構審查修復 — P0 import crash + i18n 零 hardcode + 靜默錯誤

P0:
- proposal_service.py: 補 get_redis + INCIDENT_KEY_PREFIX import
  (修前: resolve_incident_after_approval 必 NameError crash)

P1 i18n:
- page.tsx: 拓撲群組移除 emoji,改用 tTopo() i18n key
- page.tsx: 主機標籤 (DevOps金庫等) 改 tTopo() i18n
- ai-model-status.tsx: 加 useTranslations,AI 模型狀態 → t('aiModelStatus')
- disposition-mini.tsx: 查看完整報表 → t('viewAllReport')
- recent-activity.tsx: 查看活動串流 → t('viewAllAlerts')

P2 品質:
- pending-approvals-card.tsx: approve/reject 加 r.ok 檢查+錯誤顯示,查看全部授權加路由+i18n
- page-tabs.tsx: TabSkeleton 載入中... → t('loading')
- page.tsx: ↑5% → tDashboard('trendUp', {pct}) 動態值
- page.tsx: Prometheus '23' hardcode → '-- targets'

i18n 新增 key (zh-TW + en 同步):
- dashboard: viewAllAlerts/viewAllAuth/viewAllReport/aiModelStatus/loading/trendUp
- topology: groupExternal/allReachable/investigating/hostDevops/hostAiData/hostK3sMaster/hostK3sWorker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 18:34:50 +08:00
parent 309fe04698
commit 890e2a9568
9 changed files with 68 additions and 22 deletions

View File

@@ -39,10 +39,11 @@ from src.models.incident import (
IncidentStatus,
Severity,
)
from src.core.redis_client import get_redis
from src.services.approval_db import get_approval_service
from src.services.incident_engine import get_incident_engine
from src.services.incident_memory import get_incident_memory
from src.services.incident_service import get_incident_service
from src.services.incident_service import INCIDENT_KEY_PREFIX, get_incident_service
from src.services.openclaw import get_openclaw
from src.services.trust_engine import normalize_action_pattern, trust_engine
from src.utils.incident_converter import local_to_brain
@@ -179,9 +180,11 @@ class ProposalService:
# LLM 提供的 risk_level 轉換
llm_risk = llm_proposal.get("risk_level", "medium")
# 2026-04-09 Claude Sonnet 4.6: P1-2 QA修復 — 補 "high" 鍵,防止 LLM 自由文字回傳 high 時降為 MEDIUM
risk_map = {
"low": ApprovalRiskLevel.LOW,
"medium": ApprovalRiskLevel.MEDIUM,
"high": ApprovalRiskLevel.HIGH,
"critical": ApprovalRiskLevel.CRITICAL,
}
base_risk = risk_map.get(llm_risk, ApprovalRiskLevel.MEDIUM)

View File

@@ -198,7 +198,13 @@
"dashboardConnecting": "Dashboard API connecting...",
"alertBadge": "{count} alerts",
"alertBadgeZero": "0 alerts",
"awaitingConfirm": "Awaiting Confirmation"
"awaitingConfirm": "Awaiting Confirmation",
"viewAllAlerts": "View All Alerts",
"viewAllAuth": "View All Authorizations",
"viewAllReport": "View Full Report",
"aiModelStatus": "AI Model Status",
"loading": "Loading...",
"trendUp": "↑{pct}%"
},
"openclaw": {
"name": "OpenClaw",
@@ -822,8 +828,15 @@
"groupK3s": "K3s Cluster",
"groupAiData": "AI/Data Center",
"allHealthy": "All Healthy",
"allReachable": "All Reachable",
"warning": "Warning",
"healthy": "Healthy"
"healthy": "Healthy",
"investigating": "Investigating",
"groupExternal": "External Services",
"hostDevops": "DevOps Vault",
"hostAiData": "AI+Web Hub",
"hostK3sMaster": "K3s Master",
"hostK3sWorker": "K3s Worker"
},
"notifications": {
"title": "Notifications",

View File

@@ -199,7 +199,13 @@
"dashboardConnecting": "Dashboard API 連線中",
"alertBadge": "{count} 告警",
"alertBadgeZero": "0 告警",
"awaitingConfirm": "等待確認"
"awaitingConfirm": "等待確認",
"viewAllAlerts": "查看全部告警",
"viewAllAuth": "查看全部授權",
"viewAllReport": "查看完整報表",
"aiModelStatus": "AI 模型狀態",
"loading": "載入中...",
"trendUp": "↑{pct}%"
},
"openclaw": {
"name": "OpenClaw",
@@ -823,8 +829,15 @@
"groupK3s": "K3s 叢集",
"groupAiData": "AI/數據中心",
"allHealthy": "全部健康",
"allReachable": "全部可達",
"warning": "異常",
"healthy": "健康"
"healthy": "健康",
"investigating": "調查中",
"groupExternal": "外部服務",
"hostDevops": "DevOps 金庫",
"hostAiData": "AI+Web 中心",
"hostK3sMaster": "K3s Master",
"hostK3sWorker": "K3s Worker"
},
"notifications": {
"title": "通知",

View File

@@ -330,7 +330,7 @@ function MonitoringTools() {
// S10: 監控工具元資訊 (設計稿 3×2 精簡版)
const TOOL_META: Record<string, string> = {
SigNoz: 'Traces · Logs', Grafana: '3 Dashboards', Prometheus: `${tools.length > 0 ? '23' : '--'} targets`,
SigNoz: 'Traces · Logs', Grafana: '3 Dashboards', Prometheus: '-- targets',
Langfuse: 'LLMOps', Sentry: '2 Projects', Gitea: 'CI/CD',
}
@@ -455,6 +455,7 @@ function buildHostInfo(
export default function Home({ params }: { params: { locale: string } }) {
const tDashboard = useTranslations('dashboard')
const tCommon = useTranslations('common')
const tTopo = useTranslations('topology')
const locale = params.locale
const hosts = useHosts()
@@ -672,7 +673,7 @@ export default function Home({ params }: { params: { locale: string } }) {
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('autoRemediationRate')}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 2 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: '#22C55E' }}>{autoRemediationRate}</span>
{autoRemediationPct > 0 && <span style={{ fontSize: 10, fontWeight: 700, color: '#22C55E' }}>5%</span>}
{autoRemediationPct > 0 && <span style={{ fontSize: 10, fontWeight: 700, color: '#22C55E' }}>{tDashboard('trendUp', { pct: autoRemediationPct })}</span>}
</div>
<div style={{ height: 3, borderRadius: 2, background: '#ebe8df', marginTop: 4, overflow: 'hidden' }}>
<div style={{ width: `${autoRemediationPct}%`, height: '100%', borderRadius: 2, background: 'linear-gradient(90deg,#22C55E,#4ade80)' }} />
@@ -846,10 +847,10 @@ export default function Home({ params }: { params: { locale: string } }) {
{infraView === 'topo' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 14 }}>
{[
{ name: '🏗️ 基礎設施 (.110)', meta: '7 服務 · ✓ 全部健康', services: ['Gitea', 'Harbor', 'Sentry', 'Prom'], borderColor: 'rgba(59,130,246,0.2)', bg: 'rgba(59,130,246,0.01)' },
{ name: '🧠 AI/數據 (.188)', meta: '7 服務 · ⚡ OpenClaw', services: ['PG', 'Redis', 'OpenClaw', 'Ollama'], borderColor: 'rgba(249,115,22,0.25)', bg: 'rgba(249,115,22,0.01)' },
{ name: '☸️ K3s 叢集', meta: `5 服務 · ${incidentCount > 0 ? '⚠️ investigating' : '✓ 健康'}`, services: ['api×2', 'web×2', 'worker'], borderColor: 'rgba(168,85,247,0.25)', bg: 'rgba(168,85,247,0.01)', warning: incidentCount > 0 },
{ name: '🌐 外部服務', meta: '3 服務 · ✓ 全部可達', services: ['Gemini', 'NVIDIA', 'CF'], borderColor: 'rgba(245,158,11,0.2)', bg: 'rgba(245,158,11,0.01)' },
{ name: `${tTopo('groupInfra')} (.110)`, meta: `7 ${tTopo('services')} · ${tTopo('allHealthy')}`, services: ['Gitea', 'Harbor', 'Sentry', 'Prom'], borderColor: 'rgba(59,130,246,0.2)', bg: 'rgba(59,130,246,0.01)' },
{ name: `${tTopo('groupAiData')} (.188)`, meta: `7 ${tTopo('services')} · OpenClaw`, services: ['PG', 'Redis', 'OpenClaw', 'Ollama'], borderColor: 'rgba(249,115,22,0.25)', bg: 'rgba(249,115,22,0.01)' },
{ name: tTopo('groupK3s'), meta: `5 ${tTopo('services')} · ${incidentCount > 0 ? tTopo('investigating') : tTopo('healthy')}`, services: ['api×2', 'web×2', 'worker'], borderColor: 'rgba(168,85,247,0.25)', bg: 'rgba(168,85,247,0.01)', warning: incidentCount > 0 },
{ name: tTopo('groupExternal'), meta: `3 ${tTopo('services')} · ${tTopo('allReachable')}`, services: ['Gemini', 'NVIDIA', 'CF'], borderColor: 'rgba(245,158,11,0.2)', bg: 'rgba(245,158,11,0.01)' },
].map(g => (
<div key={g.name} style={{
border: `0.5px solid ${g.borderColor}`, borderRadius: 8, padding: '8px 10px',
@@ -878,10 +879,10 @@ export default function Home({ params }: { params: { locale: string } }) {
{infraView === 'host' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 14 }}>
{[
{ name: 'DevOps 金庫', ip: '192.168.0.110', cpu: 35, ram: 55 },
{ name: 'AI+Web 中心', ip: '192.168.0.188', cpu: 67, ram: 72 },
{ name: 'K3s Master', ip: '192.168.0.120', cpu: 45, ram: 60 },
{ name: 'K3s Worker', ip: '192.168.0.121', cpu: null as number | null, ram: null as number | null },
{ name: tTopo('hostDevops'), ip: '192.168.0.110', cpu: 35, ram: 55 },
{ name: tTopo('hostAiData'), ip: '192.168.0.188', cpu: 67, ram: 72 },
{ name: tTopo('hostK3sMaster'), ip: '192.168.0.120', cpu: 45, ram: 60 },
{ name: tTopo('hostK3sWorker'), ip: '192.168.0.121', cpu: null as number | null, ram: null as number | null },
].map(h => {
// 嘗試從 API 取得真實數據
const apiHost = hosts.find(ah => ah.ip === h.ip)

View File

@@ -24,6 +24,7 @@
import { useState, useCallback, useMemo, Suspense, type ReactNode } from 'react'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
// =============================================================================
@@ -55,6 +56,7 @@ export interface PageTabsProps {
// =============================================================================
function TabSkeleton() {
const t = useTranslations('dashboard')
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<style>{`
@@ -77,7 +79,7 @@ function TabSkeleton() {
</svg>
</div>
<span style={{ fontSize: 12, color: '#87867f', fontFamily: "'DM Mono', monospace", animation: 'tab-lobster-wave 2s ease-in-out infinite' }}>
...
{t('loading')}
</span>
</div>
)

View File

@@ -7,6 +7,7 @@
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -17,6 +18,7 @@ interface ModelInfo {
}
export function AIModelStatus() {
const t = useTranslations('dashboard')
const [models, setModels] = useState<ModelInfo[]>([
{ name: 'OpenClaw Nemo', tag: 'local', healthy: false },
{ name: 'Ollama gemma3', tag: 'local', healthy: false },
@@ -50,7 +52,7 @@ export function AIModelStatus() {
display: 'flex', alignItems: 'center', gap: 8, background: '#faf9f3',
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413' }}>AI </span>
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413' }}>{t('aiModelStatus')}</span>
</div>
<div style={{ padding: 14, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{models.map(m => (

View File

@@ -68,7 +68,7 @@ export function DispositionMini() {
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', letterSpacing: '0.5px' }}>{t('dispositionBreakdown')}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}>{t('viewAllReport')} </span>
</div>
<div style={{ padding: 14, display: 'flex', gap: 14, alignItems: 'center' }}>
{/* 環形圖 */}

View File

@@ -8,6 +8,8 @@
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { useLocale } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -29,7 +31,10 @@ const RISK_STYLE: Record<string, { bg: string; color: string }> = {
export function PendingApprovalsCard() {
const t = useTranslations('dashboard')
const router = useRouter()
const locale = useLocale()
const [approvals, setApprovals] = useState<Approval[]>([])
const [actionError, setActionError] = useState<string | null>(null)
useEffect(() => {
fetch(`${API_BASE}/api/v1/approvals/pending`)
@@ -52,7 +57,7 @@ export function PendingApprovalsCard() {
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#F59E0B' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413' }}>{t('pendingApprovalsTitle')}</span>
<span style={{ fontSize: 11, background: 'rgba(249,115,22,0.1)', color: '#F59E0B', padding: '2px 8px', fontWeight: 700, borderRadius: 10 }}>{approvals.length}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
<span onClick={() => router.push(`/${locale}/authorizations`)} style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}>{t('viewAllAuth')} </span>
</div>
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{approvals.slice(0, 3).map((ap, i) => {
@@ -74,17 +79,21 @@ export function PendingApprovalsCard() {
<div style={{ display: 'flex', gap: 4, marginTop: 5 }}>
<button
onClick={() => {
setActionError(null)
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/sign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signer: 'web-ui' }) })
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(() => {})
.catch(e => setActionError(`approve failed: ${e.message}`))
}}
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}
>{t('approve')}</button>
<button
onClick={() => {
setActionError(null)
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'rejected-from-web' }) })
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(() => {})
.catch(e => setActionError(`reject failed: ${e.message}`))
}}
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}
>{t('reject')}</button>
@@ -93,6 +102,9 @@ export function PendingApprovalsCard() {
)
})}
</div>
{actionError && (
<div style={{ padding: '4px 14px 8px', fontSize: 11, color: '#cc2200' }}>{actionError}</div>
)}
</div>
)
}

View File

@@ -52,7 +52,7 @@ export function RecentActivity() {
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', letterSpacing: '0.5px' }}>{t('activityStream')}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}>{t('viewAllAlerts')} </span>
</div>
<div style={{ padding: '10px 14px' }}>
{logs.map((log, i) => {