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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "通知",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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' }}>
|
||||
{/* 環形圖 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user