feat: add all application source code

- apps/api: FastAPI backend with Dockerfile
- apps/web: Next.js frontend with Dockerfile
- apps/sensor: Signal collection agent
- packages: shared packages

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View File

@@ -0,0 +1,521 @@
'use client'
/**
* Action Log Page - K8s 操作稽核日誌
* ==================================
* Phase 4: 行動日誌介面
*
* Features:
* - 真實 API 數據 (GET /api/v1/audit-logs)
* - 分頁顯示
* - 統計概覽
* - 操作類型、狀態篩選
* - 執行時間、耗時、簽核者資訊
*
* i18n: 100% next-intl零硬編碼
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { DataPincerPanel, DataPincerCard } from '@/components/cyber'
import { cn } from '@/lib/utils'
import {
FileText,
CheckCircle2,
XCircle,
Clock,
Activity,
ChevronLeft,
ChevronRight,
RefreshCw,
AlertCircle,
Zap,
TrendingUp,
} from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface AuditLog {
id: string
approval_id: string
operation_type: string
target_resource: string
namespace: string
success: boolean
error_message: string | null
k8s_response: Record<string, unknown> | null
executed_by: string
execution_duration_ms: number | null
dry_run_passed: boolean
dry_run_message: string | null
created_at: string
}
interface AuditLogListResponse {
count: number
logs: AuditLog[]
page: number
page_size: number
total_pages: number
}
interface AuditStats {
total_executions: number
success_count: number
failure_count: number
success_rate: number
avg_duration_ms: number | null
by_operation_type: Record<string, number>
by_namespace: Record<string, number>
last_24h_count: number
}
// =============================================================================
// API Helper
// =============================================================================
const getApiBaseUrl = (): string => {
if (typeof window === 'undefined') return ''
// 統帥鐵律: 禁止任何 Fallback IP
const url = process.env.NEXT_PUBLIC_API_URL
if (!url) {
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
return ''
}
return url
}
// =============================================================================
// Stat Card Component
// =============================================================================
function StatCard({
icon: Icon,
label,
value,
subValue,
variant = 'default',
}: {
icon: typeof Activity
label: string
value: string | number
subValue?: string
variant?: 'default' | 'success' | 'warning'
}) {
return (
<div
className={cn(
'rounded-lg border p-4',
'bg-white/50 backdrop-blur-sm',
variant === 'success' && 'border-status-healthy/30',
variant === 'warning' && 'border-status-warning/30',
variant === 'default' && 'border-nothing-gray-200'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center',
variant === 'success' && 'bg-status-healthy/10',
variant === 'warning' && 'bg-status-warning/10',
variant === 'default' && 'bg-nothing-gray-100'
)}
>
<Icon
className={cn(
'w-5 h-5',
variant === 'success' && 'text-status-healthy',
variant === 'warning' && 'text-status-warning',
variant === 'default' && 'text-nothing-gray-600'
)}
/>
</div>
<div>
<p className="text-xs text-nothing-gray-500 font-mono uppercase">
{label}
</p>
<p className="text-xl font-bold text-nothing-black">{value}</p>
{subValue && (
<p className="text-[10px] text-nothing-gray-400 font-mono">
{subValue}
</p>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export default function ActionLogPage({
params,
}: {
params: { locale: string }
}) {
const t = useTranslations()
const locale = params.locale
// State
const [logs, setLogs] = useState<AuditLog[]>([])
const [stats, setStats] = useState<AuditStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
// ==========================================================================
// Fetch Audit Logs
// ==========================================================================
const fetchLogs = useCallback(async (pageNum: number) => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
setIsLoading(true)
setError(null)
try {
const response = await fetch(
`${apiBaseUrl}/api/v1/audit-logs?page=${pageNum}&page_size=10`,
{ headers: { 'Content-Type': 'application/json' } }
)
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
const data: AuditLogListResponse = await response.json()
setLogs(data.logs)
setPage(data.page)
setTotalPages(data.total_pages)
setTotalCount(data.count)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
console.error('[ActionLog] Fetch error:', message)
} finally {
setIsLoading(false)
}
}, [])
// ==========================================================================
// Fetch Stats
// ==========================================================================
const fetchStats = useCallback(async () => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
try {
const response = await fetch(`${apiBaseUrl}/api/v1/audit-logs/stats`, {
headers: { 'Content-Type': 'application/json' },
})
if (response.ok) {
const data: AuditStats = await response.json()
setStats(data)
}
} catch (err) {
console.error('[ActionLog] Stats fetch error:', err)
}
}, [])
// ==========================================================================
// Initial Fetch
// ==========================================================================
useEffect(() => {
fetchLogs(1)
fetchStats()
}, [fetchLogs, fetchStats])
// ==========================================================================
// Pagination Handlers
// ==========================================================================
const handlePrevPage = () => {
if (page > 1) {
fetchLogs(page - 1)
}
}
const handleNextPage = () => {
if (page < totalPages) {
fetchLogs(page + 1)
}
}
// ==========================================================================
// Format Helpers
// ==========================================================================
const formatDate = (isoString: string) => {
try {
const date = new Date(isoString)
return date.toLocaleString(locale === 'zh-TW' ? 'zh-TW' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return isoString
}
}
const formatDuration = (ms: number | null) => {
if (ms === null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
// ==========================================================================
// Render
// ==========================================================================
return (
<AppLayout locale={locale}>
{/* Page Title */}
<div className="mb-6">
<h2 className="font-heading text-2xl font-bold text-nothing-black">
{t('actionLog.title')}
</h2>
<p className="mt-1 text-sm text-nothing-gray-500">
{t('actionLog.subtitle')}
</p>
</div>
{/* Stats Overview */}
{stats && (
<div className="mb-6 grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={Activity}
label={t('actionLog.stats.total')}
value={stats.total_executions}
/>
<StatCard
icon={TrendingUp}
label={t('actionLog.stats.successRate')}
value={`${stats.success_rate}%`}
variant="success"
/>
<StatCard
icon={Clock}
label={t('actionLog.stats.avgDuration')}
value={stats.avg_duration_ms ? `${Math.round(stats.avg_duration_ms)}ms` : '-'}
/>
<StatCard
icon={Zap}
label={t('actionLog.stats.last24h')}
value={stats.last_24h_count}
variant="warning"
/>
</div>
)}
{/* Main Content */}
<DataPincerPanel title={t('actionLog.title')} status="healthy">
{/* Toolbar */}
<div className="flex items-center justify-between mb-4 px-1">
<div className="text-sm text-nothing-gray-500 font-mono">
{totalCount > 0
? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s`
: ''}
</div>
<button
onClick={() => {
fetchLogs(page)
fetchStats()
}}
disabled={isLoading}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-mono',
'bg-nothing-gray-100 text-nothing-gray-600',
'hover:bg-nothing-gray-200 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<RefreshCw
className={cn('w-3 h-3', isLoading && 'animate-spin')}
/>
{t('common.refresh')}
</button>
</div>
{/* Error State */}
{error && (
<div className="flex items-center gap-2 p-4 mb-4 rounded-lg bg-status-critical/10 border border-status-critical/20">
<AlertCircle className="w-4 h-4 text-status-critical" />
<span className="text-sm text-status-critical font-mono">
{t('actionLog.fetchError')}: {error}
</span>
</div>
)}
{/* Loading State */}
{isLoading && logs.length === 0 && (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-nothing-gray-400 animate-spin" />
<span className="ml-2 text-nothing-gray-400 font-mono">
{t('actionLog.loading')}
</span>
</div>
)}
{/* Empty State */}
{!isLoading && logs.length === 0 && !error && (
<div className="text-center py-12">
<FileText className="w-12 h-12 mx-auto mb-3 text-nothing-gray-300" />
<p className="text-nothing-gray-400 font-mono">
{t('actionLog.noLogs')}
</p>
</div>
)}
{/* Logs Table */}
{logs.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-nothing-gray-200">
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.time')}
</th>
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.operation')}
</th>
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.target')}
</th>
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.namespace')}
</th>
<th className="px-3 py-2 text-center font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.status')}
</th>
<th className="px-3 py-2 text-right font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.duration')}
</th>
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
{t('actionLog.columns.executor')}
</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr
key={log.id}
className="border-b border-nothing-gray-100 hover:bg-nothing-gray-50/50 transition-colors"
>
<td className="px-3 py-3 font-mono text-xs text-nothing-gray-600">
{formatDate(log.created_at)}
</td>
<td className="px-3 py-3">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
log.operation_type === 'DELETE_POD' &&
'bg-status-critical/10 text-status-critical',
log.operation_type === 'RESTART_DEPLOYMENT' &&
'bg-status-warning/10 text-status-warning',
log.operation_type === 'SCALE_DEPLOYMENT' &&
'bg-claw-blue/10 text-claw-blue'
)}
>
{t(`actionLog.operations.${log.operation_type}` as never) ||
log.operation_type}
</span>
</td>
<td className="px-3 py-3 font-mono text-xs text-nothing-black max-w-[200px] truncate">
{log.target_resource}
</td>
<td className="px-3 py-3 font-mono text-xs text-nothing-gray-600">
{log.namespace}
</td>
<td className="px-3 py-3 text-center">
{log.success ? (
<span className="inline-flex items-center gap-1 text-status-healthy">
<CheckCircle2 className="w-4 h-4" />
<span className="text-[10px] font-mono uppercase">
{t('actionLog.status.success')}
</span>
</span>
) : (
<span className="inline-flex items-center gap-1 text-status-critical">
<XCircle className="w-4 h-4" />
<span className="text-[10px] font-mono uppercase">
{t('actionLog.status.failure')}
</span>
</span>
)}
</td>
<td className="px-3 py-3 text-right font-mono text-xs text-nothing-gray-600">
{formatDuration(log.execution_duration_ms)}
</td>
<td className="px-3 py-3 font-mono text-xs text-nothing-gray-600">
{log.executed_by}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-nothing-gray-500 font-mono">
{t('actionLog.pagination.page', {
current: page,
total: totalPages,
})}
</span>
<div className="flex items-center gap-2">
<button
onClick={handlePrevPage}
disabled={page <= 1 || isLoading}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-mono',
'border border-nothing-gray-200',
'hover:bg-nothing-gray-50 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<ChevronLeft className="w-3 h-3" />
{t('actionLog.pagination.prev')}
</button>
<button
onClick={handleNextPage}
disabled={page >= totalPages || isLoading}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-mono',
'border border-nothing-gray-200',
'hover:bg-nothing-gray-50 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{t('actionLog.pagination.next')}
<ChevronRight className="w-3 h-3" />
</button>
</div>
</div>
)}
</DataPincerPanel>
{/* 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>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
/**
* Demo Page - 戰情室
* ==================
* Phase 1: 視覺靈魂注入 - Lab-White Style
*
* - NemoClaw 3D 陶瓷機械爪視覺化
* - VT323 點陣字體品牌識別
* - LiveDashboard: SSE 串流真實主機數據
* - HITLSection: AI 思考流 → 動態卡片對接
*/
import { useCallback, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { LiveDashboard } from '@/components/dashboard/live-dashboard'
import { HITLSection } from '@/components/ai'
// =============================================================================
// API Configuration (統帥鐵律: 禁止任何 Fallback IP)
// =============================================================================
const getApiBaseUrl = (): string => {
if (typeof window === 'undefined') return ''
const url = process.env.NEXT_PUBLIC_API_URL
if (!url) {
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
return ''
}
return url
}
const API_BASE_URL = getApiBaseUrl()
// =============================================================================
// Create Test Approval (i18n aware)
// =============================================================================
interface CreateApprovalConfig {
action: string
description: string
data_impact: string
rbacLabel: string
syntaxLabel: string
backupLabel: string
backupMessage: string
okMessage: string
}
async function createTestApprovalWithConfig(
riskLevel: 'low' | 'medium' | 'critical',
config: CreateApprovalConfig
) {
const response = await fetch(`${API_BASE_URL}/api/v1/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: config.action,
description: config.description,
risk_level: riskLevel,
blast_radius: {
affected_pods: riskLevel === 'critical' ? 0 : 3,
estimated_downtime: riskLevel === 'low' ? '0' : '~2 min',
related_services: ['auth-service', 'api-gateway', 'user-service'],
data_impact: config.data_impact,
},
dry_run_checks: [
{ name: config.rbacLabel, passed: true, message: 'cluster-admin' },
{ name: config.syntaxLabel, passed: true },
{ name: config.backupLabel, passed: riskLevel !== 'critical', message: riskLevel === 'critical' ? config.backupMessage : config.okMessage },
],
requested_by: 'OpenClaw',
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
// =============================================================================
// Main Demo Page
// =============================================================================
export default function DemoPage({ params }: { params: { locale: string } }) {
const t = useTranslations('demo')
const tApproval = useTranslations('approval')
const tMock = useTranslations('mockData')
const tDryRun = useTranslations('dryRun')
const locale = params.locale
const [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
// i18n-aware approval creation configs
const approvalConfigs = {
low: {
action: tMock('testActions.lowAction'),
description: tMock('testActions.lowDesc'),
data_impact: 'none',
rbacLabel: tDryRun('rbac'),
syntaxLabel: tDryRun('syntax'),
backupLabel: tDryRun('backupAvailable'),
backupMessage: tDryRun('noRecentBackup'),
okMessage: tDryRun('ok'),
},
medium: {
action: tMock('testActions.mediumAction'),
description: tMock('testActions.mediumDesc'),
data_impact: 'none',
rbacLabel: tDryRun('rbac'),
syntaxLabel: tDryRun('syntax'),
backupLabel: tDryRun('backupAvailable'),
backupMessage: tDryRun('noRecentBackup'),
okMessage: tDryRun('ok'),
},
critical: {
action: tMock('testActions.criticalAction'),
description: tMock('testActions.criticalDesc'),
data_impact: 'destructive',
rbacLabel: tDryRun('rbac'),
syntaxLabel: tDryRun('syntax'),
backupLabel: tDryRun('backupAvailable'),
backupMessage: tDryRun('noRecentBackup'),
okMessage: tDryRun('ok'),
},
}
const handleCreateApproval = useCallback(async (riskLevel: 'low' | 'medium' | 'critical') => {
setIsCreating(true)
setCreateError(null)
try {
const result = await createTestApprovalWithConfig(riskLevel, approvalConfigs[riskLevel])
console.log('[Demo] Created approval:', result)
} catch (err) {
setCreateError(`${tApproval('fetchError')}: ${err}`)
console.error('[Demo] Create approval failed:', err)
} finally {
setIsCreating(false)
}
}, [approvalConfigs, tApproval])
return (
<AppLayout locale={locale}>
<div className="max-w-7xl mx-auto space-y-8">
{/* Page Title - Dot Matrix Style */}
<header className="text-center py-6">
<h1 className="font-dot-matrix text-4xl text-nothing-gray-800 tracking-wider mb-2">
AWOOOI
</h1>
<p className="font-dot-matrix text-lg text-claw-blue">
AI Sees. AI Acts. You Approve.
</p>
</header>
{/* Live Dashboard - Real SSE Data */}
<section>
<h2 className="font-dot-matrix text-2xl text-nothing-gray-700 mb-4 tracking-wide">
{t('liveDashboard')}
</h2>
<LiveDashboard locale={locale} />
</section>
{/* HITL Section - NemoClaw + Approval Cards */}
<HITLSection locale={locale} />
{/* Error Display */}
{createError && (
<div className="p-3 bg-status-critical/10 border border-status-critical/30 rounded-lg">
<p className="text-sm text-status-critical font-mono">{createError}</p>
</div>
)}
</div>
</AppLayout>
)
}

View File

@@ -0,0 +1,71 @@
import type { Metadata } from 'next'
import { Inter, JetBrains_Mono, VT323 } from 'next/font/google'
import { notFound } from 'next/navigation'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { routing, type Locale } from '@/i18n/routing'
import '../globals.css'
import { Providers } from '../providers'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
})
// VT323 點陣字體 - 品牌與 AI 狀態專用
const vt323 = VT323({
weight: '400',
subsets: ['latin'],
variable: '--font-dot-matrix',
})
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export async function generateMetadata({
params: { locale },
}: {
params: { locale: Locale }
}): Promise<Metadata> {
const messages = await getMessages()
const metadata = messages.metadata as { title: string; description: string }
return {
title: metadata?.title || 'AWOOOI',
description: metadata?.description || 'AI-Powered Intelligent Operations Platform',
}
}
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode
params: { locale: string }
}) {
// 驗證語系
if (!routing.locales.includes(locale as Locale)) {
notFound()
}
// 取得翻譯訊息
const messages = await getMessages()
return (
<html lang={locale}>
<body
className={`${inter.variable} ${jetbrainsMono.variable} ${vt323.variable} font-body bg-nothing-gray-50 text-nothing-black antialiased`}
>
<NextIntlClientProvider messages={messages}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
/**
* AWOOOI 全局戰情室 (Global War Room)
* ====================================
* Phase 2: 完整 AppLayout 整合
*
* 佈局結構:
* - 左側側邊欄: 導航選單
* - 主內容: 70/30 Grid (系統狀態 + AI 面板)
*
* 視覺規範:
* - awoooi-glass 白玻璃毛玻璃
* - DataPincer 數據鉗容器
* - 點陣紋理背景
*
* i18n: 100% next-intl零硬編碼
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { LiveDashboard } from '@/components/dashboard/live-dashboard'
import { DataPincerCard, 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 { IncidentCard, IncidentCardGrid, IncidentEmptyState, ThinkingTerminal, DEMO_DECISION_CHAIN } from '@/components/incident'
import { Activity, AlertTriangle } from 'lucide-react'
// =============================================================================
// Main Page
// =============================================================================
export default function Home({ params }: { params: { locale: string } }) {
const t = useTranslations()
const locale = params.locale
// 統帥鐵律: 使用真實數據 Hook禁止假數據
const { metrics: pulseMetrics, isLoading: isPulseLoading, error: pulseError } = useGlobalPulseMetrics({
pollInterval: 30000, // 30 秒輪詢
enablePolling: true,
})
// Phase 7: 真實 Incident 數據
const {
incidents,
pendingApprovals,
isLoading: isIncidentsLoading,
error: incidentsError,
} = useIncidents({
pollInterval: 15000, // 15 秒輪詢
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>
{/* Main Grid: 左側 70% | 右側 30% */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_380px] gap-6">
{/* =========================================================== */}
{/* 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} />
)}
</DataPincerPanel>
{/* System Status Section */}
<DataPincerPanel
title={t('dashboard.systemStatus')}
status="healthy"
>
<LiveDashboard locale={locale} />
</DataPincerPanel>
{/* Active Incidents Section (Phase 7: 真實血脈) */}
<DataPincerPanel
title={t('incident.activeIncidents')}
status={incidents.length > 0 ? 'critical' : 'healthy'}
>
{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>
) : 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 ? (
<IncidentEmptyState />
) : (
<IncidentCardGrid>
{incidents.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
/>
))}
</IncidentCardGrid>
)}
</DataPincerPanel>
{/* OpenClaw Thinking Terminal (Phase 7: 決策鏈視覺化) */}
<DataPincerPanel
title="OpenClaw Terminal"
status="thinking"
>
<ThinkingTerminal
decisionChain={incidents.length > 0 ? DEMO_DECISION_CHAIN : null}
incidentId={incidents.length > 0 ? incidents[0].incident_id : undefined}
autoPlay={incidents.length > 0}
maxHeight="300px"
/>
</DataPincerPanel>
</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>
</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>
)
}

