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:
@@ -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 Panel:AI + Infra(flex: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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user