feat(page): redesign AI Center layout - 3-col Feed+RightPanel, Metrics Strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-01 20:07:58 +08:00
parent beb6a227db
commit 9268d22dee

View File

@@ -1,367 +1,199 @@
'use client'
/**
* AWOOOI 全局戰情室 (Global War Room)
* AWOOOI AI Center 主頁 (Task 10 重構)
* ====================================
* Phase 2: 完整 AppLayout 整合
* 3欄佈局: Metrics Strip + Feed + RightPanel
*
* 佈局結構:
* - 左側側邊欄: 導航選單
* - 主內容: 70/30 Grid (系統狀態 + AI 面板)
*
* 視覺規範:
* - awoooi-glass 白玻璃毛玻璃
* - DataPincer 數據鉗容器
* - 點陣紋理背景
*
* i18n: 100% next-intl零硬編碼
* 統帥鐵律: 使用真實數據 Hook禁止假數據
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { LiveDashboard } from '@/components/dashboard/live-dashboard'
import { DataPincerPanel } from '@/components/cyber'
import { OpenClawStateMachine } from '@/components/ai/openclaw-state-machine'
import { GlobalPulseChart } from '@/components/charts/global-pulse-chart'
import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics'
import { useIncidents } from '@/hooks/useIncidents'
import type { DecisionInfo } from '@/lib/api-client'
import {
ThinkingTerminal,
DualStateIncidentCard,
} from '@/components/incident'
import { AlertTriangle } from 'lucide-react'
import type { IncidentResponse } from '@/lib/api-client'
// =============================================================================
// Types for AI Decision Chain Display
// =============================================================================
interface ReasoningStep {
step: string
reasoning: string
timestamp?: string
}
interface DecisionChainDisplay {
analysis_type: 'blast_radius' | 'root_cause' | 'action_suggestion'
target_service: string
reasoning_steps: ReasoningStep[]
conclusion: string
confidence: number
}
// =============================================================================
// Utility: Convert API proposal_data to DecisionChain format
// =============================================================================
function convertToDecisionChain(
incident: IncidentResponse | null | undefined
): DecisionChainDisplay | null {
if (!incident?.decision?.proposal_data) {
return null
}
const data = incident.decision.proposal_data
const target = incident.affected_services?.[0] || 'unknown-service'
const source = data.source || 'unknown'
const _now = new Date().toISOString()
// 建構推理步驟 (從 API 資料)
const steps: ReasoningStep[] = [
{
step: 'SIGNAL_RECEIVED',
reasoning: `收到 ${incident.signal_count || 1} 筆告警,影響服務: ${target}`,
timestamp: incident.created_at,
},
{
step: 'SEVERITY_EVALUATION',
reasoning: `告警等級: ${incident.severity} | 狀態: ${incident.status}`,
},
{
step: 'AI_ENGINE',
reasoning: `決策引擎: ${source === 'expert_system' ? 'Expert System (規則引擎)' : 'OpenClaw LLM (智能分析)'}`,
},
]
// 加入 AI 推理 (如果有)
if (data.reasoning) {
steps.push({
step: 'ROOT_CAUSE_ANALYSIS',
reasoning: data.reasoning,
})
}
// 加入描述 (如果有)
if (data.description) {
steps.push({
step: 'ANALYSIS_RESULT',
reasoning: data.description,
})
}
// 加入建議動作
if (data.action || data.kubectl_command) {
steps.push({
step: 'ACTION_RECOMMENDATION',
reasoning: `建議動作: ${data.action || data.kubectl_command}\n風險等級: ${data.risk_level || 'medium'}`,
})
}
return {
analysis_type: 'action_suggestion',
target_service: target,
reasoning_steps: steps,
conclusion: data.action || '等待 AI 分析完成...',
confidence: data.confidence ?? 0.75,
}
}
// =============================================================================
// Utility: Map IncidentResponse to DualStateIncidentCard props
// =============================================================================
function mapToDualState(incident: IncidentResponse | null | undefined): {
id: string
serviceName: string
status: 'normal' | 'alert'
tier?: 1 | 2 | 3
message: string
timestamp: string
decision?: DecisionInfo | null // Phase 6.5: 決策令牌
} {
// 防禦性檢查: 若 incident 無效則返回預設值
if (!incident) {
return {
id: 'unknown',
serviceName: 'Unknown Service',
status: 'normal',
tier: undefined,
message: '資料載入中...',
timestamp: '-',
decision: null,
}
}
// P0/P1/P2 視為異常 (alert),只有 P3 視為正常 (normal)
const severity = incident.severity || 'P3'
const isAlert = severity === 'P0' || severity === 'P1' || severity === 'P2'
// Tier 判定: P0 = Tier 3 (親核), P1 = Tier 2 (授權), P2+ = Tier 1 (自主)
let tier: 1 | 2 | 3 | undefined = undefined
if (isAlert) {
if (severity === 'P0') {
tier = 3
} else if (severity === 'P1') {
tier = 2
} else {
tier = 1
}
}
// 安全提取服務名稱
const services = incident.affected_services || []
const serviceName = services.length > 0
? services[0]
: incident.incident_id?.split('-')[2] || 'Unknown Service'
// 格式化時間 (安全處理)
let timestamp = '-'
try {
const date = new Date(incident.created_at || Date.now())
timestamp = date.toLocaleString('zh-TW', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
timestamp = 'Invalid Date'
}
// 安全生成訊息
const signalCount = incident.signal_count ?? 0
const status = incident.status || 'unknown'
const message = `[${severity}] ${signalCount} 筆告警 | ${status}`
return {
id: incident.incident_id || 'unknown',
serviceName,
status: isAlert ? 'alert' : 'normal',
tier,
message,
timestamp,
decision: incident.decision, // Phase 6.5: 傳遞決策令牌
}
}
import { IncidentCard } from '@/components/incident'
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
import { HostGrid } from '@/components/infra/host-grid'
// =============================================================================
// Main Page
// =============================================================================
export default function Home({ params }: { params: { locale: string } }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const t = useTranslations()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const locale = params.locale
// 統帥鐵律: 使用真實數據 Hook禁止假數據
const { metrics: pulseMetrics, isLoading: isPulseLoading, error: pulseError } = useGlobalPulseMetrics({
pollInterval: 30000, // 30 秒輪詢
const { metrics: pulseMetrics } = useGlobalPulseMetrics({
pollInterval: 30000,
enablePolling: true,
})
// Phase 7: 真實 Incident 數據
const {
incidents,
pendingApprovals: _pendingApprovals,
pendingApprovals,
isLoading: isIncidentsLoading,
error: incidentsError,
} = useIncidents({
pollInterval: 15000, // 15 秒輪詢
pollInterval: 15000,
enablePolling: true,
})
return (
<AppLayout locale={locale}>
{/* Page Title */}
<div className="mb-6">
<h2 className="font-heading text-2xl font-bold text-nothing-black">
{t('dashboard.title')}
</h2>
<p className="mt-1 text-sm text-nothing-gray-500">
{t('dashboard.subtitle')}
</p>
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
background: '#f5f4ed',
fontFamily: 'Inter, system-ui, sans-serif',
overflow: 'hidden',
}}>
{/* ── Metrics Strip (50px) ────────────────────────────────────── */}
<div style={{
background: '#faf9f3',
borderBottom: '0.5px solid #e0ddd4',
height: 50,
padding: '0 20px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
gap: 0,
}}>
{[
{ label: '活躍事件', value: incidents?.length ?? '--', sub: incidents?.filter((i) => i.severity === 'P0').length ? `+${incidents.filter((i) => i.severity === 'P0').length} P0` : '穩定' },
{ label: '服務健康', value: `${pulseMetrics?.length ?? '--'}/${pulseMetrics?.length ?? '--'}`, sub: '正常' },
{ label: '待簽核', value: pendingApprovals ?? '--', sub: '等待授權' },
{ label: '今日事件', value: incidents?.length ?? '--', sub: '' },
{ label: '自動處置率', value: '--', sub: '' },
{ label: 'MTTR 均值', value: '--', sub: '' },
].map((m, i, arr) => (
<div key={i} style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
paddingRight: 14,
borderRight: i < arr.length - 1 ? '0.5px solid #e8e5dc' : 'none',
marginRight: i < arr.length - 1 ? 14 : 0,
}}>
<span style={{ fontSize: 8, color: '#b0ad9f', letterSpacing: '2px', textTransform: 'uppercase' }}>
{m.label}
</span>
<span style={{ fontSize: 16, fontWeight: 700, color: '#141413', lineHeight: 1.1, fontFamily: 'monospace' }}>
{String(m.value)}
</span>
{m.sub && <span style={{ fontSize: 8, color: '#87867f' }}>{m.sub}</span>}
</div>
))}
</div>
{/* Main Grid: 左側 70% | 右側 30% */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_380px] gap-6">
{/* ── 主體 3 欄 ────────────────────────────────────────────────── */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* =========================================================== */}
{/* Left Column (70%): 系統脈搏 + 系統狀態 + 事件流 */}
{/* =========================================================== */}
<div className="space-y-6">
{/* Global Pulse Chart - 系統心跳 (真實血脈) */}
<DataPincerPanel
title={t('dashboard.globalPulse')}
status={pulseError ? 'critical' : 'healthy'}
>
{isPulseLoading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-nothing-gray-300 border-t-claw-blue rounded-full animate-spin" />
<span className="ml-3 font-mono text-sm text-nothing-gray-400">
{t('dashboard.loadingMetrics')}
</span>
</div>
) : pulseError ? (
<div className="flex flex-col items-center justify-center py-8 text-status-critical">
<span className="font-mono text-sm">{t('dashboard.metricsError')}</span>
<span className="font-mono text-xs text-nothing-gray-400 mt-1">{pulseError}</span>
</div>
) : (
<GlobalPulseChart metrics={pulseMetrics} />
{/* ── Feed活躍事件flex:1──────────────────────────────── */}
<div style={{
flex: 1,
borderRight: '0.5px solid #e0ddd4',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
{/* Feed 標題列 */}
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
background: '#faf9f3',
}}>
<span style={{ fontSize: 10, fontWeight: 700, color: '#141413', letterSpacing: '1px', textTransform: 'uppercase', fontFamily: 'monospace' }}>
</span>
{(incidents?.length ?? 0) > 0 && (
<span style={{
fontSize: 9, background: 'rgba(217,119,87,0.1)', color: '#a04010',
padding: '2px 8px', fontWeight: 700,
border: '0.5px solid rgba(217,119,87,0.25)', borderRadius: 10,
}}>
{incidents?.length}
</span>
)}
</DataPincerPanel>
</div>
{/* System Status Section */}
<DataPincerPanel
title={t('dashboard.systemStatus')}
status="healthy"
>
<LiveDashboard locale={locale} />
</DataPincerPanel>
{/* Active Incidents Section (Phase 7: 真實血脈 + Phase 6.5b 雙態卡片) */}
<DataPincerPanel
title={t('incident.activeIncidents')}
status={(incidents?.length || 0) > 0 ? 'critical' : 'healthy'}
>
{/* Feed 內容 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{isIncidentsLoading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-nothing-gray-300 border-t-claw-blue rounded-full animate-spin" />
<span className="ml-3 font-mono text-sm text-nothing-gray-400">
{t('common.loading')}
</span>
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<span style={{ fontSize: 10, color: '#b0ad9f' }}>...</span>
</div>
) : incidentsError ? (
<div className="flex flex-col items-center justify-center py-8 text-status-critical">
<AlertTriangle className="w-5 h-5 mb-2" />
<span className="font-mono text-sm">{incidentsError}</span>
</div>
) : (incidents?.length || 0) === 0 ? (
/* Nothing.tech 風格平靜態: 系統穩定 */
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-3 h-3 rounded-full bg-green-500 mb-4 animate-pulse" />
<p className="font-mono text-sm text-neutral-600">
{t('incident.systemStable', { defaultValue: '系統穩定' })}
</p>
<p className="font-mono text-xs text-neutral-400 mt-1">
0 {t('incident.activeAlerts', { defaultValue: '活躍異常' })}
</p>
<div style={{ padding: 16, fontSize: 10, color: '#cc2200' }}>{incidentsError}</div>
) : (incidents?.length ?? 0) === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#22C55E' }} />
<span style={{ fontSize: 10, color: '#87867f' }}> · 0 </span>
</div>
) : (
<div className="space-y-4">
{/* Phase 6.5b: 雙態戰情室卡片 (脈衝雷達 + Tier 決策層) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(incidents || []).map((incident, index) => {
const dualProps = mapToDualState(incident)
return (
<DualStateIncidentCard
key={`dual-${incident?.incident_id || index}`}
{...dualProps}
/>
)
})}
</div>
</div>
incidents?.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
decision={incident.decision}
/>
))
)}
</DataPincerPanel>
{/* OpenClaw Thinking Terminal (Phase 7: 決策鏈視覺化 - 真實 AI 資料) */}
<DataPincerPanel
title="OpenClaw Terminal"
status={(incidents?.length || 0) > 0 ? "thinking" : "healthy"}
>
<ThinkingTerminal
decisionChain={
(incidents?.length || 0) > 0
? convertToDecisionChain(incidents?.[0])
: null
}
incidentId={(incidents?.length || 0) > 0 ? incidents?.[0]?.incident_id : undefined}
autoPlay={(incidents?.length || 0) > 0}
maxHeight="300px"
/>
</DataPincerPanel>
</div>
</div>
{/* =========================================================== */}
{/* Right Column (30%): AI 狀態機 (OpenClaw + ThinkingStream + ApprovalCard) */}
{/* =========================================================== */}
<div className="lg:sticky lg:top-20 lg:self-start">
<DataPincerPanel
title={t('dashboard.aiAgent')}
status="thinking"
>
<OpenClawStateMachine demoMode={false} />
</DataPincerPanel>
</div>
{/* ── Right PanelAI + Infraflex:1─────────────────────── */}
<div style={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* 7.1 NemoClaw + Reasoning Stream */}
<div style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 10, fontWeight: 700, color: '#141413',
letterSpacing: '1px', textTransform: 'uppercase',
fontFamily: 'monospace', background: '#faf9f3',
}}>
OpenClaw
</div>
<OpenClawPanel
status={
(incidents?.length ?? 0) > 0 ? 'analyzing' : 'patrolling'
}
/>
</div>
{/* 7.3 基礎架構 2×2 Grid */}
<div style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 10, fontWeight: 700, color: '#141413',
letterSpacing: '1px', textTransform: 'uppercase',
fontFamily: 'monospace', background: '#faf9f3',
}}>
</div>
<HostGrid hosts={[]} />
</div>
</div>
</div>
</div>
{/* =========================================================== */}
{/* Footer */}
{/* =========================================================== */}
<footer className="mt-12 pt-6 border-t border-nothing-gray-200">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-nothing-gray-500">
{t('footer.copyright')}
</p>
<p className="text-sm text-nothing-gray-400 font-mono">
{t('footer.poweredBy')} v1.0.0
</p>
</div>
</footer>
</AppLayout>
</div>
)
}