View File

@@ -0,0 +1,281 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ==================== CSS Properties for Animations ==================== */
/* Border Beam Animation Variable */
@property --start {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
/* Shimmer Position */
@property --shimmer-position {
syntax: '<percentage>';
initial-value: 0%;
inherits: false;
}
:root {
--font-mono: 'JetBrains Mono', monospace;
--font-dot-matrix: 'DSEG7-Classic', 'JetBrains Mono', monospace;
}
/* ==================== Nothing Dot Matrix Font ==================== */
/* DSEG7-Classic - Digital 7-Segment Display Font */
@font-face {
font-family: 'DSEG7-Classic';
src: url('/fonts/DSEG7Classic-Bold.woff2') format('woff2'),
url('/fonts/DSEG7Classic-Bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DSEG7-Classic';
src: url('/fonts/DSEG7Classic-Regular.woff2') format('woff2'),
url('/fonts/DSEG7Classic-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* ==================== Nothing.tech Light Theme Base ==================== */
@layer base {
html {
@apply antialiased;
}
body {
@apply bg-nothing-gray-50 text-nothing-gray-900;
font-feature-settings: 'liga' 1, 'calt' 1;
}
/* 點陣紋理背景 (Dot Matrix Pattern) */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image: radial-gradient(
circle at center,
rgba(0, 0, 0, 0.04) 1px,
transparent 1px
);
background-size: 24px 24px;
}
/* 主內容層級 */
#__next,
main {
position: relative;
z-index: 1;
}
/* 滾動條 - 極簡風格 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-nothing-gray-300 rounded-full;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-nothing-gray-400;
}
/* 選取樣式 */
::selection {
@apply bg-nothing-gray-900 text-nothing-white;
}
}
/* ==================== Glass Components ==================== */
@layer components {
/* 主要玻璃卡片 */
.glass-card {
@apply bg-white/70 backdrop-blur-glass border border-black/[0.08] rounded-glass shadow-glass;
transition: all 0.2s ease;
}
.glass-card:hover {
@apply bg-white/80 shadow-glass-hover border-black/[0.12];
}
/* 頂部導航 */
.glass-header {
@apply bg-white/85 backdrop-blur-[20px] border-b border-black/[0.06];
}
/* AI Copilot 面板 */
.glass-copilot {
@apply bg-nothing-gray-50/90 backdrop-blur-[20px] border border-status-thinking/20 rounded-glass;
box-shadow: 0 4px 24px rgba(139, 92, 246, 0.1);
}
/* 狀態指示點 */
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-dot-healthy {
@apply status-dot bg-status-healthy;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot-syncing {
@apply status-dot bg-status-syncing animate-pulse;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
}
.status-dot-warning {
@apply status-dot bg-status-warning;
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
}
.status-dot-critical {
@apply status-dot bg-status-critical animate-breathe;
box-shadow: 0 0 8px rgba(255, 51, 0, 0.5);
}
.status-dot-idle {
@apply status-dot bg-status-idle;
}
.status-dot-thinking {
@apply status-dot bg-status-thinking animate-breathe;
box-shadow: 0 0 8px rgba(139, 92, 246, 0.5);
}
/* 節點標籤 */
.node-badge {
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-button;
@apply bg-nothing-gray-100 text-nothing-gray-700 font-mono text-xs;
}
/* 服務列表項 */
.service-item {
@apply flex items-center justify-between py-2 px-3 rounded-button;
@apply hover:bg-nothing-gray-100/50 transition-colors;
}
/* 按鈕 - 主要 */
.btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-button;
@apply bg-nothing-gray-900 text-nothing-white font-medium text-sm;
@apply hover:bg-nothing-gray-800 transition-colors;
@apply focus:outline-none focus:ring-2 focus:ring-nothing-gray-400 focus:ring-offset-2;
}
/* 按鈕 - 次要 */
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-button;
@apply bg-nothing-gray-100 text-nothing-gray-800 font-medium text-sm;
@apply hover:bg-nothing-gray-200 transition-colors;
@apply focus:outline-none focus:ring-2 focus:ring-nothing-gray-300 focus:ring-offset-2;
}
/* 按鈕 - 警告 */
.btn-warning {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-button;
@apply bg-status-critical/10 text-status-critical font-medium text-sm;
@apply hover:bg-status-critical/20 transition-colors;
@apply focus:outline-none focus:ring-2 focus:ring-status-critical/30 focus:ring-offset-2;
}
/* AI 思考指示器 */
.ai-indicator {
@apply animate-breathe;
}
/* 掃描線動畫 */
.scan-line {
@apply absolute inset-0 overflow-hidden;
}
.scan-line::after {
content: '';
@apply absolute inset-y-0 w-1/3;
background: linear-gradient(
90deg,
transparent,
rgba(139, 92, 246, 0.1),
transparent
);
animation: scan 2s linear infinite;
}
}
/* ==================== Utilities ==================== */
@layer utilities {
/* Nothing.tech 高對比墨水色 (禁止 muted-foreground) */
.text-ink {
color: #111111;
}
.text-ink-secondary {
color: #525252;
}
/* Dot Matrix Display Numbers (靈魂注入) */
.font-dot-matrix {
font-family: var(--font-dot-matrix);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
/* 巨型數字顯示 (2x size) */
.dot-matrix-display {
font-family: var(--font-dot-matrix);
font-size: 2.5rem;
line-height: 1;
font-weight: 700;
color: #111111;
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}
.dot-matrix-display-sm {
font-family: var(--font-dot-matrix);
font-size: 1.5rem;
line-height: 1.2;
font-weight: 700;
color: #111111;
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
}
/* 文字漸層 */
.text-gradient {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, #111111 0%, #525252 100%);
}
/* 網格背景 (替代方案) */
.bg-grid {
background-image:
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
background-size: 32px 32px;
}
/* 隱藏滾動條但保留功能 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}

View File

@@ -0,0 +1,28 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { ToastProvider, ToastInitializer } from '@/components/ui/toast'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ToastInitializer />
{children}
</ToastProvider>
</QueryClientProvider>
)
}