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,356 @@
'use client'
/**
* ApprovalCard - HITL 授權卡片
* ============================
* Phase 2.1: 人機協作核心組件
* 設計風格: Nothing.tech (毛玻璃 + 極簡 + 風險色彩)
*
* i18n: 100% 使用 useTranslations禁止任何寫死字串
* 符合 AWOOOI 專案開發憲法 v2.0
*/
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
// ==================== Types ====================
type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
interface DryRunCheck {
name: string
passed: boolean
message?: string
}
interface BlastRadius {
affectedPods: number
estimatedDowntime: string
relatedServices: string[]
dataImpact: 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE'
}
interface ApprovalRequest {
id: string
action: string
description: string
riskLevel: RiskLevel
blastRadius: BlastRadius
dryRunChecks: DryRunCheck[]
requiredSignatures: number
currentSignatures: number
requestedBy: string
requestedAt: string
}
interface ApprovalCardProps {
request: ApprovalRequest
onApprove?: (id: string) => void
onReject?: (id: string) => void
className?: string
}
// ==================== Config ====================
const riskStyleConfig: Record<
RiskLevel,
{ color: string; bgColor: string; borderColor: string }
> = {
low: {
color: 'text-status-healthy',
bgColor: 'bg-status-healthy/10',
borderColor: 'border-status-healthy/30',
},
medium: {
color: 'text-status-warning',
bgColor: 'bg-status-warning/10',
borderColor: 'border-status-warning/30',
},
high: {
color: 'text-nothing-red',
bgColor: 'bg-nothing-red/10',
borderColor: 'border-nothing-red/30',
},
critical: {
color: 'text-nothing-red',
bgColor: 'bg-nothing-red/20',
borderColor: 'border-nothing-red/50',
},
}
const dataImpactStyleConfig: Record<
BlastRadius['dataImpact'],
{ color: string }
> = {
NONE: { color: 'text-nothing-gray-400' },
READ_ONLY: { color: 'text-status-healthy' },
WRITE: { color: 'text-status-warning' },
DESTRUCTIVE: { color: 'text-nothing-red' },
}
// ==================== Component ====================
export function ApprovalCard({
request,
onApprove,
onReject,
className,
}: ApprovalCardProps) {
const t = useTranslations('approval')
const tRisk = useTranslations('risk')
const tBlast = useTranslations('blastRadius')
const tDryRun = useTranslations('dryRun')
const [confirmStep, setConfirmStep] = useState<0 | 1 | 2>(0)
const riskStyle = riskStyleConfig[request.riskLevel]
const dataImpactStyle = dataImpactStyleConfig[request.blastRadius?.dataImpact ?? 'NONE']
const allChecksPassed = request.dryRunChecks?.every((c) => c.passed) ?? true
const needsMoreSignatures = request.currentSignatures < request.requiredSignatures
const isDestructive = request.blastRadius?.dataImpact === 'DESTRUCTIVE'
// i18n: 風險等級標籤
const riskLabel = tRisk(request.riskLevel)
// i18n: 資料影響標籤
const dataImpactLabel = tBlast(
request.blastRadius?.dataImpact === 'READ_ONLY' ? 'readOnly' :
request.blastRadius?.dataImpact === 'WRITE' ? 'write' :
request.blastRadius?.dataImpact === 'DESTRUCTIVE' ? 'destructive' : 'none'
)
const handleApprove = () => {
if (isDestructive && confirmStep < 2) {
setConfirmStep((prev) => Math.min(prev + 1, 2) as 0 | 1 | 2)
if (confirmStep === 1) {
onApprove?.(request.id)
setConfirmStep(0)
}
} else {
onApprove?.(request.id)
}
}
const handleReject = () => {
setConfirmStep(0)
onReject?.(request.id)
}
return (
<div className={cn('glass-panel p-6 space-y-5', className)}>
{/* Header: Risk Badge + Action */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Risk Badge */}
<div
className={cn(
'inline-flex items-center gap-2 px-3 py-1 rounded-full border text-xs font-mono tracking-wider mb-3',
riskStyle.bgColor,
riskStyle.borderColor,
riskStyle.color
)}
>
<span
className={cn(
'w-2 h-2 rounded-full',
request.riskLevel === 'critical' && 'animate-pulse',
request.riskLevel === 'low' && 'bg-status-healthy',
request.riskLevel === 'medium' && 'bg-status-warning',
(request.riskLevel === 'high' || request.riskLevel === 'critical') && 'bg-nothing-red'
)}
/>
{riskLabel}
</div>
{/* Action Title */}
<h3 className="font-display text-display-sm text-nothing-white truncate">
{request.action}
</h3>
<p className="text-nothing-gray-400 text-sm mt-1">
{request.description}
</p>
</div>
{/* Signature Counter */}
<div className="text-right shrink-0">
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
{t('signatures')}
</div>
<div
className={cn(
'font-mono text-lg',
needsMoreSignatures ? 'text-status-warning' : 'text-status-healthy'
)}
>
{request.currentSignatures}/{request.requiredSignatures}
</div>
</div>
</div>
{/* Divider */}
<div className="h-px bg-nothing-gray-800" />
{/* Blast Radius Section */}
<div className="space-y-3">
<h4 className="font-mono text-xs text-nothing-gray-500 tracking-wider">
{tBlast('title')}
</h4>
<div className="grid grid-cols-2 gap-3">
{/* Affected Pods */}
<div className="bg-nothing-gray-900 rounded-card p-3">
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
{tBlast('affectedPods')}
</div>
<div className="font-mono text-lg text-nothing-white">
{request.blastRadius?.affectedPods ?? 0}
</div>
</div>
{/* Estimated Downtime */}
<div className="bg-nothing-gray-900 rounded-card p-3">
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
{tBlast('estimatedDowntime')}
</div>
<div className="font-mono text-lg text-nothing-white">
{request.blastRadius?.estimatedDowntime ?? '0'}
</div>
</div>
{/* Related Services */}
<div className="bg-nothing-gray-900 rounded-card p-3">
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
{tBlast('relatedServices')}
</div>
<div className="flex flex-wrap gap-1 mt-1">
{(request.blastRadius?.relatedServices ?? []).map((service) => (
<span
key={service}
className="px-2 py-0.5 bg-nothing-gray-800 rounded text-xs font-mono text-nothing-gray-300"
>
{service}
</span>
))}
</div>
</div>
{/* Data Impact */}
<div className="bg-nothing-gray-900 rounded-card p-3">
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
{tBlast('dataImpact')}
</div>
<div className={cn('font-mono text-lg', dataImpactStyle.color)}>
{dataImpactLabel}
</div>
</div>
</div>
</div>
{/* Divider */}
<div className="h-px bg-nothing-gray-800" />
{/* Dry-Run Checks */}
<div className="space-y-3">
<h4 className="font-mono text-xs text-nothing-gray-500 tracking-wider">
{tDryRun('validation')}
</h4>
<div className="space-y-2">
{request.dryRunChecks.map((check) => (
<div
key={check.name}
className="flex items-center justify-between bg-nothing-gray-900 rounded-card px-4 py-2"
>
<div className="flex items-center gap-3">
<div
className={cn(
'w-5 h-5 rounded-full flex items-center justify-center text-xs',
check.passed
? 'bg-status-healthy/20 text-status-healthy'
: 'bg-nothing-red/20 text-nothing-red'
)}
>
{check.passed ? '✓' : '✗'}
</div>
<span className="font-mono text-sm text-nothing-white">
{check.name}
</span>
</div>
{check.message && (
<span className="font-mono text-xs text-nothing-gray-500">
{check.message}
</span>
)}
</div>
))}
</div>
</div>
{/* Divider */}
<div className="h-px bg-nothing-gray-800" />
{/* Footer: Meta + Actions */}
<div className="flex items-center justify-between">
{/* Meta */}
<div className="font-mono text-xs text-nothing-gray-600">
<span>{t('requestedBy')} </span>
<span className="text-nothing-gray-400">{request.requestedBy}</span>
<span className="mx-2">|</span>
<span>{request.requestedAt}</span>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3">
{/* Reject Button */}
<button
onClick={handleReject}
className={cn(
'px-5 py-2 rounded-button font-mono text-sm transition-all',
'border border-nothing-gray-700 text-nothing-gray-400',
'hover:border-nothing-red hover:text-nothing-red hover:bg-nothing-red/10'
)}
>
{t('reject')}
</button>
{/* Approve Button - with DESTRUCTIVE protection */}
{isDestructive && confirmStep === 1 ? (
<button
onClick={handleApprove}
disabled={!allChecksPassed}
className={cn(
'px-5 py-2 rounded-button font-mono text-sm transition-all animate-pulse',
'bg-nothing-red text-nothing-white border-2 border-nothing-red',
'hover:bg-nothing-red/80'
)}
>
{t('confirmDestructive')}
</button>
) : (
<button
onClick={handleApprove}
disabled={!allChecksPassed}
className={cn(
'px-5 py-2 rounded-button font-mono text-sm transition-all',
allChecksPassed
? isDestructive
? 'bg-nothing-red/20 text-nothing-red border border-nothing-red/50 hover:bg-nothing-red/30'
: 'bg-nothing-white text-nothing-black hover:bg-nothing-gray-200'
: 'bg-nothing-gray-800 text-nothing-gray-600 cursor-not-allowed'
)}
>
{needsMoreSignatures
? t('needMore', { count: request.requiredSignatures - request.currentSignatures })
: isDestructive
? t('approveDestructive')
: t('approve')}
</button>
)}
</div>
</div>
</div>
)
}
// ==================== Export Types ====================
export type { ApprovalRequest, DryRunCheck, BlastRadius, RiskLevel }

View File

@@ -0,0 +1,237 @@
'use client'
/**
* DataPincer - AWOOOI 靈魂視覺元件
* =================================
* 數據鉗狀態燈AI 代理的核心狀態指示器
* 設計風格Nothing.tech (極簡 + 呼吸燈 + 毛玻璃)
*
* i18n: 100% 使用 useTranslations禁止任何寫死字串
* 符合 AWOOOI 專案開發憲法 v2.0
*
* 狀態映射:
* - idle: 灰色靜止
* - thinking: 琥珀色呼吸
* - executing: 綠色脈動
* - waiting_approval: 紅色呼吸 (需要人類介入)
* - error: 紅色閃爍
*/
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
useAgentStore,
selectAgentStatus,
selectIsThinking,
selectHasError,
type AgentStatus,
} from '@/stores/agent.store'
// ==================== Types ====================
interface DataPincerProps {
size?: 'sm' | 'md' | 'lg' | 'xl'
showLabel?: boolean
showPulse?: boolean
className?: string
}
// ==================== Config ====================
// 狀態樣式配置 (不含 labellabel 由 i18n 提供)
const statusStyleConfig: Record<
AgentStatus,
{
color: string
glowColor: string
labelKey: string // i18n key
animate: boolean
pulseClass: string
}
> = {
idle: {
color: 'bg-nothing-gray-600',
glowColor: 'shadow-none',
labelKey: 'standby',
animate: false,
pulseClass: '',
},
thinking: {
color: 'bg-status-thinking',
glowColor: 'shadow-[0_0_30px_rgba(139,92,246,0.5)]',
labelKey: 'analyzing',
animate: true,
pulseClass: 'animate-breathe',
},
executing: {
color: 'bg-status-healthy',
glowColor: 'shadow-[0_0_30px_rgba(34,197,94,0.5)]',
labelKey: 'executing',
animate: true,
pulseClass: 'animate-pulse-slow',
},
waiting_approval: {
color: 'bg-status-critical',
glowColor: 'shadow-[0_0_30px_rgba(215,25,33,0.6)]',
labelKey: 'waitingApproval',
animate: true,
pulseClass: 'animate-breathe',
},
error: {
color: 'bg-status-critical',
glowColor: 'shadow-[0_0_40px_rgba(215,25,33,0.8)]',
labelKey: 'error',
animate: true,
pulseClass: 'animate-pulse',
},
}
const sizeConfig = {
sm: {
orb: 'w-8 h-8',
ring: 'w-12 h-12',
outerRing: 'w-16 h-16',
label: 'text-xs',
},
md: {
orb: 'w-16 h-16',
ring: 'w-24 h-24',
outerRing: 'w-32 h-32',
label: 'text-sm',
},
lg: {
orb: 'w-24 h-24',
ring: 'w-36 h-36',
outerRing: 'w-48 h-48',
label: 'text-base',
},
xl: {
orb: 'w-32 h-32',
ring: 'w-48 h-48',
outerRing: 'w-64 h-64',
label: 'text-lg',
},
}
// ==================== Component ====================
export function DataPincer({
size = 'lg',
showLabel = true,
showPulse = true,
className,
}: DataPincerProps) {
const t = useTranslations('agent')
const status = useAgentStore(selectAgentStatus)
const hasError = useAgentStore(selectHasError)
const config = statusStyleConfig[status]
const sizeClass = sizeConfig[size]
// i18n: 狀態標籤
const statusLabel = t(config.labelKey)
return (
<div className={cn('relative flex flex-col items-center', className)}>
{/* Outer Pulse Ring (條件渲染) */}
{showPulse && config.animate && (
<div
className={cn(
'absolute rounded-full opacity-20',
sizeClass.outerRing,
config.color,
'animate-ping'
)}
style={{ animationDuration: '2s' }}
/>
)}
{/* Middle Ring - Glass Effect */}
<div
className={cn(
'absolute rounded-full',
'bg-gradient-to-br from-white/10 to-transparent',
'border border-white/10',
'backdrop-blur-sm',
sizeClass.ring,
config.animate && config.pulseClass
)}
/>
{/* Core Orb */}
<div
className={cn(
'relative rounded-full z-10',
'transition-all duration-500',
sizeClass.orb,
config.color,
config.glowColor,
config.animate && config.pulseClass
)}
>
{/* Inner Highlight */}
<div
className={cn(
'absolute inset-2 rounded-full',
'bg-gradient-to-br from-white/30 to-transparent'
)}
/>
{/* Center Dot */}
<div
className={cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'w-1/4 h-1/4 rounded-full',
'bg-white/50'
)}
/>
</div>
{/* Status Label */}
{showLabel && (
<div
className={cn(
'mt-6 font-mono tracking-wider uppercase',
sizeClass.label,
hasError ? 'text-status-critical' : 'text-nothing-gray-400',
config.animate && 'animate-pulse'
)}
>
{statusLabel}
</div>
)}
{/* Decorative Lines (Nothing.tech 風格) */}
<div className="absolute -inset-4 pointer-events-none">
{/* Top Line */}
<div
className={cn(
'absolute top-0 left-1/2 -translate-x-1/2 w-px h-4',
'bg-gradient-to-b from-transparent to-nothing-gray-700'
)}
/>
{/* Bottom Line */}
<div
className={cn(
'absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-4',
'bg-gradient-to-t from-transparent to-nothing-gray-700'
)}
/>
{/* Left Line */}
<div
className={cn(
'absolute left-0 top-1/2 -translate-y-1/2 h-px w-4',
'bg-gradient-to-r from-transparent to-nothing-gray-700'
)}
/>
{/* Right Line */}
<div
className={cn(
'absolute right-0 top-1/2 -translate-y-1/2 h-px w-4',
'bg-gradient-to-l from-transparent to-nothing-gray-700'
)}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export * from './data-pincer'
export * from './thinking-terminal'
export * from './approval-card'

View File

@@ -0,0 +1,390 @@
'use client'
/**
* ThinkingTerminal - AI 思考流終端機 (Phase 4 升級版)
*
* 顯示 OpenClaw 的思考過程Nothing.tech 終端機風格
* 支援 GraphRAG (Blast Radius / Root Cause) 視覺化
* 支援 FinOps 成本分析視覺化
*/
import { useEffect, useMemo } from 'react'
import { cn } from '@/lib/utils'
import {
useAgentStore,
selectThinkingStream,
selectAgentStatus,
selectError,
type ThinkingStep,
} from '@/stores/agent.store'
interface ThinkingTerminalProps {
className?: string
maxHeight?: string
}
// ==================== GraphRAG 關鍵字偵測 ====================
const GRAPH_RAG_KEYWORDS = {
blast_radius: ['分析爆炸半徑', 'blast radius', 'affected services', '影響範圍'],
root_cause: ['找到根本原因', 'root cause', 'probable root', '根本原因分析'],
}
function detectGraphRAGType(content: string): 'blast_radius' | 'root_cause' | null {
const lowerContent = content.toLowerCase()
for (const keyword of GRAPH_RAG_KEYWORDS.blast_radius) {
if (lowerContent.includes(keyword.toLowerCase())) return 'blast_radius'
}
for (const keyword of GRAPH_RAG_KEYWORDS.root_cause) {
if (lowerContent.includes(keyword.toLowerCase())) return 'root_cause'
}
return null
}
// ==================== 依賴路徑視覺化 ====================
function DependencyPathVisualizer({
paths,
direction,
}: {
paths: string[]
direction: 'upstream' | 'downstream'
}) {
if (paths.length === 0) return null
return (
<div className="mt-2 p-3 bg-nothing-gray-800 rounded border border-nothing-gray-700">
<div className="text-xs text-nothing-gray-500 mb-2">
{direction === 'upstream' ? '[ BLAST RADIUS ]' : '[ ROOT CAUSE CHAIN ]'}
</div>
<div className="font-mono text-sm space-y-1">
{paths.map((path, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-nothing-gray-600">{i === 0 ? '>' : '|'}</span>
<span
className={cn(
direction === 'upstream' ? 'text-status-warning' : 'text-status-critical'
)}
>
{path}
</span>
</div>
))}
</div>
</div>
)
}
function ServiceChainVisualizer({
services,
target,
type,
}: {
services: string[]
target: string
type: 'blast_radius' | 'root_cause'
}) {
// 建構 ASCII 風格的依賴圖
const isBlastRadius = type === 'blast_radius'
return (
<div className="mt-2 p-3 bg-nothing-gray-800 rounded border border-nothing-gray-700">
<div className="text-xs text-nothing-gray-500 mb-2">
{isBlastRadius ? '[ UPSTREAM IMPACT ]' : '[ DOWNSTREAM DEPENDENCIES ]'}
</div>
{/* ASCII Art 風格圖形 */}
<div className="font-mono text-xs leading-relaxed">
{isBlastRadius ? (
// Blast Radius: 向上展示誰會受影響
<>
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
<div className="text-status-warning">
{' │ '}{services.slice(0, 3).join(', ').padEnd(19)}{'│'}
</div>
<div className="text-nothing-gray-600">{' └─────────┬───────────┘'}</div>
<div className="text-nothing-gray-600">{' │ depends on'}</div>
<div className="text-nothing-gray-600">{' ▼'}</div>
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
<div className="text-status-critical">
{' │ '}{target.padEnd(19)}{'│ '}<span className="animate-pulse">X</span>
</div>
<div className="text-nothing-gray-600">{' └─────────────────────┘'}</div>
</>
) : (
// Root Cause: 向下展示依賴誰
<>
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
<div className="text-status-warning">
{' │ '}{target.padEnd(19)}{'│ '}<span className="text-status-warning">!</span>
</div>
<div className="text-nothing-gray-600">{' └─────────┬───────────┘'}</div>
<div className="text-nothing-gray-600">{' │ calls'}</div>
<div className="text-nothing-gray-600">{' ▼'}</div>
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
<div className="text-status-critical">
{' │ '}{services.slice(0, 3).join(', ').padEnd(19)}{'│ '}<span className="animate-pulse">X</span>
</div>
<div className="text-nothing-gray-600">{' └─────────────────────┘'}</div>
</>
)}
</div>
{/* 詳細清單 */}
{services.length > 0 && (
<div className="mt-2 pt-2 border-t border-nothing-gray-700">
<div className="flex flex-wrap gap-1">
{services.map((svc, i) => (
<span
key={i}
className={cn(
'px-2 py-0.5 rounded text-xs',
isBlastRadius
? 'bg-status-warning/20 text-status-warning'
: 'bg-status-critical/20 text-status-critical'
)}
>
{svc}
</span>
))}
</div>
</div>
)}
</div>
)
}
// ==================== FinOps 視覺化 ====================
function FinOpsVisualizer({ data }: { data: NonNullable<ThinkingStep['finopsData']> }) {
return (
<div className="mt-2 p-3 bg-nothing-gray-800 rounded border border-nothing-gray-700">
<div className="text-xs text-nothing-gray-500 mb-2">[ FINOPS ANALYSIS ]</div>
{/* 成本摘要 */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="text-center p-2 bg-nothing-gray-900 rounded">
<div className="text-lg font-bold text-status-critical">
${data.totalWastedUsd.toFixed(0)}
</div>
<div className="text-xs text-nothing-gray-500">Wasted/mo</div>
</div>
<div className="text-center p-2 bg-nothing-gray-900 rounded">
<div className="text-lg font-bold text-status-healthy">
${data.realizableSavingsUsd.toFixed(0)}
</div>
<div className="text-xs text-nothing-gray-500">Realizable</div>
</div>
<div className="text-center p-2 bg-nothing-gray-900 rounded">
<div className="text-lg font-bold text-status-warning">
${data.freedResourcesUsd.toFixed(0)}
</div>
<div className="text-xs text-nothing-gray-500">Freed</div>
</div>
</div>
{/* Top Actions */}
{data.topActions.length > 0 && (
<div className="space-y-1">
{data.topActions.slice(0, 3).map((action, i) => (
<div
key={i}
className="flex items-center justify-between text-xs py-1 border-b border-nothing-gray-700 last:border-0"
>
<span className="text-nothing-gray-300 truncate max-w-[200px]">
{action.action}
</span>
<span
className={cn(
'font-mono',
action.risk === 'low' && 'text-status-healthy',
action.risk === 'medium' && 'text-status-warning',
action.risk === 'high' && 'text-status-critical'
)}
>
-${action.savings.toFixed(0)}
</span>
</div>
))}
</div>
)}
</div>
)
}
// ==================== Step Renderer ====================
function ThinkingStepRenderer({ step }: { step: ThinkingStep }) {
// 偵測 GraphRAG 相關內容
const detectedType = useMemo(() => detectGraphRAGType(step.content), [step.content])
// 基礎渲染
const baseContent = (
<div
className={cn(
'py-0.5 animate-fade-in',
step.type === 'result' && 'text-status-healthy',
step.type === 'thinking' && 'text-nothing-gray-300',
step.type === 'error' && 'text-status-critical',
step.type === 'graph_rag' && 'text-status-thinking',
step.type === 'finops' && 'text-status-warning'
)}
>
<span className="text-nothing-gray-600 mr-2">
[{step.type.toUpperCase()}]
</span>
{step.content}
</div>
)
// GraphRAG 結構化資料渲染
if (step.graphData) {
const { analysisType, targetService, affectedServices, probableRootCauses, criticalPath } =
step.graphData
return (
<div className="space-y-1">
{baseContent}
{analysisType === 'blast_radius' && affectedServices && (
<ServiceChainVisualizer
services={affectedServices}
target={targetService}
type="blast_radius"
/>
)}
{analysisType === 'root_cause' && probableRootCauses && (
<ServiceChainVisualizer
services={probableRootCauses}
target={targetService}
type="root_cause"
/>
)}
{criticalPath && criticalPath.length > 0 && (
<DependencyPathVisualizer
paths={criticalPath}
direction={analysisType === 'blast_radius' ? 'upstream' : 'downstream'}
/>
)}
</div>
)
}
// FinOps 結構化資料渲染
if (step.finopsData) {
return (
<div className="space-y-1">
{baseContent}
<FinOpsVisualizer data={step.finopsData} />
</div>
)
}
// 關鍵字偵測 (無結構化資料時的 fallback)
if (detectedType && !step.graphData) {
// 從文字內容嘗試解析服務名稱
const servicePattern = /([a-z]+-[a-z]+(-[a-z]+)?)/gi
const matches = step.content.match(servicePattern) || []
if (matches.length > 0) {
return (
<div className="space-y-1">
{baseContent}
<DependencyPathVisualizer
paths={matches.slice(0, 5)}
direction={detectedType === 'blast_radius' ? 'upstream' : 'downstream'}
/>
</div>
)
}
}
return baseContent
}
export function ThinkingTerminal({
className,
maxHeight = '300px',
}: ThinkingTerminalProps) {
const thinkingStream = useAgentStore(selectThinkingStream)
const status = useAgentStore(selectAgentStatus)
const error = useAgentStore(selectError)
const startThinkingStream = useAgentStore((s) => s.startThinkingStream)
const stopThinkingStream = useAgentStore((s) => s.stopThinkingStream)
const isStreaming = status === 'thinking'
// Cleanup on unmount
useEffect(() => {
return () => {
stopThinkingStream()
}
}, [stopThinkingStream])
return (
<div className={cn('glass-panel p-6 w-full space-y-4', className)}>
{/* Header */}
<div className="flex items-center justify-between border-b border-nothing-gray-800 pb-4">
<div className="flex items-center gap-3">
{/* Terminal Icon */}
<div className="flex gap-1">
<div className="w-3 h-3 rounded-full bg-status-critical" />
<div className="w-3 h-3 rounded-full bg-status-thinking" />
<div className="w-3 h-3 rounded-full bg-status-healthy" />
</div>
<h2 className="font-display text-display-sm">AWOOOI Terminal</h2>
</div>
<span className="font-mono text-xs text-nothing-gray-500">
v0.1.0 | SSE
</span>
</div>
{/* Control Button */}
<button
onClick={() => startThinkingStream()}
disabled={isStreaming}
className={cn(
'w-full py-3 px-4 rounded-button font-mono text-sm transition-all border',
isStreaming
? 'bg-nothing-gray-800 text-nothing-gray-500 border-nothing-gray-700 cursor-not-allowed'
: 'bg-nothing-white text-nothing-black border-transparent hover:bg-nothing-gray-200'
)}
>
{isStreaming ? '>_ EXECUTING...' : 'INITIATE SYNC'}
</button>
{/* Terminal Output */}
<div
className="bg-nothing-gray-900 rounded-card p-4 overflow-y-auto font-mono text-sm"
style={{ maxHeight, minHeight: '150px' }}
>
{thinkingStream.length === 0 && !isStreaming && !error && (
<div className="text-nothing-gray-600">
{'>'} Waiting for command...
</div>
)}
{thinkingStream.map((step, index) => (
<ThinkingStepRenderer key={index} step={step} />
))}
{/* Cursor Animation */}
{isStreaming && (
<div className="flex items-center gap-1 mt-1">
<span className="text-nothing-gray-500">{'>'}</span>
<span className="w-2 h-4 bg-nothing-white animate-pulse" />
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-2 border-t border-nothing-gray-800">
<p className="font-mono text-xs text-nothing-gray-600">
STREAM: /agent/thinking
</p>
<p className="font-mono text-xs text-nothing-gray-600">
{thinkingStream.length} events
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
/**
* AICommandPanel - 戰情室右側 AI 指揮面板
* ==========================================
* Phase 1: OpenClaw + HITL 授權卡片整合
*
* Features:
* - OpenClaw AI 視覺化 (頂部)
* - 待授權卡片列表 (底部)
* - 即時輪詢後端待簽核
* - Nothing.tech 純白極簡風格
*
* i18n: 100% next-intl
*/
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
import { ApprovalCard } from '@/components/approval/approval-card'
import {
useApprovalStore,
usePendingApprovals,
toFrontendApproval,
} from '@/stores/approval.store'
import { ShieldCheck, Bell } from 'lucide-react'
// =============================================================================
// Props
// =============================================================================
interface AICommandPanelProps {
className?: string
}
// =============================================================================
// Component
// =============================================================================
export function AICommandPanel({ className }: AICommandPanelProps) {
const t = useTranslations()
const tApproval = useTranslations('approval')
// Store
const { fetchPending, signApproval, startPolling, stopPolling } = useApprovalStore()
const pendingApprovals = usePendingApprovals()
// Start polling on mount
useEffect(() => {
startPolling(5000) // Poll every 5 seconds
return () => stopPolling()
}, [startPolling, stopPolling])
// Handle approval
const handleApprove = async (id: string) => {
await signApproval(id, 'demo-user', 'War Room User', 'Approved via Command Center')
await fetchPending()
}
// Handle rejection
const handleReject = async (id: string) => {
// TODO: Implement rejection API
console.log('[AICommandPanel] Reject:', id)
await fetchPending()
}
return (
<div className={cn('flex flex-col gap-4', className)}>
{/* OpenClaw AI Section */}
<OpenClawPanel status="patrolling" />
{/* Pending Approvals Section */}
{pendingApprovals.length > 0 && (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-claw-blue" />
<span className="font-mono text-xs text-nothing-gray-600">
{tApproval('pendingApprovals')}
</span>
</div>
<div className="flex items-center gap-1.5">
<Bell className="w-3 h-3 text-status-warning animate-pulse" />
<span className="font-mono text-xs font-bold text-status-warning">
{pendingApprovals.length}
</span>
</div>
</div>
{/* Approval Cards */}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin">
{pendingApprovals.map((approval) => {
const frontendApproval = toFrontendApproval(approval)
return (
<ApprovalCard
key={approval.id}
request={frontendApproval}
onApprove={handleApprove}
onReject={handleReject}
holdDuration={approval.risk_level === 'critical' ? 2000 : 1000}
/>
)
})}
</div>
</div>
)}
{/* Empty State - No pending approvals */}
{pendingApprovals.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-center">
<ShieldCheck className="w-8 h-8 text-nothing-gray-300 mb-2" />
<p className="font-mono text-xs text-nothing-gray-400">
{tApproval('noApprovals')}
</p>
</div>
)}
</div>
)
}
export default AICommandPanel

View File

@@ -0,0 +1,248 @@
'use client'
/**
* AIThinkingPanel - OpenClaw AI 思考狀態面板
* ==========================================
* Phase 1: 視覺靈魂注入 (Frontend AI UX Integration)
*
* Features:
* - 思維紫 (--status-thinking) 色彩
* - 點陣字體打字機特效
* - 脈衝呼吸燈動畫
* - Slide Up 動畫過渡到 ApprovalCard
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Brain, Sparkles, Zap, Shield, AlertTriangle } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export type ThinkingPhase =
| 'intercepting' // [SYS] 攔截異常...
| 'analyzing' // OpenClaw 正在分析爆炸半徑...
| 'calculating' // 計算風險矩陣...
| 'generating' // 生成修復建議...
| 'complete' // 分析完成
export interface AIThinkingPanelProps {
isActive: boolean
phase?: ThinkingPhase
alertType?: string
onComplete?: () => void
className?: string
}
// =============================================================================
// Typewriter Messages (i18n keys or fallback)
// =============================================================================
const PHASE_MESSAGES: Record<ThinkingPhase, string> = {
intercepting: '[SYS] 攔截異常訊號...',
analyzing: 'OpenClaw 正在分析爆炸半徑...',
calculating: '計算風險矩陣與簽核門檻...',
generating: '生成修復腳本建議...',
complete: '分析完成,待簽核卡片已建立',
}
const PHASE_ICONS: Record<ThinkingPhase, typeof Brain> = {
intercepting: AlertTriangle,
analyzing: Brain,
calculating: Zap,
generating: Shield,
complete: Sparkles,
}
// =============================================================================
// Component
// =============================================================================
export function AIThinkingPanel({
isActive,
phase = 'intercepting',
alertType,
onComplete,
className,
}: AIThinkingPanelProps) {
const t = useTranslations('ai')
const [displayText, setDisplayText] = useState('')
const [cursorVisible, setCursorVisible] = useState(true)
const [currentPhase, setCurrentPhase] = useState<ThinkingPhase>('intercepting')
// Typewriter effect
const typeText = useCallback((text: string, callback?: () => void) => {
let index = 0
setDisplayText('')
const interval = setInterval(() => {
if (index < text.length) {
setDisplayText(text.slice(0, index + 1))
index++
} else {
clearInterval(interval)
callback?.()
}
}, 50) // 50ms per character
return () => clearInterval(interval)
}, [])
// Phase progression
useEffect(() => {
if (!isActive) {
setCurrentPhase('intercepting')
setDisplayText('')
return
}
const phases: ThinkingPhase[] = ['intercepting', 'analyzing', 'calculating', 'generating', 'complete']
let phaseIndex = 0
const progressPhase = () => {
if (phaseIndex < phases.length) {
const currentPhase = phases[phaseIndex]
setCurrentPhase(currentPhase)
const cleanup = typeText(PHASE_MESSAGES[currentPhase], () => {
phaseIndex++
if (phaseIndex < phases.length) {
setTimeout(progressPhase, 800) // Wait before next phase
} else {
onComplete?.()
}
})
return cleanup
}
}
const cleanup = progressPhase()
return () => cleanup?.()
}, [isActive, typeText, onComplete])
// Cursor blink
useEffect(() => {
const interval = setInterval(() => {
setCursorVisible((v) => !v)
}, 500)
return () => clearInterval(interval)
}, [])
if (!isActive) return null
const PhaseIcon = PHASE_ICONS[currentPhase]
return (
<div
className={cn(
'relative overflow-hidden rounded-xl',
'bg-gradient-to-br from-status-thinking/5 to-status-thinking/10',
'border border-status-thinking/20',
'p-6',
'animate-fade-in',
className
)}
>
{/* Animated scan line */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute h-[2px] w-full bg-gradient-to-r from-transparent via-status-thinking/50 to-transparent animate-scan" />
</div>
{/* Sparkle decorations */}
<div className="absolute top-3 right-3">
<Sparkles className="w-5 h-5 text-status-thinking/40 animate-pulse" />
</div>
<div className="absolute bottom-3 left-3">
<Sparkles className="w-4 h-4 text-status-thinking/30 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* Main content */}
<div className="flex items-start gap-4">
{/* Brain icon with glow */}
<div className="relative flex-shrink-0">
<div className={cn(
'w-14 h-14 rounded-xl flex items-center justify-center',
'bg-gradient-to-br from-status-thinking/20 to-status-thinking/30',
'border border-status-thinking/30',
'shadow-glow-purple'
)}>
<PhaseIcon className={cn(
'w-7 h-7 text-status-thinking',
currentPhase !== 'complete' && 'animate-pulse'
)} />
</div>
{/* Breathing indicator */}
<div className="absolute -bottom-1 -right-1 w-4 h-4">
<span className="absolute inline-flex h-full w-full rounded-full bg-status-thinking opacity-75 animate-ping" />
<span className="relative inline-flex rounded-full h-4 w-4 bg-status-thinking" />
</div>
</div>
{/* Text content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-mono text-status-thinking uppercase tracking-widest">
OpenClaw AI
</span>
{alertType && (
<span className="px-2 py-0.5 rounded text-[10px] font-mono bg-status-thinking/10 text-status-thinking border border-status-thinking/20">
{alertType}
</span>
)}
</div>
{/* Typewriter text - Dot Matrix Style */}
<div className="font-mono text-lg text-nothing-gray-800 tracking-wide">
<span className="whitespace-pre-wrap">{displayText}</span>
<span
className={cn(
'inline-block w-2 h-5 bg-status-thinking ml-1 -mb-0.5',
cursorVisible ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
{/* Progress dots */}
<div className="flex items-center gap-2 mt-4">
{(['intercepting', 'analyzing', 'calculating', 'generating', 'complete'] as ThinkingPhase[]).map((p, i) => (
<div
key={p}
className={cn(
'w-2 h-2 rounded-full transition-all duration-300',
currentPhase === p
? 'bg-status-thinking scale-125 shadow-glow-purple'
: i < ['intercepting', 'analyzing', 'calculating', 'generating', 'complete'].indexOf(currentPhase)
? 'bg-status-thinking/50'
: 'bg-nothing-gray-300'
)}
/>
))}
</div>
</div>
</div>
{/* Bottom status bar */}
<div className="mt-4 pt-4 border-t border-status-thinking/10">
<div className="flex items-center justify-between text-xs font-mono text-nothing-gray-500">
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-status-thinking animate-pulse" />
{currentPhase === 'complete' ? t('analysisComplete') : t('processingAlert')}
</span>
<span className="text-status-thinking">
AI Fallback: Ollama Gemini Claude
</span>
</div>
</div>
</div>
)
}
// =============================================================================
// Export
// =============================================================================
export default AIThinkingPanel

View File

@@ -0,0 +1,439 @@
'use client'
/**
* OpenClawPanel - 賽博維運風格 AI 面板
* =====================================
* Phase 1: 視覺靈魂注入 (Cyber-Shell Visual Identity)
*
* Features:
* - 3D 骨架機械爪視覺化 (CSS Art)
* - 核心藍色 LED 脈衝動畫
* - 點陣字體狀態顯示
* - AI 思考流過渡動畫
* - 高通透度 awoooi-glass 效果
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Sparkles } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export type OpenClawStatus =
| 'patrolling' // [AGENT] patrolling...
| 'intercepting' // [SYS] 攔截異常...
| 'analyzing' // [SYS] Analyzing blast radius...
| 'generating' // [SYS] Generating proposed action...
| 'complete' // [SYS] Analysis complete
export interface OpenClawPanelProps {
status?: OpenClawStatus
alertType?: string
onAnalysisComplete?: () => void
className?: string
}
// =============================================================================
// Status Messages (Dot Matrix Style)
// =============================================================================
const STATUS_MESSAGES: Record<OpenClawStatus, string> = {
patrolling: '[AGENT] patrolling...',
intercepting: '[SYS] Intercepting anomaly...',
analyzing: '[SYS] Analyzing blast radius...',
generating: '[SYS] Generating proposed action...',
complete: '[SYS] Analysis complete',
}
// =============================================================================
// NemoClaw 3D Ceramic SVG Component (Lab-White Style)
// =============================================================================
function NemoClaw({ isActive, isPulsing }: { isActive: boolean; isPulsing: boolean }) {
return (
<svg
viewBox="0 0 140 140"
className="w-full h-full drop-shadow-lg"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{/* 3D Ceramic gradient - white/cream tones */}
<linearGradient id="ceramic3d" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FFFFFF" />
<stop offset="40%" stopColor="#F8F8F8" />
<stop offset="70%" stopColor="#E8E8E8" />
<stop offset="100%" stopColor="#D8D8D8" />
</linearGradient>
{/* Core glow filter - stronger */}
<filter id="coreGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
{/* Pulse glow animation filter */}
<filter id="pulseGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="8" result="blur">
<animate attributeName="stdDeviation" values="6;10;6" dur="1.5s" repeatCount="indefinite" />
</feGaussianBlur>
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
{/* Shadow for 3D effect */}
<filter id="shadow3d" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="4" stdDeviation="3" floodOpacity="0.15" />
</filter>
</defs>
{/* Base shadow */}
<ellipse cx="70" cy="125" rx="35" ry="8" fill="rgba(0,0,0,0.08)" />
{/* Main body - 3D ceramic sphere */}
<circle
cx="70"
cy="70"
r="32"
fill="url(#ceramic3d)"
filter="url(#shadow3d)"
stroke="#E0E0E0"
strokeWidth="1"
/>
{/* Inner ring - depth effect */}
<circle
cx="70"
cy="70"
r="26"
fill="none"
stroke="#D0D0D0"
strokeWidth="1"
opacity="0.5"
/>
{/* Core LED - Blue pulsing (the eye) */}
<circle
cx="70"
cy="70"
r="16"
fill={isActive ? '#4A90D9' : '#B0B0B0'}
filter={isPulsing ? 'url(#pulseGlow)' : 'url(#coreGlow)'}
className="transition-all duration-500"
>
{isPulsing && (
<animate
attributeName="r"
values="14;17;14"
dur="1s"
repeatCount="indefinite"
/>
)}
</circle>
{/* Core highlight */}
<circle
cx="70"
cy="70"
r="8"
fill="white"
opacity={isActive ? 0.9 : 0.4}
className="transition-opacity duration-300"
/>
{/* Claw arms - ceramic white 3D style */}
{/* Top arm */}
<g filter="url(#shadow3d)">
<path
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
{/* Claw tips */}
<circle cx="58" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
<circle cx="82" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Left arm */}
<g filter="url(#shadow3d)">
<path
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="6" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
<circle cx="6" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Right arm */}
<g filter="url(#shadow3d)">
<path
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="134" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
<circle cx="134" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Bottom left arm */}
<g filter="url(#shadow3d)">
<path
d="M 48 92 L 28 112 L 16 116"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 48 92 L 28 112 L 16 116"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="16" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Bottom right arm */}
<g filter="url(#shadow3d)">
<path
d="M 92 92 L 112 112 L 124 116"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 92 92 L 112 112 L 124 116"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="124" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Orbit ring when active */}
{isActive && (
<circle
cx="70"
cy="70"
r="42"
fill="none"
stroke="#4A90D9"
strokeWidth="1"
strokeDasharray="6 6"
opacity="0.4"
className="animate-spin"
style={{ animationDuration: '8s', transformOrigin: '70px 70px' }}
/>
)}
</svg>
)
}
// =============================================================================
// Typewriter Hook
// =============================================================================
function useTypewriter(text: string, speed: number = 50) {
const [displayText, setDisplayText] = useState('')
useEffect(() => {
let index = 0
setDisplayText('')
const interval = setInterval(() => {
if (index < text.length) {
setDisplayText(text.slice(0, index + 1))
index++
} else {
clearInterval(interval)
}
}, speed)
return () => clearInterval(interval)
}, [text, speed])
return displayText
}
// =============================================================================
// Component
// =============================================================================
export function OpenClawPanel({
status = 'patrolling',
alertType,
onAnalysisComplete,
className,
}: OpenClawPanelProps) {
const t = useTranslations('ai')
const [cursorVisible, setCursorVisible] = useState(true)
const isActive = status !== 'patrolling'
const isPulsing = status === 'intercepting' || status === 'analyzing'
const statusMessage = STATUS_MESSAGES[status]
const displayText = useTypewriter(statusMessage, 40)
// Cursor blink
useEffect(() => {
const interval = setInterval(() => {
setCursorVisible((v) => !v)
}, 500)
return () => clearInterval(interval)
}, [])
// Notify when complete
useEffect(() => {
if (status === 'complete') {
const timeout = setTimeout(() => {
onAnalysisComplete?.()
}, 1000)
return () => clearTimeout(timeout)
}
}, [status, onAnalysisComplete])
return (
<div
className={cn(
'relative overflow-hidden rounded-xl',
// awoooi-glass effect
'bg-white/70 backdrop-blur-[20px]',
// 骨架細框線
'border border-nothing-gray-200/50',
// 輕柔陰影
'shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
'p-4',
className
)}
>
{/* Scan line animation when active */}
{isActive && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute h-[1px] w-full bg-gradient-to-r from-transparent via-claw-blue/50 to-transparent animate-scan" />
</div>
)}
{/* Header - Dot Matrix Style */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="font-dot-matrix text-sm text-nothing-gray-700 tracking-wider">
AWOOOI v1.0.0
</span>
<span className="font-dot-matrix text-xs text-nothing-gray-400">
| Production
</span>
{isActive && (
<span className="w-2 h-2 rounded-full bg-claw-blue animate-ping" />
)}
</div>
{alertType && (
<span className="px-2 py-0.5 rounded text-[10px] font-dot-matrix bg-status-critical/10 text-status-critical border border-status-critical/20">
{alertType}
</span>
)}
</div>
{/* NemoClaw 3D Ceramic Visualization */}
<div className="relative w-32 h-32 mx-auto mb-4">
<NemoClaw isActive={isActive} isPulsing={isPulsing} />
{/* Sparkle effects when active */}
{isActive && (
<>
<Sparkles className="absolute -top-1 -right-1 w-4 h-4 text-claw-blue/60 animate-pulse" />
<Sparkles className="absolute -bottom-1 -left-1 w-3 h-3 text-claw-blue/40 animate-pulse" style={{ animationDelay: '0.3s' }} />
</>
)}
</div>
{/* Status Display - VT323 Dot Matrix Font */}
<div className="text-center bg-nothing-gray-50/50 rounded-lg py-2 px-3">
<div className={cn(
'font-dot-matrix text-base tracking-wide',
isActive ? 'text-claw-blue' : 'text-nothing-gray-600'
)}>
<span>{displayText}</span>
<span
className={cn(
'inline-block w-2 h-4 ml-1 -mb-0.5',
isActive ? 'bg-claw-blue' : 'bg-nothing-gray-400',
cursorVisible ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
</div>
{/* Progress indicator when analyzing */}
{(status === 'analyzing' || status === 'generating') && (
<div className="mt-3 flex justify-center gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-claw-blue animate-pulse"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</div>
)}
{/* Complete indicator */}
{status === 'complete' && (
<div className="mt-3 flex justify-center">
<span className="px-2 py-0.5 rounded text-[9px] font-mono bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
READY
</span>
</div>
)}
</div>
)
}
export default OpenClawPanel

View File

@@ -0,0 +1,558 @@
'use client'
/**
* OpenClawStateMachine - 戰情室 AI 狀態機整合
* ==========================================
* Phase 2: 真實 API 數據整合 (禁止 Mock)
*
* Features:
* - 三態狀態機 (idle / thinking / awaiting_approval)
* - 真實 API 輪詢 (/api/v1/approvals/pending)
* - ThinkingStream 打字機動畫
* - ApprovalCard 滑入動畫
* - 記憶體安全清理
*
* 真實性條款: 禁止任何 Mock Data
* i18n: 100% next-intl
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
import { ThinkingStream, DEFAULT_THINKING_MESSAGES } from './thinking-stream'
import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card'
import { RefreshCw, AlertCircle, CheckCircle2, Clock, Archive } from 'lucide-react'
import { toast } from '@/components/ui/toast'
import { useTimelineStore, useStartSmartPolling } from '@/stores/timeline.store'
// =============================================================================
// Types
// =============================================================================
export type MachineState = 'idle' | 'thinking' | 'awaiting_approval'
export interface OpenClawStateMachineProps {
/** 啟用 Demo 模式 (僅供開發測試) */
demoMode?: boolean
/** API 輪詢間隔 (ms) */
pollInterval?: number
className?: string
}
interface PendingApprovalsResponse {
count: number
approvals: ApprovalRequest[]
}
// 歷史紀錄擴展類型
interface HistoryApproval extends ApprovalRequest {
status: 'approved' | 'rejected' | 'executed' | 'execution_failed'
resolvedAt?: string
}
type TabType = 'pending' | 'history'
// =============================================================================
// 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
}
// API response uses snake_case, frontend uses camelCase
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformApiResponse(apiApproval: any): ApprovalRequest {
return {
id: apiApproval.id,
action: apiApproval.action,
description: apiApproval.description,
riskLevel: apiApproval.risk_level,
blastRadius: {
affectedPods: apiApproval.blast_radius?.affected_pods ?? 0,
estimatedDowntime: apiApproval.blast_radius?.estimated_downtime ?? '0',
relatedServices: apiApproval.blast_radius?.related_services ?? [],
dataImpact: ((apiApproval.blast_radius?.data_impact ?? 'none').toUpperCase()) as 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE',
},
dryRunChecks: apiApproval.dry_run_checks ?? [],
requiredSignatures: apiApproval.required_signatures ?? 2,
currentSignatures: apiApproval.current_signatures ?? 0,
requestedBy: apiApproval.requested_by ?? 'OpenClaw',
requestedAt: apiApproval.created_at ?? new Date().toISOString(),
hitCount: apiApproval.hit_count,
lastSeenAt: apiApproval.last_seen_at,
fingerprint: apiApproval.fingerprint,
}
}
// =============================================================================
// Component
// =============================================================================
export function OpenClawStateMachine({
demoMode = false,
pollInterval = 5000,
className,
}: OpenClawStateMachineProps) {
const t = useTranslations()
// State
const [machineState, setMachineState] = useState<MachineState>('idle')
const [openclawStatus, setClawbotStatus] = useState<OpenClawStatus>('patrolling')
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastFetch, setLastFetch] = useState<Date | null>(null)
// 歷史紀錄狀態
const [activeTab, setActiveTab] = useState<TabType>('pending')
const [historyApprovals, setHistoryApprovals] = useState<HistoryApproval[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Timer refs for cleanup
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
// ==========================================================================
// API: Fetch Pending Approvals
// ==========================================================================
const fetchPendingApprovals = useCallback(async () => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/pending`, {
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
const rawData = await response.json()
const data: PendingApprovalsResponse = {
count: rawData.count,
approvals: (rawData.approvals ?? []).map(transformApiResponse),
}
setPendingApprovals(data.approvals)
setLastFetch(new Date())
// Update machine state based on approvals
if (data.count > 0) {
setMachineState('awaiting_approval')
setClawbotStatus('complete')
} else {
setMachineState('idle')
setClawbotStatus('patrolling')
}
console.log('[OpenClaw] Fetched approvals:', data.count)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
console.error('[OpenClaw] Fetch error:', message)
} finally {
setIsLoading(false)
}
}, [])
// ==========================================================================
// API: Fetch History Approvals (歷史歸檔)
// ==========================================================================
const fetchHistoryApprovals = useCallback(async () => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
setIsLoadingHistory(true)
try {
// 取得已處理的歷史紀錄
const response = await fetch(`${apiBaseUrl}/api/v1/approvals?status=approved,executed,rejected,execution_failed&limit=20`, {
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
const rawData = await response.json()
const items = (rawData.items ?? rawData.approvals ?? []).map((item: any) => ({
...transformApiResponse(item),
status: item.status,
resolvedAt: item.resolved_at,
}))
setHistoryApprovals(items)
console.log('[OpenClaw] Fetched history:', items.length)
} catch (err) {
console.error('[OpenClaw] History fetch error:', err)
} finally {
setIsLoadingHistory(false)
}
}, [])
// 當切換到歷史標籤時自動載入
useEffect(() => {
if (activeTab === 'history') {
fetchHistoryApprovals()
}
}, [activeTab, fetchHistoryApprovals])
// ==========================================================================
// Timeline Refresh + Smart Polling
// ==========================================================================
const fetchTimeline = useTimelineStore((s) => s.fetchEvents)
const startSmartPolling = useTimelineStore((s) => s.startSmartPolling)
// ==========================================================================
// Signer rotation for Multi-Sig
// ==========================================================================
const signerCounter = useRef(0)
const SIGNERS = [
{ id: 'cto-ogt', name: '統帥 (CTO)' },
{ id: 'ciso-security', name: '資安長 (CISO)' },
{ id: 'cpo-product', name: '產品長 (CPO)' },
]
// ==========================================================================
// API: Submit Signature (with Toast & Timeline Refresh)
// ==========================================================================
const handleApprove = useCallback(async (approvalId: string) => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
// Show loading toast
const loadingId = toast.loading('[AWOOOI] 統帥已授權K8s 指令發送中...')
try {
// Rotate through signers for Multi-Sig
const signer = SIGNERS[signerCounter.current % SIGNERS.length]
signerCounter.current++
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signer_id: signer.id,
signer_name: signer.name,
comment: 'Approved via AWOOOI Dashboard',
}),
})
// Dismiss loading toast
if (loadingId) toast.dismiss(loadingId)
if (!response.ok) {
throw new Error(`Sign failed: ${response.status}`)
}
const result = await response.json()
console.log('[OpenClaw] Approval signed:', approvalId, result)
// Show success toast
if (result.execution_triggered) {
toast.success(`[AWOOOI] 簽核完成K8s Executor 已啟動執行`)
// 執行觸發後的 Toast
toast.info('[AWOOOI] K8s 執行完成,正在更新戰情時間軸...')
// 啟動 Smart Polling (每秒輪詢直到看到 EXEC 事件)
startSmartPolling()
} else {
toast.info(`[AWOOOI] ${signer.name} 簽核成功 (${result.approval?.current_signatures}/${result.approval?.required_signatures})`)
// 非最終簽核也刷新 Timeline
fetchTimeline()
}
// Refresh approvals list
await fetchPendingApprovals()
} catch (err) {
if (loadingId) toast.dismiss(loadingId)
console.error('[OpenClaw] Sign error:', err)
const errorMsg = err instanceof Error ? err.message : 'Sign failed'
setError(errorMsg)
toast.error(`[AWOOOI] 簽核失敗: ${errorMsg}`)
}
}, [fetchPendingApprovals, fetchTimeline, startSmartPolling])
// ==========================================================================
// API: Reject Request (with Toast & Timeline Refresh)
// ==========================================================================
const handleReject = useCallback(async (approvalId: string) => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
const loadingId = toast.loading('[AWOOOI] 拒絕請求處理中...')
try {
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rejector_id: 'cto-ogt',
rejector_name: '統帥 (CTO)',
reason: 'Rejected via AWOOOI Dashboard',
}),
})
if (loadingId) toast.dismiss(loadingId)
if (!response.ok) {
throw new Error(`Reject failed: ${response.status}`)
}
console.log('[OpenClaw] Approval rejected:', approvalId)
toast.success('[AWOOOI] 請求已拒絕')
// Refresh approvals list
await fetchPendingApprovals()
// Refresh Timeline (拒絕不需要 Smart Polling)
fetchTimeline()
} catch (err) {
if (loadingId) toast.dismiss(loadingId)
console.error('[OpenClaw] Reject error:', err)
const errorMsg = err instanceof Error ? err.message : 'Reject failed'
setError(errorMsg)
toast.error(`[AWOOOI] 拒絕失敗: ${errorMsg}`)
}
}, [fetchPendingApprovals, fetchTimeline])
// ==========================================================================
// Polling Effect
// ==========================================================================
useEffect(() => {
// Skip polling in demo mode
if (demoMode) return
// Initial fetch
fetchPendingApprovals()
// Start polling
pollTimerRef.current = setInterval(fetchPendingApprovals, pollInterval)
// Cleanup
return () => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
}, [demoMode, pollInterval, fetchPendingApprovals])
// ==========================================================================
// Render
// ==========================================================================
return (
<div className={cn('space-y-4', className)}>
{/* Status Bar */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-nothing-gray-500">
STATE:
</span>
<span
className={cn(
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
machineState === 'idle' && 'bg-nothing-gray-100 text-nothing-gray-600',
machineState === 'thinking' && 'bg-status-thinking/10 text-status-thinking',
machineState === 'awaiting_approval' && 'bg-status-warning/10 text-status-warning'
)}
>
{machineState}
</span>
</div>
<div className="flex items-center gap-2">
{/* Loading indicator */}
{isLoading && (
<RefreshCw className="w-3 h-3 text-nothing-gray-400 animate-spin" />
)}
{/* Error indicator */}
{error && (
<div className="flex items-center gap-1 text-status-critical">
<AlertCircle className="w-3 h-3" />
<span className="text-[10px] font-mono">{error}</span>
</div>
)}
{/* Manual refresh button */}
<button
onClick={fetchPendingApprovals}
disabled={isLoading}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-[10px] 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="w-3 h-3" />
REFRESH
</button>
</div>
</div>
{/* OpenClaw Visual */}
<OpenClawPanel
status={openclawStatus}
alertType={pendingApprovals.length > 0 ? 'POD_CRASH' : undefined}
/>
{/* Nothing.tech 風格標籤切換 */}
<div className="flex items-center gap-1 p-1 bg-nothing-gray-50 rounded-lg border border-nothing-gray-200">
<button
onClick={() => setActiveTab('pending')}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md font-mono text-xs font-semibold',
'transition-all duration-200',
activeTab === 'pending'
? 'bg-white text-nothing-gray-900 shadow-sm border border-nothing-gray-200'
: 'text-nothing-gray-500 hover:text-nothing-gray-700 hover:bg-white/50'
)}
>
<Clock className="w-3.5 h-3.5" />
{pendingApprovals.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-status-warning/10 text-status-warning rounded text-[10px]">
{pendingApprovals.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md font-mono text-xs font-semibold',
'transition-all duration-200',
activeTab === 'history'
? 'bg-white text-nothing-gray-900 shadow-sm border border-nothing-gray-200'
: 'text-nothing-gray-500 hover:text-nothing-gray-700 hover:bg-white/50'
)}
>
<Archive className="w-3.5 h-3.5" />
</button>
</div>
{/* ========== 待處理 Tab ========== */}
{activeTab === 'pending' && (
<>
{/* Pending Approvals List (REAL DATA) */}
{pendingApprovals.length > 0 && (
<div className="space-y-3">
{pendingApprovals.map((approval) => (
<div key={approval.id} className="animate-slide-in-up">
<ApprovalCard
request={approval}
onApprove={() => handleApprove(approval.id)}
onReject={() => handleReject(approval.id)}
holdDuration={1000}
/>
</div>
))}
</div>
)}
{/* Idle state - no pending approvals */}
{machineState === 'idle' && pendingApprovals.length === 0 && !isLoading && (
<div className="text-center py-6 border border-dashed border-nothing-gray-200 rounded-lg">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-status-healthy/50" />
<p className="font-mono text-xs text-nothing-gray-400">
{t('ai.standby')}
</p>
<p className="font-mono text-[10px] text-nothing-gray-300 mt-1">
{lastFetch
? `Last check: ${lastFetch.toLocaleTimeString()}`
: 'Waiting for first fetch...'}
</p>
</div>
)}
</>
)}
{/* ========== 歷史紀錄 Tab ========== */}
{activeTab === 'history' && (
<div className="space-y-3">
{/* Loading 狀態 */}
{isLoadingHistory && (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-nothing-gray-400 animate-spin" />
<span className="ml-2 font-mono text-xs text-nothing-gray-400">...</span>
</div>
)}
{/* 歷史卡片列表 */}
{!isLoadingHistory && historyApprovals.length > 0 && (
<>
{historyApprovals.map((approval) => (
<div key={approval.id} className="opacity-80">
<ApprovalCard
request={approval}
readOnly
finalStatus={
approval.status === 'executed' ? 'executed' :
approval.status === 'execution_failed' ? 'failed' :
approval.status === 'rejected' ? 'rejected' : 'approved'
}
/>
</div>
))}
</>
)}
{/* 空狀態 */}
{!isLoadingHistory && historyApprovals.length === 0 && (
<div className="text-center py-8 border border-dashed border-nothing-gray-200 rounded-lg">
<Archive className="w-8 h-8 mx-auto mb-2 text-nothing-gray-300" />
<p className="font-mono text-xs text-nothing-gray-400">
</p>
<p className="font-mono text-[10px] text-nothing-gray-300 mt-1">
</p>
</div>
)}
{/* 刷新按鈕 */}
<div className="flex justify-center pt-2">
<button
onClick={fetchHistoryApprovals}
disabled={isLoadingHistory}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-xs',
'bg-nothing-gray-50 text-nothing-gray-600 border border-nothing-gray-200',
'hover:bg-nothing-gray-100 transition-colors',
'disabled:opacity-50'
)}
>
<RefreshCw className={cn('w-3 h-3', isLoadingHistory && 'animate-spin')} />
</button>
</div>
</div>
)}
{/* Demo mode warning */}
{demoMode && (
<div className="bg-status-warning/10 border border-status-warning/20 rounded-lg p-3">
<p className="font-mono text-xs text-status-warning">
Demo mode is enabled. Real API polling is disabled.
</p>
</div>
)}
</div>
)
}
export default OpenClawStateMachine

View File

@@ -0,0 +1,498 @@
'use client'
/**
* HITLSection - 人機協作審批區域
* ================================
* Phase 1: AI 思考 → 動態卡片對接
*
* Features:
* - OpenClaw AI 思考流視覺化
* - 動態 Approval Card 渲染 (100% 後端資料)
* - Slide Up 動畫過渡
* - 即時輪詢後端待簽核列表
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
import { ApprovalCard, type RiskLevel } from '@/components/approval/approval-card'
import {
useApprovalStore,
usePendingApprovals,
toFrontendApproval,
} from '@/stores/approval.store'
import { useTimelineStore } from '@/stores/timeline.store'
import { ActionTimeline } from '@/components/timeline'
import { GlassCard, GlassCardTitle, GlassCardContent, GlassCardHeader } from '@/components/ui/glass-card'
import { ShieldCheck, Plus, Loader2, Lock, ShieldX, AlertTriangle } from 'lucide-react'
// =============================================================================
// 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()
// =============================================================================
// Phase 3: RBAC Permission System (企業護城河)
// =============================================================================
type UserRole = 'viewer' | 'developer' | 'devops' | 'admin' | 'cto' | 'ciso' | 'ceo'
const REQUIRED_ROLE_FOR_RISK: Record<RiskLevel, UserRole[]> = {
low: ['developer', 'devops', 'admin', 'cto', 'ciso', 'ceo'],
medium: ['devops', 'admin', 'cto', 'ciso', 'ceo'],
high: ['admin', 'cto', 'ciso', 'ceo'],
critical: ['cto', 'ciso', 'ceo'], // Critical 需要 CTO/CISO 等級
}
function canSignApproval(userRole: UserRole, riskLevel: RiskLevel): boolean {
const allowedRoles = REQUIRED_ROLE_FOR_RISK[riskLevel]
return allowedRoles.includes(userRole)
}
function getRequiredRolesDisplay(riskLevel: RiskLevel): string {
const roles = REQUIRED_ROLE_FOR_RISK[riskLevel]
return roles.map((r) => r.toUpperCase()).join(' / ')
}
// =============================================================================
// Types
// =============================================================================
interface HITLSectionProps {
locale: string
className?: string
}
// =============================================================================
// Component
// =============================================================================
export function HITLSection({ locale, className }: HITLSectionProps) {
const t = useTranslations('demo')
const tApproval = useTranslations('approval')
// Store
const { fetchPending, signApproval, startPolling, stopPolling } = useApprovalStore()
const pendingApprovals = usePendingApprovals()
const addTimelineEvent = useTimelineStore((state) => state.addEvent)
// AI thinking state
const [openclawStatus, setClawbotStatus] = useState<OpenClawStatus>('patrolling')
const [currentAlertType, setCurrentAlertType] = useState<string | undefined>()
const [showCards, setShowCards] = useState(true)
const [isSimulating, setIsSimulating] = useState(false)
// Phase 3: 模擬當前登入者角色 (可切換測試權限)
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('devops')
const currentUserName = currentUserRole === 'cto' ? 'CTO Admin' : 'Demo DevOps'
// Phase 3: Access Denied 模態框狀態
const [accessDeniedModal, setAccessDeniedModal] = useState<{
show: boolean
riskLevel: RiskLevel
requiredRoles: string
} | null>(null)
// Start polling on mount
useEffect(() => {
startPolling(5000) // Poll every 5 seconds
return () => stopPolling()
}, [startPolling, stopPolling])
// Simulate webhook alert (triggers AI thinking flow)
const simulateAlert = useCallback(async (alertType: string, severity: 'critical' | 'warning') => {
setIsSimulating(true)
setShowCards(false)
setCurrentAlertType(alertType)
// Phase 1: Intercepting
setClawbotStatus('intercepting')
await new Promise((r) => setTimeout(r, 800))
// Phase 2: Analyzing
setClawbotStatus('analyzing')
await new Promise((r) => setTimeout(r, 1200))
// Phase 3: Generating
setClawbotStatus('generating')
// Call actual webhook API
try {
const response = await fetch(`${API_BASE_URL}/api/v1/webhooks/alerts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alert_type: alertType,
severity,
source: 'demo-ui',
target_resource: alertType === 'k8s_pod_crash' ? 'harbor-core-7d4b8c9f5-xk2m3' : 'nginx-ingress-abc123',
namespace: 'default',
message: `Demo alert: ${alertType}`,
metrics: { cpu_percent: 95, sigma_deviation: 3.5 },
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
console.log('[HITL] Webhook response:', result)
// Phase 4: Log SYSTEM alert received
addTimelineEvent({
type: 'system',
status: 'warning',
title: `接收到 ${alertType.replace('_', ' ')} 告警`,
riskLevel: severity === 'critical' ? 'critical' : 'medium',
})
// Phase 4: Log AGENT analysis complete
addTimelineEvent({
type: 'agent',
status: 'info',
title: `OpenClaw 分析完成,提出 ${severity.toUpperCase()} 提案`,
actor: 'OpenClaw',
actorRole: 'ai',
riskLevel: severity === 'critical' ? 'critical' : 'medium',
approvalId: result.approval_id,
})
// Phase 4: Complete
setClawbotStatus('complete')
await new Promise((r) => setTimeout(r, 800))
// Refresh approvals list
await fetchPending()
// Show cards with animation
setShowCards(true)
} catch (error) {
console.error('[HITL] Webhook failed:', error)
setClawbotStatus('patrolling')
} finally {
setIsSimulating(false)
setTimeout(() => {
setClawbotStatus('patrolling')
setCurrentAlertType(undefined)
}, 2000)
}
}, [fetchPending, addTimelineEvent])
// Handle approval sign with RBAC check (Phase 3) + Timeline Logging (Phase 4)
const handleApprove = useCallback(async (id: string, riskLevel?: RiskLevel) => {
// Phase 3: 權限檢查
const level = riskLevel || 'medium'
if (!canSignApproval(currentUserRole, level)) {
// 顯示 Access Denied 模態框
setAccessDeniedModal({
show: true,
riskLevel: level,
requiredRoles: getRequiredRolesDisplay(level),
})
// Phase 4: Log SECURITY Access Denied (紅色微光)
addTimelineEvent({
type: 'security',
status: 'error',
title: `${currentUserRole.toUpperCase()} 嘗試簽核,觸發 Access Denied`,
description: `需要 ${getRequiredRolesDisplay(level)} 角色`,
actor: currentUserName,
actorRole: currentUserRole,
riskLevel: level,
approvalId: id,
})
console.warn('[HITL] Access Denied:', {
user: currentUserName,
role: currentUserRole,
riskLevel: level,
requiredRoles: getRequiredRolesDisplay(level),
})
return
}
// Phase 4: Log HUMAN signature (綠色微光)
addTimelineEvent({
type: 'human',
status: 'success',
title: `${currentUserRole.toUpperCase()} 成功簽核批准`,
actor: currentUserName,
actorRole: currentUserRole,
riskLevel: level,
approvalId: id,
})
const result = await signApproval(id, 'demo-user', currentUserName, 'Approved via demo UI')
// Check if approval is now complete - Log EXEC
if (result?.execution_triggered) {
addTimelineEvent({
type: 'exec',
status: 'success',
title: '多重簽章完成,指令已執行',
actor: 'OpenClaw',
actorRole: 'ai',
riskLevel: level,
approvalId: id,
})
}
await fetchPending()
}, [signApproval, fetchPending, currentUserRole, addTimelineEvent])
// Handle rejection
const handleReject = useCallback(async (id: string) => {
// For demo, just refresh
await fetchPending()
}, [fetchPending])
return (
<section className={cn('space-y-6', className)}>
{/* Header - Lab-White Style */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ShieldCheck className="w-6 h-6 text-claw-blue" />
<h2 className="font-dot-matrix text-2xl text-nothing-gray-800 tracking-wide">
{t('hitlRealApi')}
</h2>
<span className="px-2 py-0.5 rounded text-xs font-dot-matrix bg-claw-blue/10 text-claw-blue border border-claw-blue/20">
Multi-Sig
</span>
</div>
{/* Trigger Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => simulateAlert('k8s_pod_crash', 'critical')}
disabled={isSimulating}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
'font-mono text-xs font-medium',
'bg-status-critical/10 text-status-critical border border-status-critical/20',
'hover:bg-status-critical/20 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSimulating ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Plus className="w-3 h-3" />
)}
{t('addCritical')}
</button>
<button
onClick={() => simulateAlert('high_cpu', 'warning')}
disabled={isSimulating}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
'font-mono text-xs font-medium',
'bg-status-warning/10 text-status-warning border border-status-warning/20',
'hover:bg-status-warning/20 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSimulating ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Plus className="w-3 h-3" />
)}
{t('addMedium')}
</button>
</div>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* OpenClaw Panel (Left) */}
<div className="lg:col-span-1">
<OpenClawPanel
status={openclawStatus}
alertType={currentAlertType}
/>
</div>
{/* Approval Cards (Center) */}
<div className="lg:col-span-3">
{pendingApprovals.length === 0 ? (
<GlassCard className="flex flex-col items-center justify-center py-12">
<ShieldCheck className="w-12 h-12 text-nothing-gray-300 mb-4" />
<GlassCardTitle className="text-nothing-gray-500">
{tApproval('noApprovals')}
</GlassCardTitle>
<p className="text-sm text-nothing-gray-400 mt-2 font-mono">
[SYS] All clear. Awaiting alerts...
</p>
</GlassCard>
) : (
<div className={cn(
'space-y-4',
showCards ? 'animate-slide-in-up' : 'opacity-0'
)}>
{pendingApprovals.map((approval) => {
const frontendApproval = toFrontendApproval(approval)
const hasPermission = canSignApproval(currentUserRole, frontendApproval.riskLevel)
return (
<div key={approval.id} className="relative">
<ApprovalCard
request={frontendApproval}
onApprove={(id) => handleApprove(id, frontendApproval.riskLevel)}
onReject={handleReject}
holdDuration={approval.risk_level === 'critical' ? 2000 : 1000}
/>
{/* Phase 3: Permission Warning Badge */}
{!hasPermission && (
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-1 bg-status-critical/10 border border-status-critical/30 rounded text-[10px] font-mono text-status-critical z-10">
<Lock className="w-3 h-3" />
{getRequiredRolesDisplay(frontendApproval.riskLevel)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Action Timeline (Right) - Phase 4 */}
<div className="lg:col-span-1">
<GlassCard className="h-full" padding="md">
<ActionTimeline maxDisplay={8} />
</GlassCard>
</div>
</div>
{/* Phase 3 + 4: Current User Role Display with Switcher */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Current User */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-nothing-gray-100 rounded-lg">
<Lock className="w-3.5 h-3.5 text-nothing-gray-500" />
<span className="font-mono text-xs text-nothing-gray-600">
: <span className="font-bold text-nothing-gray-800">{currentUserName}</span>
</span>
<span className={cn(
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
currentUserRole === 'cto' || currentUserRole === 'ciso' || currentUserRole === 'ceo'
? 'bg-status-healthy/10 text-status-healthy border border-status-healthy/30'
: currentUserRole === 'admin'
? 'bg-claw-blue/10 text-claw-blue border border-claw-blue/30'
: 'bg-status-warning/10 text-status-warning border border-status-warning/30'
)}>
{currentUserRole}
</span>
</div>
{/* Role Switcher (Demo Only) */}
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-nothing-gray-400">:</span>
<button
onClick={() => setCurrentUserRole('devops')}
className={cn(
'px-2 py-1 rounded text-[10px] font-mono font-medium transition-colors',
currentUserRole === 'devops'
? 'bg-status-warning/20 text-status-warning'
: 'bg-nothing-gray-100 text-nothing-gray-500 hover:bg-nothing-gray-200'
)}
>
DevOps
</button>
<button
onClick={() => setCurrentUserRole('cto')}
className={cn(
'px-2 py-1 rounded text-[10px] font-mono font-medium transition-colors',
currentUserRole === 'cto'
? 'bg-status-healthy/20 text-status-healthy'
: 'bg-nothing-gray-100 text-nothing-gray-500 hover:bg-nothing-gray-200'
)}
>
CTO
</button>
</div>
</div>
{/* Debug Info */}
<div className="text-[10px] font-mono text-nothing-gray-400">
Pending: {pendingApprovals.length} | Status: {openclawStatus}
</div>
</div>
{/* Phase 3: Access Denied Modal (Nothing.tech Style) */}
{accessDeniedModal?.show && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
<GlassCard className="w-full max-w-md mx-4" variant="elevated" padding="lg">
<div className="text-center py-6">
{/* Icon */}
<div className="mx-auto w-16 h-16 rounded-full bg-status-critical/10 flex items-center justify-center mb-4">
<ShieldX className="w-8 h-8 text-status-critical" />
</div>
{/* Title */}
<h3 className="font-dot-matrix text-2xl text-status-critical mb-2">
ACCESS DENIED
</h3>
{/* Risk Level Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-status-critical/10 border border-status-critical/30 mb-4">
<AlertTriangle className="w-4 h-4 text-status-critical" />
<span className="font-mono text-sm text-status-critical font-semibold uppercase">
{accessDeniedModal.riskLevel} RISK
</span>
</div>
{/* Message */}
<p className="text-nothing-gray-600 font-mono text-sm mb-2">
</p>
<p className="text-nothing-gray-500 font-mono text-xs mb-6">
: <span className="text-status-warning font-bold">{currentUserRole.toUpperCase()}</span>
</p>
{/* Required Roles */}
<div className="bg-nothing-gray-50 rounded-lg p-4 mb-6">
<p className="text-[10px] text-nothing-gray-500 font-mono uppercase tracking-wider mb-2">
</p>
<div className="flex flex-wrap justify-center gap-2">
{accessDeniedModal.requiredRoles.split(' / ').map((role) => (
<span
key={role}
className="px-2 py-1 rounded bg-status-healthy/10 text-status-healthy font-mono text-xs font-bold border border-status-healthy/30"
>
{role}
</span>
))}
</div>
</div>
{/* Action Button */}
<button
onClick={() => setAccessDeniedModal(null)}
className="w-full px-6 py-3 rounded-xl font-mono text-sm font-semibold bg-nothing-gray-100 text-nothing-gray-700 hover:bg-nothing-gray-200 transition-colors"
>
</button>
</div>
</GlassCard>
</div>
)}
</section>
)
}
export default HITLSection

View File

@@ -0,0 +1,21 @@
/**
* AI Components - OpenClaw Visual Integration
* ===========================================
* Phase 5: OpenClaw 實體化升級 (2026-03-21)
*/
export { AIThinkingPanel, type ThinkingPhase, type AIThinkingPanelProps } from './ai-thinking-panel'
export { HITLSection } from './hitl-section'
export { AICommandPanel } from './ai-command-panel'
export { ThinkingStream, DEFAULT_THINKING_MESSAGES, type ThinkingMessage, type ThinkingStreamProps } from './thinking-stream'
// =============================================================================
// OpenClaw Components (Phase 5)
// =============================================================================
export { OpenClawPanel, type OpenClawStatus, type OpenClawPanelProps } from './openclaw-panel'
export { OpenClawStateMachine, type MachineState, type OpenClawStateMachineProps } from './openclaw-state-machine'
// =============================================================================
// Phase 5 完工: OpenClaw 過渡別名已移除
// 全專案已 100% 使用 OpenClaw
// =============================================================================

View File

@@ -0,0 +1,439 @@
'use client'
/**
* OpenClawPanel - 賽博維運風格 AI 面板
* =====================================
* Phase 5: OpenClaw 實體化升級
*
* Features:
* - 3D 骨架機械爪視覺化 (CSS Art)
* - 核心藍色 LED 脈衝動畫
* - 點陣字體狀態顯示
* - AI 思考流過渡動畫
* - 高通透度 awoooi-glass 效果
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Sparkles } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export type OpenClawStatus =
| 'patrolling' // [AGENT] patrolling...
| 'intercepting' // [SYS] 攔截異常...
| 'analyzing' // [SYS] Analyzing blast radius...
| 'generating' // [SYS] Generating proposed action...
| 'complete' // [SYS] Analysis complete
export interface OpenClawPanelProps {
status?: OpenClawStatus
alertType?: string
onAnalysisComplete?: () => void
className?: string
}
// =============================================================================
// Status Messages (Dot Matrix Style)
// =============================================================================
const STATUS_MESSAGES: Record<OpenClawStatus, string> = {
patrolling: '[AGENT] patrolling...',
intercepting: '[SYS] Intercepting anomaly...',
analyzing: '[SYS] Analyzing blast radius...',
generating: '[SYS] Generating proposed action...',
complete: '[SYS] Analysis complete',
}
// =============================================================================
// NemoClaw 3D Ceramic SVG Component (Lab-White Style)
// =============================================================================
function NemoClaw({ isActive, isPulsing }: { isActive: boolean; isPulsing: boolean }) {
return (
<svg
viewBox="0 0 140 140"
className="w-full h-full drop-shadow-lg"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{/* 3D Ceramic gradient - white/cream tones */}
<linearGradient id="ceramic3d" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FFFFFF" />
<stop offset="40%" stopColor="#F8F8F8" />
<stop offset="70%" stopColor="#E8E8E8" />
<stop offset="100%" stopColor="#D8D8D8" />
</linearGradient>
{/* Core glow filter - stronger */}
<filter id="coreGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
{/* Pulse glow animation filter */}
<filter id="pulseGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="8" result="blur">
<animate attributeName="stdDeviation" values="6;10;6" dur="1.5s" repeatCount="indefinite" />
</feGaussianBlur>
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
{/* Shadow for 3D effect */}
<filter id="shadow3d" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="4" stdDeviation="3" floodOpacity="0.15" />
</filter>
</defs>
{/* Base shadow */}
<ellipse cx="70" cy="125" rx="35" ry="8" fill="rgba(0,0,0,0.08)" />
{/* Main body - 3D ceramic sphere */}
<circle
cx="70"
cy="70"
r="32"
fill="url(#ceramic3d)"
filter="url(#shadow3d)"
stroke="#E0E0E0"
strokeWidth="1"
/>
{/* Inner ring - depth effect */}
<circle
cx="70"
cy="70"
r="26"
fill="none"
stroke="#D0D0D0"
strokeWidth="1"
opacity="0.5"
/>
{/* Core LED - Blue pulsing (the eye) */}
<circle
cx="70"
cy="70"
r="16"
fill={isActive ? '#4A90D9' : '#B0B0B0'}
filter={isPulsing ? 'url(#pulseGlow)' : 'url(#coreGlow)'}
className="transition-all duration-500"
>
{isPulsing && (
<animate
attributeName="r"
values="14;17;14"
dur="1s"
repeatCount="indefinite"
/>
)}
</circle>
{/* Core highlight */}
<circle
cx="70"
cy="70"
r="8"
fill="white"
opacity={isActive ? 0.9 : 0.4}
className="transition-opacity duration-300"
/>
{/* Claw arms - ceramic white 3D style */}
{/* Top arm */}
<g filter="url(#shadow3d)">
<path
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
{/* Claw tips */}
<circle cx="58" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
<circle cx="82" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Left arm */}
<g filter="url(#shadow3d)">
<path
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="6" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
<circle cx="6" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Right arm */}
<g filter="url(#shadow3d)">
<path
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="134" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
<circle cx="134" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Bottom left arm */}
<g filter="url(#shadow3d)">
<path
d="M 48 92 L 28 112 L 16 116"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 48 92 L 28 112 L 16 116"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="16" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Bottom right arm */}
<g filter="url(#shadow3d)">
<path
d="M 92 92 L 112 112 L 124 116"
stroke="url(#ceramic3d)"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M 92 92 L 112 112 L 124 116"
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<circle cx="124" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
</g>
{/* Orbit ring when active */}
{isActive && (
<circle
cx="70"
cy="70"
r="42"
fill="none"
stroke="#4A90D9"
strokeWidth="1"
strokeDasharray="6 6"
opacity="0.4"
className="animate-spin"
style={{ animationDuration: '8s', transformOrigin: '70px 70px' }}
/>
)}
</svg>
)
}
// =============================================================================
// Typewriter Hook
// =============================================================================
function useTypewriter(text: string, speed: number = 50) {
const [displayText, setDisplayText] = useState('')
useEffect(() => {
let index = 0
setDisplayText('')
const interval = setInterval(() => {
if (index < text.length) {
setDisplayText(text.slice(0, index + 1))
index++
} else {
clearInterval(interval)
}
}, speed)
return () => clearInterval(interval)
}, [text, speed])
return displayText
}
// =============================================================================
// Component
// =============================================================================
export function OpenClawPanel({
status = 'patrolling',
alertType,
onAnalysisComplete,
className,
}: OpenClawPanelProps) {
const t = useTranslations('ai')
const [cursorVisible, setCursorVisible] = useState(true)
const isActive = status !== 'patrolling'
const isPulsing = status === 'intercepting' || status === 'analyzing'
const statusMessage = STATUS_MESSAGES[status]
const displayText = useTypewriter(statusMessage, 40)
// Cursor blink
useEffect(() => {
const interval = setInterval(() => {
setCursorVisible((v) => !v)
}, 500)
return () => clearInterval(interval)
}, [])
// Notify when complete
useEffect(() => {
if (status === 'complete') {
const timeout = setTimeout(() => {
onAnalysisComplete?.()
}, 1000)
return () => clearTimeout(timeout)
}
}, [status, onAnalysisComplete])
return (
<div
className={cn(
'relative overflow-hidden rounded-xl',
// awoooi-glass effect
'bg-white/70 backdrop-blur-[20px]',
// 骨架細框線
'border border-nothing-gray-200/50',
// 輕柔陰影
'shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
'p-4',
className
)}
>
{/* Scan line animation when active */}
{isActive && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute h-[1px] w-full bg-gradient-to-r from-transparent via-claw-blue/50 to-transparent animate-scan" />
</div>
)}
{/* Header - Dot Matrix Style */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="font-dot-matrix text-sm text-nothing-gray-700 tracking-wider">
AWOOOI v1.0.0
</span>
<span className="font-dot-matrix text-xs text-nothing-gray-400">
| Production
</span>
{isActive && (
<span className="w-2 h-2 rounded-full bg-claw-blue animate-ping" />
)}
</div>
{alertType && (
<span className="px-2 py-0.5 rounded text-[10px] font-dot-matrix bg-status-critical/10 text-status-critical border border-status-critical/20">
{alertType}
</span>
)}
</div>
{/* NemoClaw 3D Ceramic Visualization */}
<div className="relative w-32 h-32 mx-auto mb-4">
<NemoClaw isActive={isActive} isPulsing={isPulsing} />
{/* Sparkle effects when active */}
{isActive && (
<>
<Sparkles className="absolute -top-1 -right-1 w-4 h-4 text-claw-blue/60 animate-pulse" />
<Sparkles className="absolute -bottom-1 -left-1 w-3 h-3 text-claw-blue/40 animate-pulse" style={{ animationDelay: '0.3s' }} />
</>
)}
</div>
{/* Status Display - VT323 Dot Matrix Font */}
<div className="text-center bg-nothing-gray-50/50 rounded-lg py-2 px-3">
<div className={cn(
'font-dot-matrix text-base tracking-wide',
isActive ? 'text-claw-blue' : 'text-nothing-gray-600'
)}>
<span>{displayText}</span>
<span
className={cn(
'inline-block w-2 h-4 ml-1 -mb-0.5',
isActive ? 'bg-claw-blue' : 'bg-nothing-gray-400',
cursorVisible ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
</div>
{/* Progress indicator when analyzing */}
{(status === 'analyzing' || status === 'generating') && (
<div className="mt-3 flex justify-center gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-claw-blue animate-pulse"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</div>
)}
{/* Complete indicator */}
{status === 'complete' && (
<div className="mt-3 flex justify-center">
<span className="px-2 py-0.5 rounded text-[9px] font-mono bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
READY
</span>
</div>
)}
</div>
)
}
export default OpenClawPanel

View File

@@ -0,0 +1,354 @@
'use client'
/**
* OpenClawStateMachine - 戰情室 AI 狀態機整合
* ==========================================
* Phase 5: OpenClaw 實體化升級
*
* Features:
* - 三態狀態機 (idle / thinking / awaiting_approval)
* - 真實 API 輪詢 (/api/v1/approvals/pending)
* - ThinkingStream 打字機動畫
* - ApprovalCard 滑入動畫
* - 記憶體安全清理
*
* 真實性條款: 禁止任何 Mock Data
* i18n: 100% next-intl
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
import { ThinkingStream, DEFAULT_THINKING_MESSAGES } from './thinking-stream'
import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card'
import { RefreshCw, AlertCircle, CheckCircle2 } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export type MachineState = 'idle' | 'thinking' | 'awaiting_approval'
export interface OpenClawStateMachineProps {
/** 啟用 Demo 模式 (僅供開發測試) */
demoMode?: boolean
/** API 輪詢間隔 (ms) */
pollInterval?: number
className?: string
}
interface PendingApprovalsResponse {
count: number
approvals: ApprovalRequest[]
}
// =============================================================================
// 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
}
// API response uses snake_case, frontend uses camelCase
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformApiResponse(apiApproval: any): ApprovalRequest {
return {
id: apiApproval.id,
action: apiApproval.action,
description: apiApproval.description,
riskLevel: apiApproval.risk_level,
blastRadius: {
affectedPods: apiApproval.blast_radius?.affected_pods ?? 0,
estimatedDowntime: apiApproval.blast_radius?.estimated_downtime ?? '0',
relatedServices: apiApproval.blast_radius?.related_services ?? [],
dataImpact: ((apiApproval.blast_radius?.data_impact ?? 'none').toUpperCase()) as 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE',
},
dryRunChecks: apiApproval.dry_run_checks ?? [],
requiredSignatures: apiApproval.required_signatures ?? 2,
currentSignatures: apiApproval.current_signatures ?? 0,
requestedBy: apiApproval.requested_by ?? 'OpenClaw',
requestedAt: apiApproval.created_at ?? new Date().toISOString(),
hitCount: apiApproval.hit_count,
lastSeenAt: apiApproval.last_seen_at,
fingerprint: apiApproval.fingerprint,
}
}
// =============================================================================
// Component
// =============================================================================
export function OpenClawStateMachine({
demoMode = false,
pollInterval = 5000,
className,
}: OpenClawStateMachineProps) {
const t = useTranslations()
// State
const [machineState, setMachineState] = useState<MachineState>('idle')
const [openclawStatus, setOpenclawStatus] = useState<OpenClawStatus>('patrolling')
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastFetch, setLastFetch] = useState<Date | null>(null)
// Timer refs for cleanup
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
// ==========================================================================
// API: Fetch Pending Approvals
// ==========================================================================
const fetchPendingApprovals = useCallback(async () => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/pending`, {
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
const rawData = await response.json()
const data: PendingApprovalsResponse = {
count: rawData.count,
approvals: (rawData.approvals ?? []).map(transformApiResponse),
}
setPendingApprovals(data.approvals)
setLastFetch(new Date())
// Update machine state based on approvals
if (data.count > 0) {
setMachineState('awaiting_approval')
setOpenclawStatus('complete')
} else {
setMachineState('idle')
setOpenclawStatus('patrolling')
}
console.log('[OpenClaw] Fetched approvals:', data.count)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
console.error('[OpenClaw] Fetch error:', message)
} finally {
setIsLoading(false)
}
}, [])
// ==========================================================================
// API: Submit Signature
// ==========================================================================
// 🔧 修復 Multi-Sig Bug: 每次簽核使用不同的 signer_id
const signerCounter = useRef(0)
const handleApprove = useCallback(async (approvalId: string) => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
// Multi-Sig: 輪替簽核者身份
const signers = [
{ id: 'cto-ogt', name: '統帥 (CTO)' },
{ id: 'ciso-security', name: '資安長 (CISO)' },
{ id: 'cpo-product', name: '產品長 (CPO)' },
]
const signer = signers[signerCounter.current % signers.length]
signerCounter.current++
try {
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signer_id: signer.id,
signer_name: signer.name,
comment: 'Approved via AWOOOI Dashboard',
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `Sign failed: ${response.status}`)
}
console.log('[OpenClaw] Approval signed by', signer.name, ':', approvalId)
// Refresh approvals list
await fetchPendingApprovals()
} catch (err) {
console.error('[OpenClaw] Sign error:', err)
setError(err instanceof Error ? err.message : 'Sign failed')
}
}, [fetchPendingApprovals])
// ==========================================================================
// API: Reject Request
// ==========================================================================
const handleReject = useCallback(async (approvalId: string) => {
const apiBaseUrl = getApiBaseUrl()
if (!apiBaseUrl) return
try {
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rejector_id: 'war-room-user',
rejector_name: 'War Room User',
reason: 'Rejected via AWOOOI Dashboard',
}),
})
if (!response.ok) {
throw new Error(`Reject failed: ${response.status}`)
}
console.log('[OpenClaw] Approval rejected:', approvalId)
// Refresh approvals list
await fetchPendingApprovals()
} catch (err) {
console.error('[OpenClaw] Reject error:', err)
setError(err instanceof Error ? err.message : 'Reject failed')
}
}, [fetchPendingApprovals])
// ==========================================================================
// Polling Effect
// ==========================================================================
useEffect(() => {
// Skip polling in demo mode
if (demoMode) return
// Initial fetch
fetchPendingApprovals()
// Start polling
pollTimerRef.current = setInterval(fetchPendingApprovals, pollInterval)
// Cleanup
return () => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
}, [demoMode, pollInterval, fetchPendingApprovals])
// ==========================================================================
// Render
// ==========================================================================
return (
<div className={cn('space-y-4', className)}>
{/* Status Bar */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-nothing-gray-500">
STATE:
</span>
<span
className={cn(
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
machineState === 'idle' && 'bg-nothing-gray-100 text-nothing-gray-600',
machineState === 'thinking' && 'bg-status-thinking/10 text-status-thinking',
machineState === 'awaiting_approval' && 'bg-status-warning/10 text-status-warning'
)}
>
{machineState}
</span>
</div>
<div className="flex items-center gap-2">
{/* Loading indicator */}
{isLoading && (
<RefreshCw className="w-3 h-3 text-nothing-gray-400 animate-spin" />
)}
{/* Error indicator */}
{error && (
<div className="flex items-center gap-1 text-status-critical">
<AlertCircle className="w-3 h-3" />
<span className="text-[10px] font-mono">{error}</span>
</div>
)}
{/* Manual refresh button */}
<button
onClick={fetchPendingApprovals}
disabled={isLoading}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-[10px] 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="w-3 h-3" />
REFRESH
</button>
</div>
</div>
{/* OpenClaw Visual */}
<OpenClawPanel
status={openclawStatus}
alertType={pendingApprovals.length > 0 ? 'POD_CRASH' : undefined}
/>
{/* Pending Approvals List (REAL DATA) */}
{pendingApprovals.length > 0 && (
<div className="space-y-3">
{pendingApprovals.map((approval) => (
<div key={approval.id} className="animate-slide-in-up">
<ApprovalCard
request={approval}
onApprove={() => handleApprove(approval.id)}
onReject={() => handleReject(approval.id)}
holdDuration={1000}
/>
</div>
))}
</div>
)}
{/* Idle state - no pending approvals */}
{machineState === 'idle' && pendingApprovals.length === 0 && !isLoading && (
<div className="text-center py-6 border border-dashed border-nothing-gray-200 rounded-lg">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-status-healthy/50" />
<p className="font-mono text-xs text-nothing-gray-400">
{t('ai.standby')}
</p>
<p className="font-mono text-[10px] text-nothing-gray-300 mt-1">
{lastFetch
? `Last check: ${lastFetch.toLocaleTimeString()}`
: 'Waiting for first fetch...'}
</p>
</div>
)}
{/* Demo mode warning */}
{demoMode && (
<div className="bg-status-warning/10 border border-status-warning/20 rounded-lg p-3">
<p className="font-mono text-xs text-status-warning">
Demo mode is enabled. Real API polling is disabled.
</p>
</div>
)}
</div>
)
}
export default OpenClawStateMachine

View File

@@ -0,0 +1,282 @@
'use client'
/**
* ThinkingStream - AI 思考流打字機動畫
* =====================================
* Phase 1: OpenClaw 靈魂注入
*
* Features:
* - 打字機效果 (Typewriter) 逐字顯示
* - VT323 點陣字體 + 思維紫色調
* - 極簡終端機風格 (Terminal Style)
* - 記憶體安全清理 (cleanup 必須清除所有計時器)
*
* 視覺規範:
* - 禁止 Chat Bubble 對話框
* - 純終端機文字流
* - 閃爍游標動畫
*
* i18n: 100% next-intl
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export interface ThinkingMessage {
id: string
prefix: '[SYS]' | '[AGENT]' | '[SCAN]' | '[CALC]'
messageKey: string // i18n key
delay?: number // 打字速度 (ms per char)
}
export interface ThinkingStreamProps {
messages: ThinkingMessage[]
onComplete?: () => void
className?: string
/** 是否顯示游標 */
showCursor?: boolean
/** 打字速度 (ms per char) */
typeSpeed?: number
}
// =============================================================================
// 預設思考訊息序列
// =============================================================================
export const DEFAULT_THINKING_MESSAGES: ThinkingMessage[] = [
{ id: '1', prefix: '[SYS]', messageKey: 'ai.intercepting' },
{ id: '2', prefix: '[AGENT]', messageKey: 'ai.analyzing' },
{ id: '3', prefix: '[CALC]', messageKey: 'ai.calculating' },
{ id: '4', prefix: '[SYS]', messageKey: 'ai.generating' },
{ id: '5', prefix: '[SYS]', messageKey: 'ai.complete' },
]
// =============================================================================
// Typewriter Hook (記憶體安全版)
// =============================================================================
function useTypewriter(
text: string,
speed: number = 35,
onComplete?: () => void
) {
const [displayText, setDisplayText] = useState('')
const [isComplete, setIsComplete] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(0)
// Reset on text change
useEffect(() => {
setDisplayText('')
setIsComplete(false)
indexRef.current = 0
}, [text])
// Typewriter effect with cleanup
useEffect(() => {
if (!text || isComplete) return
const typeNextChar = () => {
if (indexRef.current < text.length) {
setDisplayText(text.slice(0, indexRef.current + 1))
indexRef.current++
timeoutRef.current = setTimeout(typeNextChar, speed)
} else {
setIsComplete(true)
onComplete?.()
}
}
// Start typing
timeoutRef.current = setTimeout(typeNextChar, speed)
// ⚠️ CRITICAL: 記憶體安全清理
// 必須在 unmount 時清除所有 setTimeout
// 否則會造成記憶體洩漏與 setState on unmounted component
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [text, speed, isComplete, onComplete])
return { displayText, isComplete }
}
// =============================================================================
// Single Line Component
// =============================================================================
interface ThinkingLineProps {
prefix: string
message: string
typeSpeed: number
onComplete?: () => void
showCursor: boolean
}
function ThinkingLine({
prefix,
message,
typeSpeed,
onComplete,
showCursor,
}: ThinkingLineProps) {
const { displayText, isComplete } = useTypewriter(message, typeSpeed, onComplete)
const [cursorVisible, setCursorVisible] = useState(true)
// Cursor blink animation
useEffect(() => {
if (!showCursor) return
const interval = setInterval(() => {
setCursorVisible((v) => !v)
}, 530)
// ⚠️ 記憶體安全清理
return () => clearInterval(interval)
}, [showCursor])
return (
<div className="flex items-start gap-2 font-mono text-sm leading-relaxed">
{/* Prefix */}
<span
className={cn(
'shrink-0 font-bold',
prefix === '[SYS]' && 'text-status-thinking',
prefix === '[AGENT]' && 'text-claw-blue',
prefix === '[SCAN]' && 'text-status-warning',
prefix === '[CALC]' && 'text-status-info'
)}
>
{prefix}
</span>
{/* Message with cursor */}
<span className="text-nothing-gray-700">
{displayText}
{showCursor && !isComplete && (
<span
className={cn(
'inline-block w-2 h-4 ml-0.5 -mb-0.5 bg-status-thinking',
cursorVisible ? 'opacity-100' : 'opacity-0'
)}
/>
)}
</span>
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function ThinkingStream({
messages,
onComplete,
className,
showCursor = true,
typeSpeed = 35,
}: ThinkingStreamProps) {
const t = useTranslations()
const [currentIndex, setCurrentIndex] = useState(0)
const [completedLines, setCompletedLines] = useState<string[]>([])
const completedRef = useRef(false)
// Handle line completion
const handleLineComplete = useCallback(() => {
const nextIndex = currentIndex + 1
// Store completed line
if (messages[currentIndex]) {
setCompletedLines((prev) => [...prev, messages[currentIndex].id])
}
// Move to next line or complete
if (nextIndex < messages.length) {
// Small delay between lines
setTimeout(() => {
setCurrentIndex(nextIndex)
}, 300)
} else if (!completedRef.current) {
completedRef.current = true
setTimeout(() => {
onComplete?.()
}, 500)
}
}, [currentIndex, messages, onComplete])
// Reset when messages change
useEffect(() => {
setCurrentIndex(0)
setCompletedLines([])
completedRef.current = false
}, [messages])
return (
<div
className={cn(
// Terminal style container
'bg-nothing-gray-50/50 rounded-lg p-3',
'border border-nothing-gray-200/50',
// Subtle scan line effect
'relative overflow-hidden',
className
)}
>
{/* Scan line animation */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div
className="absolute h-[1px] w-full bg-gradient-to-r from-transparent via-status-thinking/30 to-transparent animate-scan"
style={{ animationDuration: '3s' }}
/>
</div>
{/* Terminal content */}
<div className="relative space-y-1.5">
{/* Completed lines */}
{messages.slice(0, currentIndex).map((msg) => (
<div
key={msg.id}
className="flex items-start gap-2 font-mono text-sm leading-relaxed opacity-60"
>
<span
className={cn(
'shrink-0 font-bold',
msg.prefix === '[SYS]' && 'text-status-thinking',
msg.prefix === '[AGENT]' && 'text-claw-blue',
msg.prefix === '[SCAN]' && 'text-status-warning',
msg.prefix === '[CALC]' && 'text-status-info'
)}
>
{msg.prefix}
</span>
<span className="text-nothing-gray-600">
{t(msg.messageKey)}
</span>
</div>
))}
{/* Current typing line */}
{messages[currentIndex] && (
<ThinkingLine
prefix={messages[currentIndex].prefix}
message={t(messages[currentIndex].messageKey)}
typeSpeed={messages[currentIndex].delay ?? typeSpeed}
onComplete={handleLineComplete}
showCursor={showCursor}
/>
)}
</div>
</div>
)
}
export default ThinkingStream

View File

@@ -0,0 +1,675 @@
'use client'
/**
* ApprovalCard - CPO-107 HITL 授權卡片
* =====================================
* Nothing.tech 明亮工業風 + 防誤觸機制
* Emergency Hotfix: 移除光害特效,回歸精密極簡
*
* i18n: 100% 使用 useTranslations禁止任何寫死字串
*
* Visual Features:
* - CRITICAL 風險: 左側紅色粗邊框 (border-l-4)
* - 乾淨白色背景,確保資料可讀性
* - 精確的 Typography 層級
*/
import { useState, useRef, useCallback, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import {
GlassCard,
GlassCardHeader,
GlassCardTitle,
GlassCardContent,
GlassCardFooter,
} from '@/components/ui/glass-card'
import { StatusOrb } from '@/components/ui/status-orb'
import { cn } from '@/lib/utils'
import { AlertTriangle, CheckCircle2, XCircle, Clock, Shield, Zap } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
export interface DryRunCheck {
name: string
passed: boolean
message?: string
}
export interface BlastRadius {
affectedPods: number
estimatedDowntime: string
relatedServices: string[]
dataImpact: 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE'
}
export interface ApprovalRequest {
id: string
action: string
description: string
riskLevel: RiskLevel
blastRadius: BlastRadius
dryRunChecks: DryRunCheck[]
requiredSignatures: number
currentSignatures: number
requestedBy: string
requestedAt: string
// 戰略 B: 告警風暴收斂
hitCount?: number // 聚合觸發次數
lastSeenAt?: string // 最後觸發時間
fingerprint?: string // 告警指紋
}
export interface Signature {
id: string
signerName: string
signedAt: string
comment?: string
}
export interface ApprovalCardProps {
request: ApprovalRequest
signatures?: Signature[]
onApprove?: (id: string) => void
onReject?: (id: string) => void
className?: string
holdDuration?: number
isLoading?: boolean
/** 唯讀模式 (歷史紀錄用) */
readOnly?: boolean
/** 最終狀態標籤 (歷史紀錄用) */
finalStatus?: 'approved' | 'rejected' | 'executed' | 'failed'
}
// =============================================================================
// Config
// =============================================================================
const HOLD_DURATION_DEFAULT = 2000
// =============================================================================
// Long Press Button Component
// =============================================================================
interface LongPressButtonProps {
onComplete: () => void
disabled?: boolean
holdDuration?: number
label: string
labelHolding: string
className?: string
variant?: 'approve' | 'danger'
}
function LongPressButton({
onComplete,
disabled = false,
holdDuration = HOLD_DURATION_DEFAULT,
label,
labelHolding,
className,
variant = 'approve',
}: LongPressButtonProps) {
const [isHolding, setIsHolding] = useState(false)
const [progress, setProgress] = useState(0)
const [showRipple, setShowRipple] = useState(false)
const startTimeRef = useRef<number | null>(null)
const animationFrameRef = useRef<number | null>(null)
const updateProgress = useCallback(() => {
if (!startTimeRef.current) return
const elapsed = Date.now() - startTimeRef.current
const newProgress = Math.min((elapsed / holdDuration) * 100, 100)
setProgress(newProgress)
if (newProgress >= 100) {
setIsHolding(false)
setProgress(0)
startTimeRef.current = null
setShowRipple(true)
setTimeout(() => setShowRipple(false), 600)
onComplete()
} else {
animationFrameRef.current = requestAnimationFrame(updateProgress)
}
}, [holdDuration, onComplete])
const handlePointerDown = useCallback(() => {
if (disabled) return
setIsHolding(true)
startTimeRef.current = Date.now()
animationFrameRef.current = requestAnimationFrame(updateProgress)
}, [disabled, updateProgress])
const handlePointerUp = useCallback(() => {
setIsHolding(false)
setProgress(0)
startTimeRef.current = null
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}, [])
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [])
// Lab-White 虛線邊框按鈕風格 (Dashed Border Style)
const baseStyles = variant === 'danger'
? [
'bg-white',
'border-2 border-dashed border-status-critical',
'text-status-critical',
'hover:bg-status-critical/5',
'hover:border-solid',
]
: [
'bg-white',
'border-2 border-dashed border-claw-blue',
'text-claw-blue',
'hover:bg-claw-blue/5',
'hover:border-solid',
]
const progressBgColor = variant === 'danger' ? 'bg-status-critical' : 'bg-claw-blue'
return (
<button
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onPointerCancel={handlePointerUp}
disabled={disabled}
className={cn(
'relative overflow-hidden px-6 py-3.5 rounded-xl font-mono text-sm font-semibold',
'transition-all duration-300 ease-out select-none touch-none',
'active:scale-[0.97]',
disabled
? 'bg-nothing-gray-100 border-2 border-nothing-gray-200 text-nothing-gray-400 cursor-not-allowed'
: baseStyles,
isHolding && 'scale-[0.98]',
className
)}
aria-label={isHolding ? labelHolding : label}
>
{/* Progress fill */}
<div
className={cn(
'absolute inset-0 transition-none origin-left',
progressBgColor,
'opacity-25'
)}
style={{
width: `${progress}%`,
clipPath: 'inset(0 0 0 0 round 10px)',
}}
/>
{/* Ripple effect on complete */}
{showRipple && (
<span
className={cn(
'absolute inset-0 flex items-center justify-center',
'animate-ripple rounded-xl',
variant === 'danger' ? 'bg-status-critical/30' : 'bg-status-healthy/30'
)}
/>
)}
<span className="relative z-10 flex items-center justify-center gap-2">
{isHolding ? (
<>
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray="60" strokeDashoffset="20" />
</svg>
<span className="tabular-nums">{labelHolding} ({Math.round(progress)}%)</span>
</>
) : (
<>
{variant === 'danger' ? (
<AlertTriangle className="w-4 h-4" />
) : (
<Shield className="w-4 h-4" />
)}
{label}
</>
)}
</span>
</button>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function ApprovalCard({
request,
signatures,
onApprove,
onReject,
className,
holdDuration = HOLD_DURATION_DEFAULT,
isLoading = false,
readOnly = false,
finalStatus,
}: ApprovalCardProps) {
const t = useTranslations('approval')
const tRisk = useTranslations('risk')
const tBlast = useTranslations('blastRadius')
const tDryRun = useTranslations('dryRun')
// 微交互狀態: 處理中 + 滑出動畫
const [isProcessing, setIsProcessing] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const allChecksPassed = request.dryRunChecks?.every((c) => c.passed) ?? true
const needsMoreSignatures = request.currentSignatures < request.requiredSignatures
const hasPartialSignatures = request.currentSignatures > 0 && needsMoreSignatures
const isCritical = request.riskLevel === 'critical'
const isDestructive = request.blastRadius?.dataImpact === 'DESTRUCTIVE'
// 處理核准 - 加入微交互
const handleApproveWithAnimation = useCallback(() => {
setIsProcessing(true)
// 800ms 後開始滑出動畫,再呼叫父層
setTimeout(() => {
setIsExiting(true)
setTimeout(() => {
onApprove?.(request.id)
}, 300) // 滑出動畫時間
}, 800)
}, [onApprove, request.id])
// 處理拒絕 - 加入微交互
const handleRejectWithAnimation = useCallback(() => {
setIsProcessing(true)
setTimeout(() => {
setIsExiting(true)
setTimeout(() => {
onReject?.(request.id)
}, 300)
}, 500)
}, [onReject, request.id])
const riskLabel = tRisk(request.riskLevel)
const orbStatus = isCritical || request.riskLevel === 'high'
? 'critical'
: request.riskLevel === 'medium'
? 'warning'
: 'healthy'
const dataImpactLabel = tBlast(
request.blastRadius?.dataImpact === 'READ_ONLY' ? 'readOnly' :
request.blastRadius?.dataImpact === 'WRITE' ? 'write' :
request.blastRadius?.dataImpact === 'DESTRUCTIVE' ? 'destructive' : 'none'
)
// 舊的直接呼叫方式 (保留相容性,但現在使用動畫版本)
const handleApprove = handleApproveWithAnimation
const handleReject = handleRejectWithAnimation
const actualHoldDuration = isDestructive || isCritical ? holdDuration : holdDuration / 2
const holdSeconds = actualHoldDuration / 1000
return (
<GlassCard
variant={isCritical ? 'elevated' : 'default'}
padding="lg"
className={cn(
'relative transition-all duration-300',
// Nothing.tech 精密極簡風格
!readOnly && 'hover:-translate-y-0.5 hover:shadow-card-hover',
// CRITICAL: 左側紅色粗邊框 (乾淨、無光害)
isCritical && 'border-l-4 border-l-status-critical',
// MEDIUM: 左側黃色邊框
request.riskLevel === 'medium' && 'border-l-4 border-l-status-warning',
// LOW: 左側綠色邊框
request.riskLevel === 'low' && 'border-l-4 border-l-status-healthy',
// 處理中狀態
isProcessing && 'opacity-70 pointer-events-none',
// 滑出動畫
isExiting && 'translate-x-full opacity-0',
// 唯讀模式 (歷史紀錄)
readOnly && 'opacity-80 bg-nothing-gray-50',
className
)}
aria-label={`${request.action} - ${riskLabel}`}
>
{/* 歷史紀錄 - 最終狀態 Badge */}
{readOnly && finalStatus && (
<div className={cn(
'absolute top-3 right-3 flex items-center gap-1.5 px-2.5 py-1 rounded-lg',
'font-mono text-xs font-bold uppercase',
finalStatus === 'executed' && 'bg-status-healthy/10 text-status-healthy border border-status-healthy/20',
finalStatus === 'approved' && 'bg-claw-blue/10 text-claw-blue border border-claw-blue/20',
finalStatus === 'rejected' && 'bg-nothing-gray-100 text-nothing-gray-500 border border-nothing-gray-200',
finalStatus === 'failed' && 'bg-status-critical/10 text-status-critical border border-status-critical/20',
)}>
{finalStatus === 'executed' && <CheckCircle2 className="w-3 h-3" />}
{finalStatus === 'approved' && <Shield className="w-3 h-3" />}
{finalStatus === 'rejected' && <XCircle className="w-3 h-3" />}
{finalStatus === 'failed' && <AlertTriangle className="w-3 h-3" />}
{finalStatus === 'executed' ? '執行成功' :
finalStatus === 'approved' ? '已核准' :
finalStatus === 'rejected' ? '已拒絕' : '執行失敗'}
</div>
)}
{/* Header */}
<GlassCardHeader>
<div className="flex items-center gap-3">
<StatusOrb
status={orbStatus}
size="md"
glow={isCritical}
pulse={isCritical}
/>
{/* Risk Badge - 乾淨無光害 */}
<div
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg',
'font-mono text-xs font-semibold tracking-wide uppercase',
isCritical
? 'bg-status-critical/10 text-status-critical border border-status-critical/30'
: request.riskLevel === 'high'
? 'bg-status-warning/10 text-status-warning border border-status-warning/30'
: request.riskLevel === 'medium'
? 'bg-status-warning/10 text-status-warning border border-status-warning/30'
: 'bg-status-healthy/10 text-status-healthy border border-status-healthy/30'
)}
>
{isCritical ? (
<AlertTriangle className="w-3.5 h-3.5" />
) : request.riskLevel === 'low' ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
{riskLabel}
</div>
{/* 戰略 B: 告警聚合次數 Badge (Nothing.tech VT323 風格) */}
{request.hitCount && request.hitCount > 1 && (
<div
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg',
'font-heading text-sm font-bold tracking-wide', // VT323 點陣字體
'bg-nothing-gray-900/10 text-nothing-gray-700',
'border border-nothing-gray-300',
'animate-pulse-subtle', // 微妙脈動提示有新告警聚合
)}
title={`相同指紋告警已觸發 ${request.hitCount}`}
>
<span className="text-nothing-gray-500">×</span>
<span className="tabular-nums">{request.hitCount}</span>
<span className="text-[10px] text-nothing-gray-500 uppercase"></span>
</div>
)}
</div>
{/* Multi-Sig Counter - Enhanced */}
<div className="text-right">
<div className="text-[10px] text-nothing-gray-500 font-mono uppercase tracking-wider mb-1">
{t('signatures')}
</div>
<div
className={cn(
'text-2xl font-mono font-bold tabular-nums',
'transition-colors duration-200',
needsMoreSignatures ? 'text-status-warning' : 'text-status-healthy'
)}
>
{request.currentSignatures}
<span className="text-nothing-gray-400">/</span>
{request.requiredSignatures}
</div>
</div>
</GlassCardHeader>
{/* Title & Description */}
<div className="mb-5">
<GlassCardTitle className="text-nothing-gray-900 mb-2">
{request.action}
</GlassCardTitle>
<p className="text-sm text-nothing-gray-600">{request.description}</p>
</div>
<GlassCardContent>
{/* Blast Radius Grid - Enhanced */}
<div className="mb-5">
<h4 className="text-[10px] font-mono text-nothing-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2">
<Zap className="w-3 h-3" />
{tBlast('title')}
</h4>
{request.blastRadius && (
<div className="grid grid-cols-2 gap-3">
<MetricBox
label={tBlast('affectedPods')}
value={String(request.blastRadius.affectedPods ?? 0)}
highlight={(request.blastRadius.affectedPods ?? 0) > 5}
/>
<MetricBox
label={tBlast('estimatedDowntime')}
value={request.blastRadius.estimatedDowntime ?? '0'}
highlight={request.blastRadius.estimatedDowntime !== '0'}
/>
<div className="col-span-2">
<div className="bg-nothing-gray-50 rounded-lg p-3 border border-nothing-gray-200">
<div className="text-[10px] text-nothing-gray-500 font-mono uppercase tracking-wider mb-2">
{tBlast('relatedServices')}
</div>
<div className="flex flex-wrap gap-1.5">
{(request.blastRadius.relatedServices ?? []).map((svc) => (
<span
key={svc}
className="px-2 py-0.5 rounded text-xs font-mono bg-white border border-nothing-gray-200 text-nothing-gray-700"
>
{svc}
</span>
))}
</div>
</div>
</div>
</div>
)}
</div>
{/* Data Impact */}
{isDestructive && (
<div className="mb-5 p-4 bg-status-critical/5 border border-status-critical/20 rounded-lg">
<div className="flex items-center gap-2">
<StatusOrb status="critical" size="sm" pulse />
<span className="text-sm font-mono text-status-critical font-semibold">
{tBlast('dataImpact')}: {dataImpactLabel}
</span>
</div>
</div>
)}
{/* Dry-Run Checks - Enhanced */}
<div className="mb-5">
<h4 className="text-[10px] font-mono text-nothing-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2">
<Clock className="w-3 h-3" />
{tDryRun('validation')}
</h4>
<div className="space-y-2">
{(request.dryRunChecks ?? []).map((check) => (
<div
key={check.name}
className={cn(
'flex items-center justify-between rounded-lg px-3 py-2.5',
'transition-all duration-200',
check.passed
? 'bg-status-healthy/5 border border-status-healthy/10 hover:border-status-healthy/20'
: 'bg-status-critical/5 border border-status-critical/10 hover:border-status-critical/20'
)}
>
<div className="flex items-center gap-2.5">
{check.passed ? (
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
) : (
<XCircle className="w-4 h-4 text-status-critical" />
)}
<span className="text-sm text-nothing-gray-800 font-mono font-medium">
{check.name}
</span>
</div>
{check.message && (
<span className={cn(
'text-xs font-mono px-2 py-0.5 rounded',
check.passed
? 'bg-status-healthy/10 text-status-healthy'
: 'bg-status-critical/10 text-status-critical'
)}>
{check.message}
</span>
)}
</div>
))}
</div>
</div>
</GlassCardContent>
<GlassCardFooter>
{/* Meta */}
<div className="flex items-center justify-between mb-4">
<div className="text-xs text-nothing-gray-500 font-mono">
<span>{t('requestedBy')} </span>
<span className="text-nothing-gray-700">{request.requestedBy}</span>
<span className="mx-2">|</span>
<span>{request.requestedAt}</span>
</div>
</div>
{/* 處理中指示器 */}
{isProcessing && (
<div className="mb-4 p-4 bg-claw-blue/5 border border-claw-blue/20 rounded-lg">
<div className="flex items-center justify-center gap-3">
<svg className="w-5 h-5 text-claw-blue animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray="60" strokeDashoffset="20" />
</svg>
<span className="text-sm font-mono text-claw-blue font-semibold">
...
</span>
</div>
</div>
)}
{/* 唯讀模式 - 不顯示按鈕 */}
{readOnly ? (
<div className="text-center py-2">
<p className="text-xs text-nothing-gray-400 font-mono">
</p>
</div>
) : (
<>
{/* Partial Signatures Notice */}
{hasPartialSignatures && !isProcessing && (
<div className="mb-4 p-3 bg-status-warning/10 border border-status-warning/30 rounded-lg">
<div className="flex items-center gap-2">
<StatusOrb status="warning" size="sm" />
<span className="text-sm font-mono text-status-warning">
{t('waitingSecondSig')}
</span>
</div>
</div>
)}
{/* Action Buttons - Enhanced */}
{!isProcessing && (
<>
<div className="flex items-center gap-4">
<button
onClick={handleReject}
disabled={isLoading || isProcessing}
className={cn(
'flex-1 flex items-center justify-center gap-2',
'px-6 py-3.5 rounded-xl font-mono text-sm font-semibold',
'bg-nothing-gray-50 border-2 border-nothing-gray-200 text-nothing-gray-600',
'hover:border-nothing-gray-300 hover:bg-nothing-gray-100 hover:text-nothing-gray-800',
'active:scale-[0.97]',
'transition-all duration-200',
(isLoading || isProcessing) && 'opacity-50 cursor-not-allowed'
)}
>
<XCircle className="w-4 h-4" />
{t('reject')}
</button>
<LongPressButton
onComplete={handleApprove}
disabled={!allChecksPassed || isLoading || isProcessing}
holdDuration={actualHoldDuration}
label={
isLoading
? t('signing')
: needsMoreSignatures && request.currentSignatures === 0
? t('holdToSign')
: hasPartialSignatures
? t('holdToSign')
: isDestructive
? t('holdToConfirm')
: t('holdToApprove')
}
labelHolding={t('signing')}
variant={isDestructive || isCritical ? 'danger' : 'approve'}
className="flex-1"
/>
</div>
{/* Long Press Hint - Subtle */}
<p className="mt-4 text-[11px] text-nothing-gray-400 text-center font-mono tracking-wide">
{t('holdHint', {
seconds: holdSeconds,
action: hasPartialSignatures ? t('actionSign') : isDestructive ? t('actionConfirm') : t('actionApprove')
})}
</p>
</>
)}
</>
)}
</GlassCardFooter>
</GlassCard>
)
}
// =============================================================================
// Sub-components
// =============================================================================
interface MetricBoxProps {
label: string
value: string
className?: string
highlight?: boolean
}
function MetricBox({ label, value, className, highlight = false }: MetricBoxProps) {
return (
<div className={cn(
'rounded-lg p-3 border bg-nothing-gray-50',
highlight ? 'border-status-warning/30' : 'border-nothing-gray-200',
className
)}>
<div className="text-[10px] text-nothing-gray-500 font-mono uppercase tracking-wider mb-1">{label}</div>
<div className={cn(
'text-lg font-mono font-bold tabular-nums',
highlight ? 'text-status-warning' : 'text-nothing-gray-900'
)}>
{value}
</div>
</div>
)
}
export { LongPressButton }

View File

@@ -0,0 +1,90 @@
/**
* Approval Components
* ===================
* HITL (Human-in-the-Loop) 授權組件
*/
export {
ApprovalCard,
LongPressButton,
type ApprovalCardProps,
type ApprovalRequest,
type RiskLevel,
type BlastRadius,
type DryRunCheck,
type Signature,
} from './approval-card'
export { LiveApprovalPanel } from './live-approval-panel'
// =============================================================================
// Mock Data for Demo
// =============================================================================
export const MOCK_APPROVAL_HIGH: import('./approval-card').ApprovalRequest = {
id: 'apr-001',
action: 'Delete Pod: nginx-frontend-7d4b8c9f5-xk2m3',
description: 'Clean up unresponsive frontend Pod, ReplicaSet will auto-rebuild',
riskLevel: 'high',
blastRadius: {
affectedPods: 3,
estimatedDowntime: '~2 min',
relatedServices: ['nginx-ingress', 'frontend-svc', 'cdn-cache'],
dataImpact: 'NONE',
},
dryRunChecks: [
{ name: 'RBAC Permission', passed: true, message: 'cluster-admin' },
{ name: 'Syntax Valid', passed: true },
{ name: 'Resource Exists', passed: true, message: 'Pod found' },
{ name: 'Replica Count > 1', passed: true, message: '3 replicas' },
],
requiredSignatures: 2,
currentSignatures: 1,
requestedBy: 'OpenClaw',
requestedAt: '2026-03-20 14:32:05',
}
export const MOCK_APPROVAL_CRITICAL: import('./approval-card').ApprovalRequest = {
id: 'apr-002',
action: 'DROP TABLE: user_sessions',
description: 'Clear all user sessions, will force logout all users',
riskLevel: 'critical',
blastRadius: {
affectedPods: 0,
estimatedDowntime: '0',
relatedServices: ['auth-service', 'api-gateway', 'user-service'],
dataImpact: 'DESTRUCTIVE',
},
dryRunChecks: [
{ name: 'RBAC Permission', passed: true, message: 'db-admin' },
{ name: 'Syntax Valid', passed: true },
{ name: 'Table Exists', passed: true },
{ name: 'Backup Available', passed: false, message: 'No recent backup!' },
],
requiredSignatures: 2,
currentSignatures: 2,
requestedBy: 'OpenClaw',
requestedAt: '2026-03-20 14:45:12',
}
export const MOCK_APPROVAL_LOW: import('./approval-card').ApprovalRequest = {
id: 'apr-003',
action: 'Scale Deployment: api-backend',
description: 'Scale from 3 to 5 replicas for increased traffic',
riskLevel: 'low',
blastRadius: {
affectedPods: 5,
estimatedDowntime: '0',
relatedServices: ['api-backend'],
dataImpact: 'NONE',
},
dryRunChecks: [
{ name: 'RBAC Permission', passed: true, message: 'deployment-admin' },
{ name: 'Syntax Valid', passed: true },
{ name: 'Resource Quota', passed: true, message: '5/20 pods' },
],
requiredSignatures: 1,
currentSignatures: 1,
requestedBy: 'OpenClaw',
requestedAt: '2026-03-20 15:00:00',
}

View File

@@ -0,0 +1,406 @@
'use client'
/**
* LiveApprovalPanel - HITL 即時授權面板 + Phase 3 權限擋板
* ==========================================================
* 整合後端 API 的 Multi-Sig 授權流程
*
* Features:
* - 輪詢 GET /api/v1/approvals/pending
* - 真實 API 簽核與拒絕
* - Multi-Sig 狀態即時更新
* - 簽核成功動畫
* - **Phase 3: 權限擋板 (RBAC Check)**
* - CRITICAL 告警需要 CTO/CISO 權限
* - DevOps 角色長按時顯示 Access Denied
*/
import { useEffect, useState, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { useApprovalStore, usePendingApprovals, toFrontendApproval } from '@/stores/approval.store'
import { ApprovalCard, type ApprovalRequest, type RiskLevel } from './approval-card'
import {
GlassCard,
GlassCardHeader,
GlassCardTitle,
GlassCardContent,
} from '@/components/ui/glass-card'
import { StatusOrb } from '@/components/ui/status-orb'
import { cn } from '@/lib/utils'
import { ShieldX, Lock, AlertTriangle } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
type UserRole = 'viewer' | 'developer' | 'devops' | 'admin' | 'cto' | 'ciso' | 'ceo'
interface CurrentUser {
id: string
name: string
role: UserRole
}
interface LiveApprovalPanelProps {
className?: string
signerId?: string
signerName?: string
signerRole?: UserRole
}
// =============================================================================
// Permission Logic (Phase 3 企業護城河)
// =============================================================================
/**
* 風險矩陣權限檢查
*
* | Risk Level | 簽章數 | 允許角色 |
* |------------|--------|----------|
* | low | 0 | 全部 (自動執行) |
* | medium | 1 | admin, devops, cto, ciso, ceo |
* | critical | 2 | 含 CTO 或 CISO |
*/
const ROLE_HIERARCHY: Record<UserRole, number> = {
viewer: 0,
developer: 1,
devops: 2,
admin: 3,
cto: 4,
ciso: 4,
ceo: 5,
}
const REQUIRED_ROLE_FOR_RISK: Record<RiskLevel, UserRole[]> = {
low: ['developer', 'devops', 'admin', 'cto', 'ciso', 'ceo'],
medium: ['devops', 'admin', 'cto', 'ciso', 'ceo'],
high: ['admin', 'cto', 'ciso', 'ceo'],
critical: ['cto', 'ciso', 'ceo'], // Critical 需要 CTO/CISO 等級
}
function canSignApproval(userRole: UserRole, riskLevel: RiskLevel): boolean {
const allowedRoles = REQUIRED_ROLE_FOR_RISK[riskLevel]
return allowedRoles.includes(userRole)
}
function getRequiredRolesDisplay(riskLevel: RiskLevel): string {
const roles = REQUIRED_ROLE_FOR_RISK[riskLevel]
return roles.map((r) => r.toUpperCase()).join(' / ')
}
// =============================================================================
// Component
// =============================================================================
export function LiveApprovalPanel({
className,
signerId = 'user-001',
signerName = 'Demo User',
signerRole = 'devops', // 模擬當前登入者角色
}: LiveApprovalPanelProps) {
const t = useTranslations('approval')
const tCommon = useTranslations('common')
const { startPolling, stopPolling, signApproval, rejectApproval, error } = useApprovalStore()
const pendingApprovals = usePendingApprovals()
// 模擬當前登入者 (Phase 3 權限擋板)
const currentUser: CurrentUser = {
id: signerId,
name: signerName,
role: signerRole,
}
// Local state for UI feedback
const [signingStates, setSigningStates] = useState<Record<string, 'signing' | 'success' | 'error'>>({})
const [rejectModalId, setRejectModalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
// Phase 3: Access Denied 模態框狀態
const [accessDeniedModal, setAccessDeniedModal] = useState<{
show: boolean
riskLevel: RiskLevel
requiredRoles: string
} | null>(null)
// Start polling on mount
useEffect(() => {
startPolling(5000)
return () => stopPolling()
}, [startPolling, stopPolling])
// Handle sign with permission check (Phase 3 權限擋板)
const handleSign = useCallback(async (id: string, riskLevel: RiskLevel) => {
// Phase 3: 權限檢查
if (!canSignApproval(currentUser.role, riskLevel)) {
// 顯示 Access Denied 模態框
setAccessDeniedModal({
show: true,
riskLevel,
requiredRoles: getRequiredRolesDisplay(riskLevel),
})
console.warn('[HITL] Access Denied:', {
user: currentUser.name,
role: currentUser.role,
riskLevel,
requiredRoles: getRequiredRolesDisplay(riskLevel),
})
return
}
setSigningStates((prev) => ({ ...prev, [id]: 'signing' }))
const result = await signApproval(id, signerId, signerName)
if (result) {
setSigningStates((prev) => ({ ...prev, [id]: 'success' }))
// Log for demo
console.log('[HITL] Sign result:', {
id,
user: currentUser.name,
role: currentUser.role,
status: result.approval.status,
signatures: `${result.approval.current_signatures}/${result.approval.required_signatures}`,
executionTriggered: result.execution_triggered,
})
// Clear success state after animation
if (result.execution_triggered) {
setTimeout(() => {
setSigningStates((prev) => {
const next = { ...prev }
delete next[id]
return next
})
}, 2000)
}
} else {
setSigningStates((prev) => ({ ...prev, [id]: 'error' }))
setTimeout(() => {
setSigningStates((prev) => {
const next = { ...prev }
delete next[id]
return next
})
}, 3000)
}
}, [signApproval, signerId, signerName, currentUser])
// Handle reject
const handleReject = useCallback((id: string) => {
setRejectModalId(id)
setRejectReason('')
}, [])
const confirmReject = useCallback(async () => {
if (!rejectModalId || !rejectReason.trim()) return
const success = await rejectApproval(
rejectModalId,
signerId,
signerName,
rejectReason.trim()
)
if (success) {
console.log('[HITL] Rejected:', rejectModalId)
}
setRejectModalId(null)
setRejectReason('')
}, [rejectModalId, rejectReason, rejectApproval, signerId, signerName])
// Convert to frontend format
const approvals: ApprovalRequest[] = pendingApprovals.map(toFrontendApproval)
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusOrb status={approvals.length > 0 ? 'warning' : 'healthy'} size="md" glow pulse={approvals.length > 0} />
<h2 className="font-heading text-2xl font-bold text-nothing-gray-900">
{t('pendingApprovals')}
</h2>
<span className="px-3 py-1 rounded-full bg-nothing-gray-100 text-nothing-gray-600 font-mono text-sm">
{approvals.length}
</span>
</div>
</div>
{/* Error State */}
{error && (
<div className="p-4 bg-status-critical/10 border border-status-critical/30 rounded-lg">
<p className="text-sm text-status-critical font-mono">{t('fetchError')}: {error}</p>
</div>
)}
{/* Empty State */}
{approvals.length === 0 && !error && (
<GlassCard>
<GlassCardContent>
<div className="py-12 text-center">
<StatusOrb status="healthy" size="lg" glow className="mx-auto mb-4" />
<p className="text-nothing-gray-500 font-mono">{t('noApprovals')}</p>
</div>
</GlassCardContent>
</GlassCard>
)}
{/* Current User Role Display (Phase 3 Demo) */}
<div className="flex items-center gap-2 px-4 py-2 bg-nothing-gray-100 rounded-lg w-fit">
<Lock className="w-4 h-4 text-nothing-gray-500" />
<span className="font-mono text-xs text-nothing-gray-600">
: <span className="font-bold text-nothing-gray-800">{currentUser.name}</span>
</span>
<span className={cn(
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
currentUser.role === 'cto' || currentUser.role === 'ciso' || currentUser.role === 'ceo'
? 'bg-status-healthy/10 text-status-healthy border border-status-healthy/30'
: currentUser.role === 'admin'
? 'bg-claw-blue/10 text-claw-blue border border-claw-blue/30'
: 'bg-status-warning/10 text-status-warning border border-status-warning/30'
)}>
{currentUser.role}
</span>
</div>
{/* Approval Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{approvals.map((approval) => (
<div
key={approval.id}
className={cn(
'transition-all duration-500 relative',
signingStates[approval.id] === 'success' && 'scale-95 opacity-50'
)}
>
<ApprovalCard
request={approval}
onApprove={() => handleSign(approval.id, approval.riskLevel)}
onReject={() => handleReject(approval.id)}
holdDuration={2000}
isLoading={signingStates[approval.id] === 'signing'}
/>
{/* Permission Warning Badge (Phase 3) */}
{!canSignApproval(currentUser.role, approval.riskLevel) && (
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 bg-status-critical/10 border border-status-critical/30 rounded text-[10px] font-mono text-status-critical">
<Lock className="w-3 h-3" />
{getRequiredRolesDisplay(approval.riskLevel)}
</div>
)}
{/* Success Overlay */}
{signingStates[approval.id] === 'success' && (
<div className="absolute inset-0 flex items-center justify-center bg-status-healthy/20 rounded-xl">
<div className="text-center">
<StatusOrb status="healthy" size="lg" glow />
<p className="mt-2 text-status-healthy font-mono font-semibold">
{t('signSuccess')}
</p>
</div>
</div>
)}
</div>
))}
</div>
{/* Reject Modal */}
{rejectModalId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<GlassCard className="w-full max-w-md mx-4" variant="elevated" padding="lg">
<GlassCardHeader>
<GlassCardTitle>{t('rejectReason')}</GlassCardTitle>
</GlassCardHeader>
<GlassCardContent>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder={t('enterReason')}
className="w-full h-32 px-4 py-3 rounded-lg border border-nothing-gray-200 bg-white/50 resize-none font-mono text-sm focus:outline-none focus:ring-2 focus:ring-status-critical/50"
autoFocus
/>
<div className="flex gap-3 mt-4">
<button
onClick={() => setRejectModalId(null)}
className="flex-1 px-6 py-3 rounded-lg border border-nothing-gray-300 text-nothing-gray-600 font-mono text-sm hover:bg-nothing-gray-100 transition-colors"
>
{tCommon('cancel')}
</button>
<button
onClick={confirmReject}
disabled={!rejectReason.trim()}
className="flex-1 px-6 py-3 rounded-lg bg-status-critical text-white font-mono text-sm hover:bg-status-critical/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('reject')}
</button>
</div>
</GlassCardContent>
</GlassCard>
</div>
)}
{/* Phase 3: Access Denied Modal (Nothing.tech Style) */}
{accessDeniedModal?.show && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
<GlassCard className="w-full max-w-md mx-4" variant="elevated" padding="lg">
<div className="text-center py-6">
{/* Icon */}
<div className="mx-auto w-16 h-16 rounded-full bg-status-critical/10 flex items-center justify-center mb-4">
<ShieldX className="w-8 h-8 text-status-critical" />
</div>
{/* Title */}
<h3 className="font-dot-matrix text-2xl text-status-critical mb-2">
ACCESS DENIED
</h3>
{/* Risk Level Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-status-critical/10 border border-status-critical/30 mb-4">
<AlertTriangle className="w-4 h-4 text-status-critical" />
<span className="font-mono text-sm text-status-critical font-semibold uppercase">
{accessDeniedModal.riskLevel} RISK
</span>
</div>
{/* Message */}
<p className="text-nothing-gray-600 font-mono text-sm mb-2">
</p>
<p className="text-nothing-gray-500 font-mono text-xs mb-6">
: <span className="text-status-warning font-bold">{currentUser.role.toUpperCase()}</span>
</p>
{/* Required Roles */}
<div className="bg-nothing-gray-50 rounded-lg p-4 mb-6">
<p className="text-[10px] text-nothing-gray-500 font-mono uppercase tracking-wider mb-2">
</p>
<div className="flex flex-wrap justify-center gap-2">
{accessDeniedModal.requiredRoles.split(' / ').map((role) => (
<span
key={role}
className="px-2 py-1 rounded bg-status-healthy/10 text-status-healthy font-mono text-xs font-bold border border-status-healthy/30"
>
{role}
</span>
))}
</div>
</div>
{/* Action Button */}
<button
onClick={() => setAccessDeniedModal(null)}
className="w-full px-6 py-3 rounded-xl font-mono text-sm font-semibold bg-nothing-gray-100 text-nothing-gray-700 hover:bg-nothing-gray-200 transition-colors"
>
</button>
</div>
</GlassCard>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,273 @@
'use client'
/**
* AIProcessStepper - AI 決策流程步進器
* =====================================
* Phase 7: 視覺主權重構
*
* Nothing.tech 設計語言:
* - 極簡步進設計
* - 點陣式進度指示
* - 白玻璃層次感
*
* Features:
* - 5 步 AI 決策流程可視化
* - 當前步驟動畫高亮
* - 狀態顏色區分 (thinking/success/error)
*/
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
import {
Search,
Brain,
Shield,
Play,
CheckCircle2,
Loader2,
XCircle,
type LucideIcon,
} from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export type StepStatus = 'pending' | 'active' | 'complete' | 'error'
export interface ProcessStep {
id: string
label: string
description?: string
status: StepStatus
duration?: string
}
export interface AIProcessStepperProps {
steps: ProcessStep[]
currentStep?: number
className?: string
}
// =============================================================================
// Step Icons
// =============================================================================
const stepIcons: Record<string, LucideIcon> = {
gather: Search,
analyze: Brain,
approve: Shield,
execute: Play,
verify: CheckCircle2,
}
// =============================================================================
// Status Config (Nothing.tech palette)
// =============================================================================
const statusConfig = {
pending: {
bg: 'bg-nothing-gray-100',
border: 'border-nothing-gray-200',
text: 'text-nothing-gray-400',
icon: 'text-nothing-gray-400',
line: 'bg-nothing-gray-200',
},
active: {
bg: 'bg-status-thinking/10',
border: 'border-status-thinking/30',
text: 'text-status-thinking',
icon: 'text-status-thinking',
line: 'bg-status-thinking/30',
},
complete: {
bg: 'bg-green-50',
border: 'border-green-200',
text: 'text-green-700',
icon: 'text-green-600',
line: 'bg-green-400',
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-700',
icon: 'text-red-600',
line: 'bg-red-400',
},
}
// =============================================================================
// Step Component
// =============================================================================
interface StepProps {
step: ProcessStep
index: number
isLast: boolean
}
function Step({ step, index, isLast }: StepProps) {
const config = statusConfig[step.status]
const Icon = stepIcons[step.id] || CheckCircle2
return (
<div className="flex items-start gap-4">
{/* Step Circle & Connector */}
<div className="flex flex-col items-center">
{/* Circle */}
<div
className={cn(
'relative flex items-center justify-center',
'w-10 h-10 rounded-full border-2 transition-all duration-300',
config.bg,
config.border
)}
>
{step.status === 'active' ? (
<Loader2 className={cn('w-5 h-5 animate-spin', config.icon)} />
) : step.status === 'error' ? (
<XCircle className={cn('w-5 h-5', config.icon)} />
) : step.status === 'complete' ? (
<CheckCircle2 className={cn('w-5 h-5', config.icon)} />
) : (
<Icon className={cn('w-5 h-5', config.icon)} />
)}
{/* Pulse Animation for Active */}
{step.status === 'active' && (
<div className="absolute inset-0 rounded-full border-2 border-status-thinking animate-ping opacity-20" />
)}
</div>
{/* Connector Line */}
{!isLast && (
<div
className={cn(
'w-0.5 h-12 mt-2 transition-colors duration-300',
step.status === 'complete' ? config.line : 'bg-nothing-gray-200'
)}
/>
)}
</div>
{/* Step Content */}
<div className="flex-1 pt-1.5 pb-6">
<div className="flex items-center gap-2">
<span
className={cn(
'text-xs font-mono uppercase tracking-wider',
config.text
)}
>
Step {index + 1}
</span>
{step.duration && (
<span className="text-xs text-nothing-gray-400">
{step.duration}
</span>
)}
</div>
<h3
className={cn(
'text-base font-medium mt-0.5 transition-colors duration-300',
step.status === 'active'
? 'text-nothing-gray-900'
: step.status === 'pending'
? 'text-nothing-gray-400'
: 'text-nothing-gray-700'
)}
>
{step.label}
</h3>
{step.description && (
<p className="text-sm text-nothing-gray-500 mt-1">{step.description}</p>
)}
</div>
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function AIProcessStepper({ steps, className }: AIProcessStepperProps) {
const t = useTranslations('ai')
return (
<div
className={cn(
'p-6 rounded-xl border',
'bg-white/70 backdrop-blur-[16px]',
'border-nothing-gray-200/50',
className
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-heading font-semibold text-nothing-gray-900">
{t('processFlow')}
</h2>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-status-thinking animate-pulse" />
<span className="text-xs text-nothing-gray-500">{t('processing')}</span>
</div>
</div>
{/* Steps */}
<div className="space-y-0">
{steps.map((step, index) => (
<Step
key={step.id}
step={step}
index={index}
isLast={index === steps.length - 1}
/>
))}
</div>
</div>
)
}
// =============================================================================
// Demo Component
// =============================================================================
export function AIProcessStepperDemo() {
const demoSteps: ProcessStep[] = [
{
id: 'gather',
label: 'Context Gathering',
description: 'Fetching logs, metrics, and K8s state',
status: 'complete',
duration: '1.2s',
},
{
id: 'analyze',
label: 'AI Analysis',
description: 'OpenClaw RCA engine processing',
status: 'active',
duration: '...',
},
{
id: 'approve',
label: 'HITL Approval',
description: 'Waiting for human confirmation',
status: 'pending',
},
{
id: 'execute',
label: 'Execute Action',
description: 'Apply kubectl commands',
status: 'pending',
},
{
id: 'verify',
label: 'Verification',
description: 'Confirm resolution',
status: 'pending',
},
]
return <AIProcessStepper steps={demoSteps} />
}

View File

@@ -0,0 +1,223 @@
'use client'
/**
* GlobalPulseChart - 全局脈搏統計圖
* ==================================
* Phase 7: 視覺主權重構
*
* Nothing.tech 設計語言:
* - 白玻璃層次 (bg-white/70 backdrop-blur)
* - 極簡配色 (白、灰、點陣紅)
* - 無網格線,僅保留關鍵數據
*
* Features:
* - 4 個核心指標卡片 (RPS, Error Rate, P99, AI Success)
* - Sparkline 趨勢圖
* - 即時更新動畫
*/
import { useMemo } from 'react'
import {
Area,
AreaChart,
ResponsiveContainer,
} from 'recharts'
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
// =============================================================================
// Types
// =============================================================================
export interface PulseMetric {
label: string
value: number | string
unit?: string
trend: number[] // sparkline data
status: 'healthy' | 'warning' | 'critical'
}
export interface GlobalPulseChartProps {
metrics: PulseMetric[]
className?: string
}
// =============================================================================
// Status Colors (Nothing.tech palette)
// =============================================================================
const statusConfig = {
healthy: {
bg: 'bg-nothing-gray-50',
border: 'border-nothing-gray-200',
dot: 'bg-green-500',
stroke: '#22c55e',
fill: 'rgba(34,197,94,0.1)',
},
warning: {
bg: 'bg-amber-50/50',
border: 'border-amber-200/50',
dot: 'bg-amber-500',
stroke: '#f59e0b',
fill: 'rgba(245,158,11,0.1)',
},
critical: {
bg: 'bg-red-50/50',
border: 'border-red-200/50',
dot: 'bg-nothing-red',
stroke: '#ef4444',
fill: 'rgba(239,68,68,0.1)',
},
}
// =============================================================================
// Sparkline Component
// =============================================================================
interface SparklineProps {
data: number[]
status: 'healthy' | 'warning' | 'critical'
}
function Sparkline({ data, status }: SparklineProps) {
const config = statusConfig[status]
const chartData = useMemo(
() => data.map((value, index) => ({ index, value })),
[data]
)
return (
<div className="h-8 w-full" style={{ minWidth: 80, minHeight: 32 }}>
<ResponsiveContainer width="100%" height={32}>
<AreaChart data={chartData} margin={{ top: 2, right: 0, left: 0, bottom: 2 }}>
<Area
type="monotone"
dataKey="value"
stroke={config.stroke}
strokeWidth={1.5}
fill={config.fill}
animationDuration={300}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}
// =============================================================================
// Metric Card Component
// =============================================================================
interface MetricCardProps {
metric: PulseMetric
}
function MetricCard({ metric }: MetricCardProps) {
const config = statusConfig[metric.status]
return (
<div
className={cn(
'relative p-6 rounded-sm border transition-all duration-200',
'bg-white/70 backdrop-blur-[16px]',
config.border,
'hover:shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
'hover:-translate-y-0.5'
)}
>
{/* Status Indicator */}
<div className="absolute top-6 right-6">
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
</div>
{/* Label */}
<p className="text-xs font-medium text-ink-secondary uppercase tracking-wider mb-2">
{metric.label}
</p>
{/* Value - Dot Matrix Display (靈魂注入) */}
<p className="dot-matrix-display">
{metric.value}
{metric.unit && (
<span className="text-base font-mono font-normal text-ink-secondary ml-2">
{metric.unit}
</span>
)}
</p>
{/* Sparkline */}
<div className="mt-4">
<Sparkline data={metric.trend} status={metric.status} />
</div>
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function GlobalPulseChart({ metrics, className }: GlobalPulseChartProps) {
const t = useTranslations('dashboard')
return (
<div className={cn('w-full', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-heading font-semibold text-nothing-gray-900">
{t('globalPulse')}
</h2>
<div className="flex items-center gap-2 text-xs text-nothing-gray-500">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span>{t('liveUpdates')}</span>
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{metrics.map((metric, index) => (
<MetricCard key={index} metric={metric} />
))}
</div>
</div>
)
}
// =============================================================================
// Default Export with Demo Data
// =============================================================================
export function GlobalPulseChartDemo() {
const demoMetrics: PulseMetric[] = [
{
label: 'RPS',
value: 1247,
unit: 'req/s',
trend: [120, 135, 128, 142, 138, 145, 152, 148, 156, 160],
status: 'healthy',
},
{
label: 'Error Rate',
value: '0.12',
unit: '%',
trend: [0.1, 0.15, 0.08, 0.12, 0.09, 0.11, 0.14, 0.1, 0.12, 0.12],
status: 'healthy',
},
{
label: 'P99 Latency',
value: 245,
unit: 'ms',
trend: [220, 235, 248, 242, 255, 238, 252, 248, 245, 245],
status: 'warning',
},
{
label: 'AI Success',
value: '98.5',
unit: '%',
trend: [97, 98, 99, 98, 97, 99, 98, 99, 98, 98.5],
status: 'healthy',
},
]
return <GlobalPulseChart metrics={demoMetrics} />
}

View File

@@ -0,0 +1,9 @@
/**
* Charts Components - Phase 7 Visual Sovereignty
* ===============================================
* Nothing.tech 設計語言圖表組件庫
*/
export { TimeSeriesChart, type TimeSeriesChartProps, type TimeSeriesDataPoint } from './time-series-chart'
export { GlobalPulseChart, GlobalPulseChartDemo, type GlobalPulseChartProps, type PulseMetric } from './global-pulse-chart'
export { AIProcessStepper, AIProcessStepperDemo, type AIProcessStepperProps, type ProcessStep, type StepStatus } from './ai-process-stepper'

View File

@@ -0,0 +1,195 @@
'use client'
/**
* TimeSeriesChart - Nothing.tech 風格時間序列圖
* =============================================
* Phase 7: 視覺主權重構
*
* Features:
* - SignOz Gold Metrics 時間序列展示
* - 極簡設計 (無網格線、漸層填充)
* - 響應式自適應
* - i18n 支援 (zh-TW/en)
*/
import { useMemo } from 'react'
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export interface TimeSeriesDataPoint {
timestamp: number | string
value: number
label?: string
}
export interface TimeSeriesChartProps {
/** 數據點陣列 */
data: TimeSeriesDataPoint[]
/** 圖表高度 (px) */
height?: number
/** 顏色主題 */
color?: 'primary' | 'success' | 'warning' | 'error'
/** Y 軸單位 (e.g., 'ms', '%', 'req/s') */
unit?: string
/** 是否顯示 Y 軸 */
showYAxis?: boolean
/** 是否顯示漸層填充 */
showGradient?: boolean
/** 自訂 className */
className?: string
/** 無障礙標籤 */
'aria-label'?: string
}
// =============================================================================
// Color Mapping
// =============================================================================
const colorMap = {
primary: {
stroke: '#8b5cf6', // status-thinking (purple)
fill: 'url(#gradient-primary)',
gradient: ['rgba(139,92,246,0.3)', 'rgba(139,92,246,0)'],
},
success: {
stroke: '#22c55e', // green-500
fill: 'url(#gradient-success)',
gradient: ['rgba(34,197,94,0.3)', 'rgba(34,197,94,0)'],
},
warning: {
stroke: '#f59e0b', // amber-500
fill: 'url(#gradient-warning)',
gradient: ['rgba(245,158,11,0.3)', 'rgba(245,158,11,0)'],
},
error: {
stroke: '#ef4444', // red-500
fill: 'url(#gradient-error)',
gradient: ['rgba(239,68,68,0.3)', 'rgba(239,68,68,0)'],
},
}
// =============================================================================
// Custom Tooltip
// =============================================================================
interface CustomTooltipProps {
active?: boolean
payload?: Array<{ value: number; payload: TimeSeriesDataPoint }>
unit?: string
}
function CustomTooltip({ active, payload, unit }: CustomTooltipProps) {
if (!active || !payload || payload.length === 0) return null
const data = payload[0]
const time = data.payload.label || new Date(data.payload.timestamp).toLocaleTimeString()
return (
<div className="bg-nothing-gray-900/95 backdrop-blur-sm px-3 py-2 rounded-lg border border-nothing-gray-700">
<p className="text-nothing-gray-400 text-xs mb-1">{time}</p>
<p className="text-white font-mono text-sm font-semibold">
{data.value.toFixed(2)}{unit && ` ${unit}`}
</p>
</div>
)
}
// =============================================================================
// Component
// =============================================================================
export function TimeSeriesChart({
data,
height = 120,
color = 'primary',
unit,
showYAxis = false,
showGradient = true,
className,
'aria-label': ariaLabel,
}: TimeSeriesChartProps) {
const colorConfig = colorMap[color]
// Format timestamp for X-axis
const formattedData = useMemo(() => {
return data.map(point => ({
...point,
label: point.label || new Date(point.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}))
}, [data])
return (
<div
className={cn('w-full', className)}
style={{ height }}
role="img"
aria-label={ariaLabel || 'Time series chart'}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={formattedData}
margin={{ top: 4, right: 4, left: showYAxis ? 32 : 4, bottom: 4 }}
>
{/* Gradient Definitions */}
<defs>
<linearGradient id={`gradient-${color}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={colorConfig.gradient[0]} />
<stop offset="100%" stopColor={colorConfig.gradient[1]} />
</linearGradient>
</defs>
{/* X Axis - Hidden but provides data */}
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={false}
hide
/>
{/* Y Axis - Optional */}
{showYAxis && (
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: '#6b7280', fontSize: 10, fontFamily: 'monospace' }}
width={28}
tickFormatter={(value) => `${value}${unit ? unit : ''}`}
/>
)}
{/* Tooltip */}
<Tooltip
content={<CustomTooltip unit={unit} />}
cursor={{ stroke: colorConfig.stroke, strokeWidth: 1, strokeDasharray: '4 4' }}
/>
{/* Area Chart */}
<Area
type="monotone"
dataKey="value"
stroke={colorConfig.stroke}
strokeWidth={2}
fill={showGradient ? colorConfig.fill : 'transparent'}
animationDuration={300}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
/**
* DataPincerCard - AWOOOI 數據鉗容器組件
* ========================================
* Nothing.tech 純白工業風視覺容器
*
* 視覺特徵:
* - awoooi-glass: 白玻璃毛玻璃效果
* - 極細灰色邊框 (border-nothing-mist)
* - 左側狀態指示細線 (無漸層、不遮蔽數據)
*
* i18n: 100% 使用 useTranslations禁止任何寫死字串
* 符合 AWOOOI L1 契約 - 視覺憲法
*/
import { type ReactNode } from 'react'
import { cn } from '@/lib/utils'
// ==================== Types ====================
export type CardStatus = 'healthy' | 'warning' | 'critical' | 'thinking' | 'idle'
interface DataPincerCardProps {
children: ReactNode
title?: string
status?: CardStatus
className?: string
/** 是否顯示左側狀態指示線 */
showStatusLine?: boolean
/** 卡片尺寸 */
size?: 'sm' | 'md' | 'lg'
}
// ==================== Config ====================
const statusLineColors: Record<CardStatus, string> = {
healthy: 'bg-status-healthy',
warning: 'bg-status-warning',
critical: 'bg-status-critical',
thinking: 'bg-status-thinking',
idle: 'bg-nothing-gray-300',
}
const sizeConfig = {
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
// ==================== Component ====================
export function DataPincerCard({
children,
title,
status = 'idle',
className,
showStatusLine = true,
size = 'md',
}: DataPincerCardProps) {
return (
<div
className={cn(
// awoooi-glass 白玻璃效果
'relative overflow-hidden rounded-xl',
'bg-white/70 backdrop-blur-[16px]',
// 極細灰色邊框 (border-nothing-mist)
'border border-nothing-gray-200/60',
// 輕柔陰影
'shadow-[0_2px_12px_rgba(0,0,0,0.04)]',
// 尺寸
sizeConfig[size],
// 過渡動畫
'transition-all duration-200',
// Hover 效果
'hover:shadow-[0_4px_20px_rgba(0,0,0,0.06)]',
'hover:border-nothing-gray-300/80',
className
)}
>
{/* 左側狀態指示細線 - 無漸層、不遮蔽數據 */}
{showStatusLine && (
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-1 rounded-l-xl',
statusLineColors[status],
// 思考狀態時呼吸動畫
status === 'thinking' && 'animate-pulse'
)}
/>
)}
{/* 標題 (如有) */}
{title && (
<div className="mb-4 pb-3 border-b border-nothing-gray-200/40">
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-secondary">
{title}
</h3>
</div>
)}
{/* 內容 */}
<div className={cn(showStatusLine && 'pl-2')}>
{children}
</div>
</div>
)
}
// ==================== Variants ====================
/** 大型數據面板 */
export function DataPincerPanel({
children,
title,
status = 'idle',
className,
}: Omit<DataPincerCardProps, 'size' | 'showStatusLine'>) {
return (
<DataPincerCard
title={title}
status={status}
size="lg"
showStatusLine={true}
className={cn('min-h-[200px]', className)}
>
{children}
</DataPincerCard>
)
}
/** 緊湊型狀態卡片 */
export function DataPincerCompact({
children,
status = 'idle',
className,
}: Omit<DataPincerCardProps, 'size' | 'title'>) {
return (
<DataPincerCard
status={status}
size="sm"
showStatusLine={true}
className={className}
>
{children}
</DataPincerCard>
)
}

View File

@@ -0,0 +1,14 @@
/**
* AWOOOI Cyber Components
* =======================
* Nothing.tech 純白工業風視覺組件庫
*
* 命名規範: Data Pincer (數據鉗) 系列
*/
export {
DataPincerCard,
DataPincerPanel,
DataPincerCompact,
type CardStatus,
} from './data-pincer-card'

View File

@@ -0,0 +1,135 @@
'use client'
/**
* ConnectionStatus - SSE 連線狀態指示器
* =====================================
* 顯示與後端 SSE 的連線狀態
*
* i18n: 100% 使用 useTranslations禁止任何寫死字串
* 符合 AWOOOI 專案開發憲法 v2.0
*
* States:
* - disconnected: 灰色
* - connecting: 藍色 pulse
* - connected: 綠色
* - reconnecting: 橘色 pulse
* - error: 紅色
*/
import { useTranslations } from 'next-intl'
import { useLocale } from 'next-intl'
import { StatusOrb, type StatusType } from '@/components/ui/status-orb'
import { useConnectionStatus, useMockMode, useLastUpdate } from '@/stores/dashboard.store'
import { cn } from '@/lib/utils'
// =============================================================================
// Status Mapping
// =============================================================================
type ConnectionStatusType = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
const connectionToStatus: Record<ConnectionStatusType, StatusType> = {
disconnected: 'idle',
connecting: 'syncing',
connected: 'healthy',
reconnecting: 'warning',
error: 'critical',
}
// i18n key mapping (不再使用 hardcoded labels)
const connectionLabelKeys: Record<ConnectionStatusType, string> = {
disconnected: 'disconnected',
connecting: 'connecting',
connected: 'connected',
reconnecting: 'reconnecting',
error: 'error',
}
// =============================================================================
// Component
// =============================================================================
interface ConnectionStatusProps {
className?: string
showLabel?: boolean
showLastUpdate?: boolean
}
export function ConnectionStatus({
className,
showLabel = true,
showLastUpdate = false,
}: ConnectionStatusProps) {
const t = useTranslations('connection')
const tCommon = useTranslations('common')
const locale = useLocale()
const connectionStatus = useConnectionStatus()
const mockMode = useMockMode()
const lastUpdate = useLastUpdate()
const status = connectionToStatus[connectionStatus]
const labelKey = connectionLabelKeys[connectionStatus]
// Format last update time with dynamic locale
const lastUpdateStr = lastUpdate
? lastUpdate.toLocaleTimeString(locale === 'zh-TW' ? 'zh-TW' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
: '--:--:--'
return (
<div className={cn('flex items-center gap-2', className)}>
<StatusOrb
status={status}
size="sm"
pulse={connectionStatus === 'connecting' || connectionStatus === 'reconnecting'}
glow={connectionStatus === 'connected'}
/>
{showLabel && (
<div className="flex flex-col">
<span className="text-xs font-mono text-nothing-gray-600">
{t(labelKey)}
</span>
{showLastUpdate && lastUpdate && (
<span className="text-[10px] font-mono text-nothing-gray-400">
{lastUpdateStr}
</span>
)}
</div>
)}
{mockMode && (
<span className="px-1.5 py-0.5 text-[10px] font-mono bg-status-warning/10 text-status-warning rounded">
{t('mockMode')}
</span>
)}
</div>
)
}
// =============================================================================
// Compact Version (for header)
// =============================================================================
export function ConnectionStatusCompact({ className }: { className?: string }) {
const t = useTranslations('connection')
const connectionStatus = useConnectionStatus()
const status = connectionToStatus[connectionStatus]
return (
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg bg-nothing-gray-100',
className
)}
>
<StatusOrb status={status} size="sm" pulse={connectionStatus !== 'connected'} />
<span className="font-mono text-xs text-nothing-gray-600 uppercase">
{t(connectionLabelKeys[connectionStatus])}
</span>
</div>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
/**
* HostCard - 主機狀態卡片
* ======================
* 四主機架構戰情室卡片組件
*
* Features:
* - GlassCard 透光背景
* - StatusOrb 狀態指示
* - 服務列表與 metrics
* - i18n 支援 (next-intl)
* - WCAG 2.1 AA 無障礙
*/
import { useTranslations } from 'next-intl'
import {
GlassCard,
GlassCardHeader,
GlassCardTitle,
GlassCardContent,
GlassCardFooter,
} from '@/components/ui/glass-card'
import { StatusOrb, StatusBadge, type StatusType } from '@/components/ui/status-orb'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export interface HostService {
name: string
status: StatusType
port?: number | null
latencyMs?: number | null
detail?: string
}
export interface HostMetrics {
cpuPercent: number
memoryPercent: number
diskPercent?: number
}
export interface HostCardProps {
/** 主機 IP */
ip: string
/** 主機名稱 */
name: string
/** 主機角色 */
role: string
/** 整體狀態 */
status: StatusType
/** 服務列表 */
services: HostService[]
/** 資源指標 */
metrics?: HostMetrics | null
/** 額外 className */
className?: string
/** 點擊事件 */
onClick?: () => void
}
// =============================================================================
// Helper Functions
// =============================================================================
function getOverallStatus(services: HostService[]): StatusType {
const hasCritical = services.some((s) => s.status === 'critical')
const hasWarning = services.some((s) => s.status === 'warning')
const hasThinking = services.some((s) => s.status === 'thinking')
if (hasCritical) return 'critical'
if (hasWarning) return 'warning'
if (hasThinking) return 'thinking'
return 'healthy'
}
function formatPercent(value: number): string {
return `${Math.round(value)}%`
}
// =============================================================================
// Component
// =============================================================================
export function HostCard({
ip,
name,
role,
status,
services,
metrics,
className,
onClick,
}: HostCardProps) {
const t = useTranslations('status')
const tDashboard = useTranslations('dashboard')
// Status label from i18n
const statusLabel = t(status)
return (
<GlassCard
hoverable
clickable={!!onClick}
onClick={onClick}
className={cn('h-full flex flex-col', className)}
aria-label={`${name} - ${statusLabel}`}
>
{/* Header */}
<GlassCardHeader>
<div className="flex items-center gap-3">
<StatusOrb status={status} size="md" glow />
<div>
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-nothing-gray-100 text-nothing-gray-600 text-xs font-mono">
{ip}
</span>
</div>
</div>
{/* 重備性標籤 - 參考圖風格:藍色圓點 + 標籤 */}
<div className="flex items-center gap-2">
<span className="text-xs text-nothing-gray-400 font-medium">
{tDashboard('criticality')}
</span>
<span className="w-2 h-2 rounded-full bg-[#4A90D9]" />
</div>
</GlassCardHeader>
{/* Title */}
<GlassCardTitle className="mb-4">{name}</GlassCardTitle>
{/* Services List */}
<GlassCardContent>
<div className="space-y-2">
{services.map((service) => (
<div
key={service.name}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-nothing-gray-100/50 transition-colors"
>
<div className="flex items-center gap-2">
<StatusOrb status={service.status} size="sm" />
<span className="font-mono text-sm text-nothing-gray-800">
{service.name}
</span>
</div>
<div className="flex items-center gap-2">
{service.port && (
<span className="text-xs text-nothing-gray-400 font-mono">
:{service.port}
</span>
)}
{service.latencyMs !== null && service.latencyMs !== undefined && (
<span className="text-xs text-nothing-gray-500 font-mono">
{service.latencyMs.toFixed(1)}ms
</span>
)}
</div>
</div>
))}
</div>
{/* Metrics Bar */}
{metrics && (
<div className="mt-4 pt-3 border-t border-nothing-gray-100">
<div className="grid grid-cols-2 gap-3">
{/* CPU */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-nothing-gray-500">{tDashboard('cpu')}</span>
<span className="text-xs font-mono text-nothing-gray-700">
{formatPercent(metrics.cpuPercent)}
</span>
</div>
<div className="h-1.5 bg-nothing-gray-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
metrics.cpuPercent > 80
? 'bg-status-critical'
: metrics.cpuPercent > 60
? 'bg-status-warning'
: 'bg-status-healthy'
)}
style={{ width: `${Math.min(metrics.cpuPercent, 100)}%` }}
/>
</div>
</div>
{/* Memory */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-nothing-gray-500">{tDashboard('memory')}</span>
<span className="text-xs font-mono text-nothing-gray-700">
{formatPercent(metrics.memoryPercent)}
</span>
</div>
<div className="h-1.5 bg-nothing-gray-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
metrics.memoryPercent > 85
? 'bg-status-critical'
: metrics.memoryPercent > 70
? 'bg-status-warning'
: 'bg-status-healthy'
)}
style={{ width: `${Math.min(metrics.memoryPercent, 100)}%` }}
/>
</div>
</div>
</div>
</div>
)}
</GlassCardContent>
{/* Footer */}
<GlassCardFooter>
<div className="flex items-center justify-between">
<StatusBadge status={status} label={statusLabel} size="sm" />
<span className="text-xs text-nothing-gray-400 font-mono">
{new Date().toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</GlassCardFooter>
</GlassCard>
)
}
// =============================================================================
// Host Card Grid
// =============================================================================
export interface HostCardGridProps {
children: React.ReactNode
className?: string
}
export function HostCardGrid({ children, className }: HostCardGridProps) {
return (
<div
className={cn(
'grid grid-cols-1 md:grid-cols-2 gap-4',
className
)}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,9 @@
/**
* Dashboard Components
* ====================
* 戰情室專用組件
*/
export { HostCard, HostCardGrid, type HostCardProps, type HostService, type HostMetrics } from './host-card'
export { LiveHostCard } from './live-host-card'
export { ConnectionStatus, ConnectionStatusCompact } from './connection-status'

View File

@@ -0,0 +1,422 @@
'use client'
/**
* LiveDashboard - SSE 即時戰情室
* ==============================
* Phase 2.5: Magic UI 視覺升級
* i18n: 100% 使用 useTranslations禁止任何寫死字串
*/
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useDashboardStore, useHosts, useOverallStatus, useAlerts, useMockMode } from '@/stores/dashboard.store'
import { LiveHostCard } from './live-host-card'
import { ConnectionStatus } from './connection-status'
import { HostCardGrid } from './host-card'
import {
GlassCard,
GlassCardHeader,
GlassCardTitle,
GlassCardContent,
} from '@/components/ui/glass-card'
import { StatusOrb } from '@/components/ui/status-orb'
import { cn } from '@/lib/utils'
import {
Server,
AlertTriangle,
CheckCircle2,
Activity,
Sparkles,
Eye,
Brain,
} from 'lucide-react'
// =============================================================================
// Config
// =============================================================================
const getApiBaseUrl = () => {
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
}
const HOST_IPS = ['192.168.0.110', '192.168.0.112', '192.168.0.120', '192.168.0.188']
// =============================================================================
// Component
// =============================================================================
interface LiveDashboardProps {
locale: string
}
export function LiveDashboard({ locale }: LiveDashboardProps) {
const t = useTranslations('dashboard')
const tBrand = useTranslations('brand')
const tHost = useTranslations('host')
const { connect, disconnect } = useDashboardStore()
const hosts = useHosts()
const overallStatus = useOverallStatus()
const alerts = useAlerts()
const mockMode = useMockMode()
// Host fallback data with i18n
const HOST_FALLBACKS: Record<string, { name: string; role: string; services: Array<{ name: string; status: 'idle'; port?: number }> }> = {
'192.168.0.110': {
name: tHost('devops.name'),
role: 'devops',
services: [
{ name: 'Harbor', status: 'idle', port: 5000 },
{ name: 'GH Runner', status: 'idle' },
{ name: 'Docker', status: 'idle', port: 2375 },
],
},
'192.168.0.112': {
name: tHost('security.name'),
role: 'security',
services: [
{ name: 'Scanner API', status: 'idle', port: 8080 },
{ name: 'Nmap', status: 'idle' },
{ name: 'Nuclei', status: 'idle' },
],
},
'192.168.0.120': {
name: tHost('k3s.name'),
role: 'k3s',
services: [
{ name: 'K3s Server', status: 'idle', port: 6443 },
{ name: 'awoooi-prod', status: 'idle', port: 32335 },
{ name: 'Traefik', status: 'idle', port: 80 },
],
},
'192.168.0.188': {
name: tHost('aiWeb.name'),
role: 'ai_web',
services: [
{ name: 'Nginx', status: 'idle', port: 443 },
{ name: 'PostgreSQL', status: 'idle', port: 5432 },
{ name: 'Redis', status: 'idle', port: 6380 },
{ name: 'Ollama', status: 'idle', port: 11434 },
{ name: 'OpenClaw', status: 'idle', port: 8089 },
{ name: 'SigNoz', status: 'idle', port: 3301 },
],
},
}
useEffect(() => {
const apiBaseUrl = getApiBaseUrl()
console.log('[Dashboard] Connecting to SSE...', apiBaseUrl)
connect(apiBaseUrl)
return () => {
console.log('[Dashboard] Disconnecting SSE...')
disconnect()
}
}, [connect, disconnect])
return (
<div className="space-y-6">
{/* Header Stats */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="font-heading text-3xl font-bold text-nothing-gray-900 tracking-tight">
{t('title')}
</h2>
<p className="text-nothing-gray-500 mt-1">{tBrand('slogan')}</p>
</div>
<div className="flex items-center gap-4">
<ConnectionStatus showLabel showLastUpdate />
</div>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Four Host Cards */}
<div className="lg:col-span-2">
<HostCardGrid>
{HOST_IPS.map((ip) => (
<LiveHostCard
key={ip}
ip={ip}
fallbackData={HOST_FALLBACKS[ip]}
/>
))}
</HostCardGrid>
</div>
{/* Sidebar */}
<div className="lg:col-span-1 space-y-4">
<OpenClawPanel />
<StatsPanel
hostsCount={hosts.length}
alertsCount={alerts.count}
pendingApprovals={alerts.pending}
overallStatus={overallStatus}
/>
</div>
</div>
</div>
)
}
// =============================================================================
// OpenClaw Panel
// =============================================================================
function OpenClawPanel() {
const t = useTranslations('openclaw')
const tCommon = useTranslations('common')
const tHost = useTranslations('host')
const hosts = useHosts()
const warningHost = hosts.find(
(h) => h.status === 'degraded' || h.status === 'unhealthy'
)
// Get host display name
const getHostDisplayName = (hostName: string): string => {
if (hostName.includes('DevOps')) return tHost('devops.name')
if (hostName.includes('Kali')) return tHost('security.name')
if (hostName.includes('K3s')) return tHost('k3s.name')
if (hostName.includes('AI+Web') || hostName.includes('AI')) return tHost('aiWeb.name')
return hostName
}
const insight = warningHost
? {
type: t('statusWarning'),
message: t('messageWarning', { host: getHostDisplayName(warningHost.name) }),
}
: {
type: t('statusOk'),
message: t('messageOk'),
}
return (
<GlassCard variant="copilot" className="relative overflow-hidden">
{/* Animated scan line */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute h-px w-full bg-gradient-to-r from-transparent via-status-thinking to-transparent animate-scan" />
</div>
{/* Sparkle effect */}
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-status-thinking/50 animate-pulse" />
</div>
<GlassCardHeader>
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-status-thinking/20 to-status-thinking/10 flex items-center justify-center border border-status-thinking/20">
<Brain className="w-6 h-6 text-status-thinking" />
</div>
{/* Breathing indicator */}
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3">
<span className="absolute inline-flex h-full w-full rounded-full bg-status-thinking opacity-75 animate-ping" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-status-thinking" />
</div>
</div>
<div>
<h3 className="font-heading font-bold text-nothing-gray-900">
{t('name')}
</h3>
<span className="text-[10px] text-status-thinking font-mono uppercase tracking-wider">
{t('monitoring')}
</span>
</div>
</div>
<span className="text-[10px] text-nothing-gray-400 font-mono bg-nothing-gray-100 px-2 py-1 rounded">v5.0.0</span>
</GlassCardHeader>
<GlassCardContent>
<div className={cn(
'rounded-xl p-4 mb-4 border transition-all duration-300',
warningHost
? 'bg-status-warning/5 border-status-warning/20'
: 'bg-status-healthy/5 border-status-healthy/20'
)}>
<div className="flex items-start gap-2 mb-2">
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-mono font-semibold',
warningHost
? 'bg-status-warning/10 text-status-warning'
: 'bg-status-healthy/10 text-status-healthy'
)}
>
{warningHost ? (
<AlertTriangle className="w-3 h-3" />
) : (
<CheckCircle2 className="w-3 h-3" />
)}
{insight.type}
</span>
</div>
<p className="text-sm text-nothing-gray-700 leading-relaxed">
{insight.message}
</p>
</div>
{warningHost && (
<div className="flex gap-2">
<button className={cn(
'flex-1 flex items-center justify-center gap-2',
'px-4 py-2.5 rounded-xl font-medium text-sm',
'bg-gradient-to-br from-status-thinking to-status-thinking/90 text-white',
'hover:shadow-[0_0_20px_rgba(139,92,246,0.3)]',
'transition-all duration-300'
)}>
<Eye className="w-4 h-4" />
{tCommon('viewDetails')}
</button>
<button className="px-4 py-2.5 bg-nothing-gray-100 text-nothing-gray-600 rounded-xl font-medium text-sm hover:bg-nothing-gray-200 transition-colors">
{tCommon('later')}
</button>
</div>
)}
</GlassCardContent>
</GlassCard>
)
}
// =============================================================================
// Stats Panel
// =============================================================================
interface StatsPanelProps {
hostsCount: number
alertsCount: number
pendingApprovals: number
overallStatus: string
}
function StatsPanel({
hostsCount,
alertsCount,
pendingApprovals,
overallStatus,
}: StatsPanelProps) {
const t = useTranslations('dashboard')
const stats = [
{
label: t('activeNodes'),
value: `${hostsCount}/4`,
icon: Server,
color: hostsCount === 4 ? 'healthy' : hostsCount >= 2 ? 'warning' : 'critical',
},
{
label: t('pendingAlerts'),
value: alertsCount,
icon: AlertTriangle,
color: alertsCount === 0 ? 'idle' : alertsCount <= 2 ? 'warning' : 'critical',
},
{
label: t('pendingApprovals'),
value: pendingApprovals,
icon: Eye,
color: pendingApprovals === 0 ? 'idle' : 'critical',
},
]
return (
<GlassCard>
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-nothing-gray-500" />
<GlassCardTitle>
{t('liveStats')}
</GlassCardTitle>
</div>
<GlassCardContent>
<div className="space-y-3">
{stats.map((stat) => (
<div
key={stat.label}
className={cn(
'flex items-center justify-between p-3 rounded-xl border transition-all duration-200',
stat.color === 'healthy'
? 'bg-status-healthy/5 border-status-healthy/20 hover:border-status-healthy/30'
: stat.color === 'warning'
? 'bg-status-warning/5 border-status-warning/20 hover:border-status-warning/30'
: stat.color === 'critical'
? 'bg-status-critical/5 border-status-critical/20 hover:border-status-critical/30'
: 'bg-nothing-gray-50/50 border-nothing-gray-100 hover:border-nothing-gray-200'
)}
>
<div className="flex items-center gap-2.5">
<stat.icon className={cn(
'w-4 h-4',
stat.color === 'healthy'
? 'text-status-healthy'
: stat.color === 'warning'
? 'text-status-warning'
: stat.color === 'critical'
? 'text-status-critical'
: 'text-nothing-gray-400'
)} />
<span className="text-sm text-nothing-gray-600">
{stat.label}
</span>
</div>
<span
className={cn(
'font-mono text-lg font-bold tabular-nums',
stat.color === 'healthy'
? 'text-status-healthy'
: stat.color === 'warning'
? 'text-status-warning'
: stat.color === 'critical'
? 'text-status-critical'
: 'text-nothing-gray-400'
)}
>
{stat.value}
</span>
</div>
))}
{/* Overall Status - Special Card */}
<div className="mt-4 pt-4 border-t border-nothing-gray-100">
<div className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-gradient-to-br',
overallStatus === 'healthy'
? 'from-status-healthy/10 to-status-healthy/5 border border-status-healthy/20'
: overallStatus === 'degraded'
? 'from-status-warning/10 to-status-warning/5 border border-status-warning/20'
: 'from-status-critical/10 to-status-critical/5 border border-status-critical/20'
)}>
<div className="flex items-center gap-2.5">
<CheckCircle2 className={cn(
'w-5 h-5',
overallStatus === 'healthy'
? 'text-status-healthy'
: overallStatus === 'degraded'
? 'text-status-warning'
: 'text-status-critical'
)} />
<span className="text-sm font-medium text-nothing-gray-700">
{t('overallStatus')}
</span>
</div>
<StatusOrb
status={overallStatus === 'healthy' ? 'healthy' : overallStatus === 'degraded' ? 'warning' : 'critical'}
size="lg"
glow
pulse={overallStatus !== 'healthy'}
/>
</div>
</div>
</div>
</GlassCardContent>
</GlassCard>
)
}

View File

@@ -0,0 +1,411 @@
'use client'
/**
* LiveHostCard - 即時主機狀態卡片
* ================================
* Phase 2.5: Magic UI 視覺升級
*
* Features:
* - 自動訂閱 SSE 更新
* - Metrics 動畫過渡
* - 狀態變化閃爍效果
* - Lucide Icons + 精緻 hover 效果
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
GlassCard,
GlassCardHeader,
GlassCardTitle,
GlassCardContent,
GlassCardFooter,
} from '@/components/ui/glass-card'
import { StatusOrb, StatusBadge, type StatusType } from '@/components/ui/status-orb'
import { useHostByIp, type Host, type HostService, type HostMetrics } from '@/stores/dashboard.store'
import { cn } from '@/lib/utils'
import {
Server,
Shield,
Cpu,
HardDrive,
Activity,
Wifi,
WifiOff,
Clock,
Gauge,
} from 'lucide-react'
// =============================================================================
// Helper Functions
// =============================================================================
function mapServiceStatus(status: string): StatusType {
const mapping: Record<string, StatusType> = {
up: 'healthy',
down: 'critical',
degraded: 'warning',
healthy: 'healthy',
warning: 'warning',
critical: 'critical',
thinking: 'thinking',
syncing: 'syncing',
idle: 'idle',
}
return mapping[status] || 'idle'
}
function mapHostStatus(status: string): StatusType {
const mapping: Record<string, StatusType> = {
healthy: 'healthy',
degraded: 'warning',
unhealthy: 'critical',
unreachable: 'critical',
}
return mapping[status] || 'idle'
}
function formatPercent(value: number): string {
return `${Math.round(value)}%`
}
// =============================================================================
// Component
// =============================================================================
interface LiveHostCardProps {
ip: string
fallbackData?: {
name: string
role: string
services: Array<{ name: string; status: StatusType; port?: number | null }>
}
className?: string
onClick?: () => void
}
export function LiveHostCard({
ip,
fallbackData,
className,
onClick,
}: LiveHostCardProps) {
const t = useTranslations('status')
const tDashboard = useTranslations('dashboard')
const tCommon = useTranslations('common')
const host = useHostByIp(ip)
// Track status changes for flash effect
const [isFlashing, setIsFlashing] = useState(false)
const [prevStatus, setPrevStatus] = useState<string | null>(null)
useEffect(() => {
if (host && prevStatus && host.status !== prevStatus) {
setIsFlashing(true)
setTimeout(() => setIsFlashing(false), 500)
}
if (host) {
setPrevStatus(host.status)
}
}, [host?.status, prevStatus])
// Use fallback if no data from store
if (!host) {
if (!fallbackData) {
return (
<GlassCard className={cn('h-full animate-pulse', className)}>
<div className="h-40 flex items-center justify-center">
<span className="text-nothing-gray-400 font-mono text-sm">
{tCommon('loading')} {ip}
</span>
</div>
</GlassCard>
)
}
// Render with fallback data
return (
<GlassCard hoverable className={cn('h-full flex flex-col opacity-60', className)}>
<GlassCardHeader>
<div className="flex items-center gap-3">
<StatusOrb status="idle" size="md" />
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-nothing-gray-100 text-nothing-gray-600 text-xs font-mono">
{ip}
</span>
</div>
</GlassCardHeader>
<GlassCardTitle className="mb-4">{fallbackData.name}</GlassCardTitle>
<GlassCardContent>
<div className="space-y-2">
{fallbackData.services.map((service) => (
<div key={service.name} className="flex items-center gap-2 py-1.5 px-2">
<StatusOrb status="idle" size="sm" />
<span className="font-mono text-sm text-nothing-gray-500">
{service.name}
</span>
</div>
))}
</div>
</GlassCardContent>
<GlassCardFooter>
<span className="text-xs text-nothing-gray-400">{tDashboard('waitingData')}</span>
</GlassCardFooter>
</GlassCard>
)
}
const overallStatus = mapHostStatus(host.status)
const statusLabel = t(overallStatus)
// Role icon mapping
const getRoleIcon = (role: string) => {
const icons: Record<string, typeof Server> = {
devops: HardDrive,
security: Shield,
k3s: Server,
ai_web: Cpu,
}
const Icon = icons[role] || Server
return <Icon className="w-4 h-4" />
}
return (
<GlassCard
hoverable
clickable={!!onClick}
onClick={onClick}
className={cn(
'h-full flex flex-col transition-all duration-300',
'hover:-translate-y-1 hover:shadow-card-hover',
isFlashing && 'ring-2 ring-status-warning/50 animate-pulse',
overallStatus === 'critical' && 'ring-1 ring-status-critical/30',
className
)}
aria-label={`${host.name} - ${statusLabel}`}
>
{/* Header - Enhanced */}
<GlassCardHeader>
<div className="flex items-center gap-3">
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center',
'transition-all duration-300',
overallStatus === 'healthy'
? 'bg-status-healthy/10 text-status-healthy'
: overallStatus === 'warning'
? 'bg-status-warning/10 text-status-warning'
: overallStatus === 'critical'
? 'bg-status-critical/10 text-status-critical'
: 'bg-nothing-gray-100 text-nothing-gray-500'
)}>
{getRoleIcon(host.role || '')}
</div>
<div className="flex flex-col gap-1">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-nothing-gray-50 border border-nothing-gray-100 text-nothing-gray-700 text-xs font-mono">
<Wifi className="w-3 h-3" />
{host.ip}
</span>
</div>
</div>
<StatusOrb status={overallStatus} size="md" glow pulse={overallStatus === 'critical'} />
</GlassCardHeader>
{/* Title - Enhanced */}
<div className="mb-4">
<GlassCardTitle className="text-lg">{host.name}</GlassCardTitle>
{host.role && (
<span className="text-[10px] text-nothing-gray-400 font-mono uppercase tracking-widest">
{host.role.replace('_', ' ')}
</span>
)}
</div>
{/* Services List */}
<GlassCardContent>
<div className="space-y-2">
{host.services.map((service) => (
<div
key={service.name}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-nothing-gray-100/50 transition-colors"
>
<div className="flex items-center gap-2">
<StatusOrb status={mapServiceStatus(service.status)} size="sm" />
<span className="font-mono text-sm text-nothing-gray-800">
{service.name}
</span>
</div>
<div className="flex items-center gap-2">
{service.port && (
<span className="text-xs text-nothing-gray-400 font-mono">
:{service.port}
</span>
)}
{service.latency_ms !== null && service.latency_ms !== undefined && (
<span
className={cn(
'text-xs font-mono',
service.latency_ms > 100
? 'text-status-warning'
: 'text-nothing-gray-500'
)}
>
{service.latency_ms.toFixed(1)}ms
</span>
)}
</div>
</div>
))}
</div>
{/* Metrics Bar */}
{host.metrics && (
<div className="mt-4 pt-3 border-t border-nothing-gray-100">
<div className="grid grid-cols-2 gap-3">
{/* CPU */}
<MetricBar
label={tDashboard('cpu')}
value={host.metrics.cpu_percent}
warningThreshold={60}
criticalThreshold={80}
collectingLabel={tDashboard('waitingData')}
baselineValue={host.metrics.cpu_baseline?.baseline_value}
baselineLabel={tDashboard('baseline')}
/>
{/* Memory */}
<MetricBar
label={tDashboard('memory')}
value={host.metrics.memory_percent}
warningThreshold={70}
criticalThreshold={85}
collectingLabel={tDashboard('waitingData')}
baselineValue={host.metrics.memory_baseline?.baseline_value}
baselineLabel={tDashboard('baseline')}
/>
</div>
</div>
)}
</GlassCardContent>
{/* Footer */}
<GlassCardFooter>
<div className="flex items-center justify-between">
<StatusBadge status={overallStatus} label={statusLabel} size="sm" />
<span className="text-xs text-nothing-gray-400 font-mono">
{new Date(host.last_check).toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
</div>
</GlassCardFooter>
</GlassCard>
)
}
// =============================================================================
// Metric Bar Component
// =============================================================================
interface MetricBarProps {
label: string
value: number | null | undefined
warningThreshold?: number
criticalThreshold?: number
collectingLabel?: string
baselineValue?: number | null
baselineLabel?: string
}
function MetricBar({
label,
value,
warningThreshold = 60,
criticalThreshold = 80,
collectingLabel = '-',
baselineValue,
baselineLabel = '基準線',
}: MetricBarProps) {
// Handle null/undefined values - show collecting state
if (value === null || value === undefined) {
return (
<div className="p-3 rounded-xl bg-nothing-gray-50/50 border border-nothing-gray-100">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-nothing-gray-500 uppercase tracking-wider font-mono">{label}</span>
<span className="text-xs font-mono text-nothing-gray-400 animate-pulse">
{collectingLabel}
</span>
</div>
<div className="h-2 bg-nothing-gray-100 rounded-full overflow-hidden">
<div className="h-full w-1/4 bg-nothing-gray-200 rounded-full animate-shimmer bg-[length:200%_100%] bg-gradient-to-r from-nothing-gray-200 via-nothing-gray-100 to-nothing-gray-200" />
</div>
</div>
)
}
const color =
value >= criticalThreshold
? 'bg-gradient-to-r from-status-critical to-status-critical/80'
: value >= warningThreshold
? 'bg-gradient-to-r from-status-warning to-status-warning/80'
: 'bg-gradient-to-r from-status-healthy to-status-healthy/80'
const glowColor =
value >= criticalThreshold
? 'shadow-[0_0_10px_rgba(255,51,0,0.3)]'
: value >= warningThreshold
? 'shadow-[0_0_10px_rgba(245,158,11,0.3)]'
: ''
return (
<div className={cn(
'p-3 rounded-xl border transition-all duration-300',
value >= criticalThreshold
? 'bg-status-critical/5 border-status-critical/20'
: value >= warningThreshold
? 'bg-status-warning/5 border-status-warning/20'
: 'bg-nothing-gray-50/50 border-nothing-gray-100'
)}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
{label.toLowerCase().includes('cpu') ? (
<Cpu className="w-3 h-3 text-nothing-gray-400" />
) : (
<Gauge className="w-3 h-3 text-nothing-gray-400" />
)}
<span className="text-[10px] text-nothing-gray-500 uppercase tracking-wider font-mono">{label}</span>
</div>
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-sm font-mono font-bold tabular-nums',
value >= criticalThreshold
? 'text-status-critical'
: value >= warningThreshold
? 'text-status-warning'
: 'text-nothing-gray-700'
)}
>
{formatPercent(value)}
</span>
{/* Dynamic Baseline Display */}
{baselineValue !== null && baselineValue !== undefined && (
<span className="text-[9px] font-mono text-nothing-gray-300">
({formatPercent(baselineValue)})
</span>
)}
</div>
</div>
<div className="h-2 bg-nothing-gray-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500 ease-out',
color,
glowColor
)}
style={{ width: `${Math.min(value, 100)}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,273 @@
'use client'
/**
* IncidentCard - Phase 7 事件卡片
* ================================
*
* Nothing.tech 視覺規範:
* - 純白底色 (bg-white)
* - 極細淺灰邊框 (border border-gray-200)
* - 無圓角或微圓角 (rounded-sm)
* - 嚴禁陰影 (shadow-none)
*
* 統帥鐵律: 禁止假數據!所有資料必須來自真實 API
*/
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import type { IncidentResponse } from '@/lib/api-client'
import { AlertTriangle, Clock, Server, ChevronRight } from 'lucide-react'
// =============================================================================
// Severity Config
// =============================================================================
const SEVERITY_CONFIG = {
P0: {
label: 'P0',
color: 'text-red-600',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
dotColor: 'bg-red-500',
},
P1: {
label: 'P1',
color: 'text-orange-600',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
dotColor: 'bg-orange-500',
},
P2: {
label: 'P2',
color: 'text-amber-600',
bgColor: 'bg-amber-50',
borderColor: 'border-amber-200',
dotColor: 'bg-amber-500',
},
P3: {
label: 'P3',
color: 'text-gray-600',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
dotColor: 'bg-gray-400',
},
} as const
const STATUS_CONFIG = {
investigating: {
label: 'investigating',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
mitigating: {
label: 'mitigating',
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
resolved: {
label: 'resolved',
color: 'text-green-600',
bgColor: 'bg-green-50',
},
closed: {
label: 'closed',
color: 'text-gray-500',
bgColor: 'bg-gray-50',
},
} as const
// =============================================================================
// Component
// =============================================================================
interface IncidentCardProps {
incident: IncidentResponse
onClick?: () => void
className?: string
}
export function IncidentCard({
incident,
onClick,
className,
}: IncidentCardProps) {
const t = useTranslations('incident')
const severityConfig = SEVERITY_CONFIG[incident.severity]
const statusConfig = STATUS_CONFIG[incident.status]
// 格式化時間
const createdAt = new Date(incident.created_at)
const timeAgo = getTimeAgo(createdAt)
return (
<div
onClick={onClick}
className={cn(
// Nothing.tech 核心規範
'bg-white',
'border border-gray-200',
'rounded-sm',
'shadow-none',
// 互動狀態
'transition-colors duration-150',
onClick && 'cursor-pointer hover:bg-gray-50 hover:border-gray-300',
// 佈局 (p-6 靈魂注入)
'p-6',
className
)}
>
{/* Header: ID + Severity Badge */}
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-2 min-w-0">
{/* Severity Dot */}
<span
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
severityConfig.dotColor,
(incident.severity === 'P0' || incident.severity === 'P1') &&
'animate-pulse'
)}
/>
{/* Incident ID */}
<span className="font-mono text-sm text-ink truncate">
{incident.incident_id}
</span>
</div>
{/* Severity Badge (Dot Matrix 靈魂注入) */}
<span
className={cn(
'px-3 py-1 text-sm font-dot-matrix font-bold rounded-sm border',
severityConfig.bgColor,
severityConfig.color,
severityConfig.borderColor
)}
>
{severityConfig.label}
</span>
</div>
{/* Status Badge */}
<div className="mb-4">
<span
className={cn(
'inline-flex items-center px-2.5 py-1 text-xs font-mono font-medium rounded-sm',
statusConfig.bgColor,
statusConfig.color
)}
>
{t(`status.${statusConfig.label}`)}
</span>
</div>
{/* Affected Services */}
<div className="mb-4">
<div className="flex items-center gap-1.5 text-xs text-ink-secondary mb-2">
<Server className="w-3 h-3" />
<span>{t('affectedServices')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{incident.affected_services.slice(0, 3).map((service) => (
<span
key={service}
className="px-2 py-1 text-xs font-mono bg-gray-100 text-ink rounded-sm"
>
{service}
</span>
))}
{incident.affected_services.length > 3 && (
<span className="px-2 py-1 text-xs text-ink-secondary">
+{incident.affected_services.length - 3}
</span>
)}
</div>
</div>
{/* Footer: Stats + Time */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center gap-4 text-xs text-ink-secondary">
{/* Signal Count */}
<span className="flex items-center gap-1.5 font-mono">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="font-dot-matrix text-ink">{incident.signal_count}</span> {t('signals')}
</span>
{/* Proposal Count */}
{incident.proposal_count > 0 && (
<span className="flex items-center gap-1.5 font-mono text-purple-600">
<span className="font-dot-matrix">{incident.proposal_count}</span> {t('proposals')}
</span>
)}
</div>
{/* Time + Arrow */}
<div className="flex items-center gap-1.5 text-xs text-ink-secondary">
<Clock className="w-3.5 h-3.5" />
<span className="font-mono">{timeAgo}</span>
{onClick && <ChevronRight className="w-3.5 h-3.5" />}
</div>
</div>
</div>
)
}
// =============================================================================
// Grid Container
// =============================================================================
interface IncidentCardGridProps {
children: React.ReactNode
className?: string
}
export function IncidentCardGrid({ children, className }: IncidentCardGridProps) {
return (
<div
className={cn(
'grid grid-cols-1 md:grid-cols-2 gap-3',
className
)}
>
{children}
</div>
)
}
// =============================================================================
// Empty State
// =============================================================================
export function IncidentEmptyState() {
const t = useTranslations('incident')
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<AlertTriangle className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 font-medium">
{t('emptyState')}
</p>
<p className="text-xs text-gray-400 mt-1">
{t('emptyStateDescription')}
</p>
</div>
)
}
// =============================================================================
// Utilities
// =============================================================================
function getTimeAgo(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}

View File

@@ -0,0 +1,12 @@
/**
* Incident Components - Phase 7
*/
export { IncidentCard, IncidentCardGrid, IncidentEmptyState } from './incident-card'
export {
ThinkingTerminal,
DEMO_DECISION_CHAIN,
type DecisionChain,
type ReasoningStep,
type ThinkingTerminalProps,
} from './thinking-terminal'

View File

@@ -0,0 +1,391 @@
'use client'
/**
* ThinkingTerminal - Phase 7 OpenClaw 思維終端機
* =============================================
*
* 專門渲染 Incident 的 decision_chain (AI 推論過程)
*
* Nothing.tech 視覺規範:
* - 深色背景 (bg-black text-white) - 全站唯一深色區域
* - 強制等寬字體 (font-mono)
* - 打字機效果展示推論步驟
*
* 統帥鐵律: 禁止假數據!
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Terminal, Play, Pause, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface ReasoningStep {
step: string
reasoning: string
timestamp?: string
}
interface DecisionChain {
analysis_type: 'blast_radius' | 'root_cause' | 'action_suggestion'
target_service: string
reasoning_steps: ReasoningStep[]
conclusion: string
confidence: number
}
interface ThinkingTerminalProps {
/** 決策鏈資料 */
decisionChain?: DecisionChain | null
/** 事件 ID */
incidentId?: string
/** 自定義 CSS */
className?: string
/** 最大高度 */
maxHeight?: string
/** 是否自動播放打字效果 */
autoPlay?: boolean
/** 打字速度 (毫秒/字) */
typeSpeed?: number
}
// =============================================================================
// Component
// =============================================================================
export function ThinkingTerminal({
decisionChain,
incidentId,
className,
maxHeight = '400px',
autoPlay = true,
typeSpeed = 30,
}: ThinkingTerminalProps) {
const t = useTranslations('terminal')
const [displayedSteps, setDisplayedSteps] = useState<number>(0)
const [currentText, setCurrentText] = useState<string>('')
const [isPlaying, setIsPlaying] = useState(autoPlay)
const [isExpanded, setIsExpanded] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<NodeJS.Timeout | null>(null)
const steps = decisionChain?.reasoning_steps || []
const totalSteps = steps.length
// 打字機效果
const typeStep = useCallback((stepIndex: number) => {
if (stepIndex >= totalSteps) {
setIsPlaying(false)
return
}
const step = steps[stepIndex]
const fullText = `[${step.step}] ${step.reasoning}`
let charIndex = 0
const typeChar = () => {
if (charIndex < fullText.length) {
setCurrentText(fullText.slice(0, charIndex + 1))
charIndex++
animationRef.current = setTimeout(typeChar, typeSpeed)
} else {
// 完成當前步驟,進入下一步
setDisplayedSteps(stepIndex + 1)
setCurrentText('')
animationRef.current = setTimeout(() => {
if (isPlaying) {
typeStep(stepIndex + 1)
}
}, 500) // 步驟間暫停
}
}
typeChar()
}, [steps, totalSteps, typeSpeed, isPlaying])
// 控制播放
useEffect(() => {
if (isPlaying && displayedSteps < totalSteps) {
typeStep(displayedSteps)
}
return () => {
if (animationRef.current) {
clearTimeout(animationRef.current)
}
}
}, [isPlaying, typeStep, displayedSteps, totalSteps])
// 重置
const handleReset = () => {
if (animationRef.current) {
clearTimeout(animationRef.current)
}
setDisplayedSteps(0)
setCurrentText('')
setIsPlaying(true)
}
// 暫停/繼續
const handleTogglePlay = () => {
setIsPlaying(!isPlaying)
}
// 自動滾動到底部
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [displayedSteps, currentText])
return (
<div
className={cn(
// Nothing.tech 深色終端機風格 (全站唯一深色)
'bg-black',
'border border-gray-800',
'rounded-sm',
'overflow-hidden',
className
)}
>
{/* Terminal Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
{/* Traffic Lights */}
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-500" />
<span className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="w-3 h-3 rounded-full bg-green-500" />
</div>
{/* Title */}
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-gray-400" />
<span className="font-mono text-xs text-gray-400">
OpenClaw Terminal
</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
{incidentId && (
<span className="font-mono text-xs text-gray-600">
{incidentId}
</span>
)}
<button
onClick={handleTogglePlay}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
{isPlaying ? (
<Pause className="w-4 h-4 text-gray-400" />
) : (
<Play className="w-4 h-4 text-gray-400" />
)}
</button>
<button
onClick={handleReset}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
<RotateCcw className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
</div>
</div>
{/* Terminal Body */}
{isExpanded && (
<div
ref={containerRef}
className="p-4 overflow-y-auto font-mono text-sm"
style={{ maxHeight, minHeight: '150px' }}
>
{/* No Data State */}
{!decisionChain && (
<div className="text-gray-500">
<span className="text-green-500">$</span> {t('waitingForData')}
<span className="animate-pulse ml-1">_</span>
</div>
)}
{/* Analysis Header */}
{decisionChain && (
<>
<div className="text-gray-500 mb-2">
<span className="text-green-500">$</span> openclaw analyze --type{' '}
<span className="text-cyan-400">
{decisionChain.analysis_type}
</span>{' '}
--target{' '}
<span className="text-yellow-400">
{decisionChain.target_service}
</span>
</div>
<div className="text-gray-600 mb-4">
{'─'.repeat(50)}
</div>
</>
)}
{/* Displayed Steps */}
{steps.slice(0, displayedSteps).map((step, index) => (
<ThinkingStepLine
key={index}
step={step}
isLast={index === displayedSteps - 1 && !currentText}
/>
))}
{/* Currently Typing */}
{currentText && (
<div className="text-gray-300 whitespace-pre-wrap">
{currentText}
<span className="animate-pulse text-white">_</span>
</div>
)}
{/* Conclusion */}
{displayedSteps >= totalSteps && decisionChain && (
<div className="mt-4 pt-4 border-t border-gray-800">
<div className="text-gray-500 mb-2">
{'─'.repeat(50)}
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-green-500">[CONCLUSION]</span>
<span className="text-green-400/50">
confidence: {(decisionChain.confidence * 100).toFixed(0)}%
</span>
</div>
<div className="text-green-400 pl-4">
{decisionChain.conclusion}
</div>
</div>
)}
{/* Cursor when idle */}
{!isPlaying && displayedSteps >= totalSteps && !currentText && (
<div className="mt-4 text-gray-500">
<span className="text-green-500">$</span>
<span className="animate-pulse ml-1">_</span>
</div>
)}
</div>
)}
{/* Terminal Footer */}
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-800 bg-gray-900/50">
<span className="font-mono text-xs text-gray-600">
{t('steps')}: {displayedSteps}/{totalSteps}
</span>
<span className="font-mono text-xs text-gray-600">
{isPlaying ? t('streaming') : t('paused')}
</span>
</div>
</div>
)
}
// =============================================================================
// Sub-Components
// =============================================================================
interface ThinkingStepLineProps {
step: ReasoningStep
isLast: boolean
}
function ThinkingStepLine({ step, isLast }: ThinkingStepLineProps) {
// 根據步驟類型決定顏色
const getStepColor = (stepType: string): string => {
const lowerStep = stepType.toLowerCase()
if (lowerStep.includes('error') || lowerStep.includes('critical')) {
return 'text-red-400'
}
if (lowerStep.includes('warning') || lowerStep.includes('risk')) {
return 'text-yellow-400'
}
if (lowerStep.includes('success') || lowerStep.includes('complete')) {
return 'text-green-400'
}
if (lowerStep.includes('analysis') || lowerStep.includes('evaluate')) {
return 'text-cyan-400'
}
if (lowerStep.includes('action') || lowerStep.includes('execute')) {
return 'text-purple-400'
}
return 'text-blue-400'
}
return (
<div className={cn('mb-3', isLast && 'animate-fade-in')}>
<div className="flex items-start gap-2">
<span className={cn('font-bold', getStepColor(step.step))}>
[{step.step}]
</span>
</div>
<div className="text-gray-300 pl-4 whitespace-pre-wrap leading-relaxed">
{step.reasoning}
</div>
{step.timestamp && (
<div className="text-gray-600 text-xs pl-4 mt-1">
@ {step.timestamp}
</div>
)}
</div>
)
}
// =============================================================================
// Demo Data (for testing - will be removed in production)
// =============================================================================
export const DEMO_DECISION_CHAIN: DecisionChain = {
analysis_type: 'blast_radius',
target_service: 'api-gateway',
reasoning_steps: [
{
step: 'SIGNAL_RECEIVED',
reasoning: 'Received PodCrashLoopBackOff alert for api-gateway in production namespace.',
timestamp: '2026-03-22T16:41:18Z',
},
{
step: 'SEVERITY_EVALUATION',
reasoning: 'Alert severity: CRITICAL (P0). Requires immediate attention.',
},
{
step: 'BLAST_RADIUS_ANALYSIS',
reasoning: 'Analyzing upstream dependencies...\n- payment-service: AFFECTED\n- order-service: AFFECTED\n- user-service: AFFECTED\nTotal impact: 3 services, ~2000 requests/min',
},
{
step: 'ROOT_CAUSE_HYPOTHESIS',
reasoning: 'Possible causes:\n1. OOMKilled (memory limit exceeded)\n2. Liveness probe failure\n3. Application panic/crash',
},
{
step: 'ACTION_RECOMMENDATION',
reasoning: 'Recommended action: Restart deployment api-gateway\nRisk level: CRITICAL (requires 2 signatures)\nEstimated downtime: 5-15 min',
},
],
conclusion: 'Generate proposal: Restart deployment api-gateway with multi-sig approval.',
confidence: 0.87,
}
// =============================================================================
// Exports
// =============================================================================
export type { DecisionChain, ReasoningStep, ThinkingTerminalProps }

View File

@@ -0,0 +1,121 @@
'use client'
/**
* AppLayout - 全域應用佈局
* ========================
* CPO-102: 整合側邊欄 + 頂部狀態列
*
* Features:
* - 響應式側邊欄折疊
* - 主內容區域自動適應
* - 持久化折疊狀態 (localStorage)
*/
import { useState, useEffect } from 'react'
import { Sidebar } from './sidebar'
import { Header } from './header'
import { DotMatrixBg } from '@/components/ui/dot-matrix-bg'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
interface AppLayoutProps {
locale: string
children: React.ReactNode
showBackground?: boolean
}
// =============================================================================
// Constants
// =============================================================================
const SIDEBAR_STATE_KEY = 'awoooi-sidebar-collapsed'
// =============================================================================
// Component
// =============================================================================
export function AppLayout({
locale,
children,
showBackground = true,
}: AppLayoutProps) {
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
// Load collapsed state from localStorage
useEffect(() => {
setMounted(true)
const saved = localStorage.getItem(SIDEBAR_STATE_KEY)
if (saved === 'true') {
setCollapsed(true)
}
}, [])
// Save collapsed state to localStorage
const handleToggle = () => {
const newState = !collapsed
setCollapsed(newState)
localStorage.setItem(SIDEBAR_STATE_KEY, String(newState))
}
// Prevent hydration mismatch
if (!mounted) {
return (
<div className="min-h-screen bg-nothing-gray-50">
{showBackground && (
<DotMatrixBg
spacing={24}
dotSize={1}
dotColor="rgba(0, 0, 0, 0.06)"
bgColor="#FAFAFA"
/>
)}
<div className="relative z-10">{children}</div>
</div>
)
}
return (
<div className="min-h-screen bg-nothing-gray-50">
{/* Background */}
{showBackground && (
<DotMatrixBg
spacing={24}
dotSize={1}
dotColor="rgba(0, 0, 0, 0.06)"
bgColor="#FAFAFA"
/>
)}
{/* Sidebar */}
<Sidebar
locale={locale}
collapsed={collapsed}
onToggle={handleToggle}
/>
{/* Header */}
<Header
locale={locale}
sidebarCollapsed={collapsed}
/>
{/* Main Content */}
<main
className={cn(
'relative z-10',
'pt-16', // Header height
'transition-all duration-300 ease-out',
collapsed ? 'ml-16' : 'ml-64'
)}
>
<div className="p-6">
{children}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
/**
* Header - 頂部狀態列
* ====================
* CPO-102: Nothing.tech 明亮工業風頂部導航
*
* Features:
* - 玻璃效果 + 固定定位
* - 連線狀態指示器
* - 語系切換器
* - 使用者選單
*/
import { useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
import { StatusOrb } from '@/components/ui/status-orb'
import { useConnectionStatus, useMockMode } from '@/stores/dashboard.store'
import { useApprovalCount } from '@/stores/approval.store'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
interface HeaderProps {
locale: string
sidebarCollapsed?: boolean
className?: string
}
// =============================================================================
// Component
// =============================================================================
export function Header({
locale,
sidebarCollapsed = false,
className,
}: HeaderProps) {
const t = useTranslations('locale')
const tDashboard = useTranslations('dashboard')
const pathname = usePathname()
const connectionStatus = useConnectionStatus()
const mockMode = useMockMode()
const pendingApprovals = useApprovalCount()
const switchLocale = useCallback((newLocale: string) => {
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`)
window.location.href = newPath
}, [locale, pathname])
const tConnection = useTranslations('connection')
const connectionLabel = tConnection(connectionStatus)
const connectionOrbStatus = {
disconnected: 'idle',
connecting: 'syncing',
connected: 'healthy',
reconnecting: 'warning',
error: 'critical',
}[connectionStatus] as 'idle' | 'syncing' | 'healthy' | 'warning' | 'critical'
return (
<header
className={cn(
'fixed top-0 right-0 h-16 z-30',
'flex items-center justify-between px-6',
// 玻璃效果
'bg-white/85 backdrop-blur-[20px]',
'border-b border-black/[0.06]',
// 寬度根據 sidebar 調整
'transition-all duration-300 ease-out',
sidebarCollapsed ? 'left-16' : 'left-64',
className
)}
>
{/* Left: Page Title / Breadcrumb */}
<div className="flex items-center gap-4">
<h1 className="font-heading text-xl font-bold text-nothing-black tracking-tight">
{tDashboard('title')}
</h1>
</div>
{/* Right: Status + Locale + User */}
<div className="flex items-center gap-6">
{/* Pending Approvals Badge */}
{pendingApprovals > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-status-critical/10 border border-status-critical/30">
<StatusOrb status="critical" size="sm" pulse />
<span className="text-sm font-mono text-status-critical">
{pendingApprovals} {tDashboard('pendingApprovals')}
</span>
</div>
)}
{/* Connection Status */}
<div className="flex items-center gap-2">
<StatusOrb status={connectionOrbStatus} size="md" glow />
<span className={cn(
'font-mono text-sm',
connectionStatus === 'connected' ? 'text-status-healthy' : 'text-nothing-gray-500'
)}>
{connectionLabel}
</span>
{mockMode && (
<span className="px-2 py-0.5 text-xs font-mono rounded bg-status-warning/10 text-status-warning border border-status-warning/30">
MOCK
</span>
)}
</div>
{/* Locale Switcher */}
<div className="flex items-center gap-1 bg-nothing-gray-100 rounded-lg p-1">
<button
onClick={() => switchLocale('zh-TW')}
className={cn(
'px-3 py-1.5 rounded-md font-mono text-sm transition-all',
locale === 'zh-TW'
? 'bg-nothing-black text-white'
: 'text-nothing-gray-600 hover:text-nothing-black'
)}
>
{t('zhTW')}
</button>
<button
onClick={() => switchLocale('en')}
className={cn(
'px-3 py-1.5 rounded-md font-mono text-sm transition-all',
locale === 'en'
? 'bg-nothing-black text-white'
: 'text-nothing-gray-600 hover:text-nothing-black'
)}
>
{t('en')}
</button>
</div>
{/* User Avatar */}
<button className="w-9 h-9 rounded-full bg-nothing-gray-200 flex items-center justify-center hover:bg-nothing-gray-300 transition-colors">
<span className="text-sm font-medium text-nothing-gray-700">A</span>
</button>
</div>
</header>
)
}

View File

@@ -0,0 +1,9 @@
/**
* Layout Components
* =================
* CPO-102: Global Layout 與側邊欄
*/
export { Sidebar } from './sidebar'
export { Header } from './header'
export { AppLayout } from './app-layout'

View File

@@ -0,0 +1,239 @@
'use client'
/**
* Sidebar - 高透光玻璃側邊欄
* ==========================
* CPO-102: Nothing.tech 明亮工業風側邊導航
* Phase 2.5: lucide-react 純代碼視覺 (符合第七章憲法)
*
* Features:
* - backdrop-blur 毛玻璃效果
* - 固定寬度 (w-64)
* - 折疊/展開動畫 - 機械爪開合意象
* - i18n 導航標籤
* - 當前路由高亮
* - lucide-react 圖示 (無實體圖片依賴)
*/
import { useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
import { StatusOrb } from '@/components/ui/status-orb'
import { cn } from '@/lib/utils'
import {
LayoutDashboard,
ShieldCheck,
Zap,
BookOpen,
Settings,
Bot,
Eye,
ChevronLeft,
ChevronRight,
Sparkles,
} from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface SidebarProps {
locale: string
collapsed?: boolean
onToggle?: () => void
className?: string
}
// =============================================================================
// Navigation Items (using lucide-react icons)
// =============================================================================
type NavItemConfig = {
id: string
href: string
labelKey: string
Icon: typeof LayoutDashboard
}
const NAV_ITEMS: NavItemConfig[] = [
{ id: 'dashboard', href: '/demo', labelKey: 'dashboard', Icon: LayoutDashboard },
{ id: 'approvals', href: '/approvals', labelKey: 'approvals', Icon: ShieldCheck },
{ id: 'actions', href: '/action-logs', labelKey: 'actions', Icon: Zap },
{ id: 'knowledge', href: '/knowledge', labelKey: 'knowledge', Icon: BookOpen },
{ id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings },
]
// =============================================================================
// Component
// =============================================================================
export function Sidebar({
locale,
collapsed = false,
onToggle,
className,
}: SidebarProps) {
const t = useTranslations('nav')
const tBrand = useTranslations('brand')
const tClawbot = useTranslations('openclaw')
const pathname = usePathname()
const isActive = (href: string) => {
const fullHref = `/${locale}${href}`
return pathname === fullHref || pathname.startsWith(fullHref + '/')
}
return (
<aside
className={cn(
'fixed left-0 top-0 h-screen z-40',
'flex flex-col',
// 玻璃效果
'bg-white/70 backdrop-blur-[20px]',
'border-r border-black/[0.06]',
// 寬度動畫
'transition-all duration-300 ease-out',
collapsed ? 'w-16' : 'w-64',
className
)}
>
{/* Logo - lucide-react + CSS Typography (第七章規範) */}
<div className={cn(
'flex items-center border-b border-black/[0.04]',
'transition-all duration-300',
collapsed ? 'h-20 justify-center px-3' : 'h-24 px-6'
)}>
<div className={cn(
'flex items-center gap-3',
collapsed ? 'flex-col' : ''
)}>
{/* Bot Icon + 呼吸燈效果 */}
<div className="relative">
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center',
'bg-gradient-to-br from-claw-blue/20 to-claw-blue/10',
'border border-claw-blue/30',
'transition-all duration-300'
)}>
<Eye className="w-5 h-5 text-claw-blue" />
</div>
{/* Breathing indicator */}
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-claw-blue opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-claw-blue" />
</div>
</div>
{/* 品牌字體排版 - i18n 規範 */}
{!collapsed && (
<div className="flex flex-col">
<span className="text-xl font-black tracking-widest text-nothing-black uppercase font-mono">
{tBrand('name')}
</span>
<span className="text-[9px] text-nothing-gray-500 tracking-widest font-mono font-medium uppercase">
{tBrand('version')} | {tBrand('tagline')}
</span>
</div>
)}
</div>
</div>
{/* Navigation - lucide-react Icons */}
<nav className="flex-1 py-6 overflow-y-auto">
<ul className="space-y-1.5 px-3">
{NAV_ITEMS.map((item) => {
const active = isActive(item.href)
return (
<li key={item.id}>
<a
href={`/${locale}${item.href}`}
className={cn(
'flex items-center gap-3.5 px-3.5 py-3 rounded-xl',
'transition-all duration-200',
'group relative',
active
? 'bg-nothing-black text-white shadow-lg shadow-black/20'
: 'text-nothing-gray-600 hover:bg-nothing-gray-100/80 hover:text-nothing-black',
collapsed && 'justify-center px-2'
)}
>
{/* Active indicator - 左側條紋 */}
{active && !collapsed && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-white/30 rounded-r-full" />
)}
<item.Icon className={cn(
'w-5 h-5 flex-shrink-0 transition-all duration-200',
active ? 'opacity-100' : 'opacity-60 group-hover:opacity-100',
'group-hover:scale-105'
)} />
{!collapsed && (
<span className="font-medium text-[13px] tracking-wide truncate">
{t(item.labelKey)}
</span>
)}
</a>
</li>
)
})}
</ul>
</nav>
{/* OpenClaw Status - lucide-react Bot Icon */}
<div className={cn(
'border-t border-black/[0.04] p-5',
collapsed ? 'flex justify-center' : ''
)}>
<div className={cn(
'flex items-center',
collapsed ? 'flex-col gap-2' : 'gap-3.5'
)}>
{/* Bot Icon with glow */}
<div className="relative">
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center',
'bg-gradient-to-br from-status-thinking/20 to-status-thinking/10',
'border border-status-thinking/30'
)}>
<Bot className="w-5 h-5 text-status-thinking" />
</div>
{/* Sparkle effect */}
<Sparkles className="absolute -top-1 -right-1 w-3 h-3 text-status-thinking animate-pulse" />
</div>
{!collapsed && (
<div className="flex flex-col gap-0.5">
<span className="text-sm font-bold text-nothing-black tracking-tight">
{tClawbot('name')}
</span>
<span className="text-[9px] text-status-thinking font-mono uppercase tracking-widest">
{tClawbot('monitoring')}
</span>
</div>
)}
</div>
</div>
{/* Collapse Toggle - lucide-react Chevron */}
<button
onClick={onToggle}
className={cn(
'absolute -right-4 top-24',
'w-8 h-8 rounded-full',
'bg-white border-2 border-nothing-gray-200/80',
'shadow-md shadow-black/5',
'flex items-center justify-center',
'hover:bg-nothing-gray-50 hover:border-nothing-gray-300',
'hover:shadow-lg hover:shadow-black/10',
'active:scale-95',
'transition-all duration-200'
)}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? (
<ChevronRight className="w-4 h-4 text-nothing-gray-600" />
) : (
<ChevronLeft className="w-4 h-4 text-nothing-gray-600" />
)}
</button>
</aside>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
/**
* LanguageSwitcher - 賽博風語系切換器
*
* Nothing.tech 風格極簡設計
* 支援 EN / 繁中 切換
*/
import { useLocale } from 'next-intl'
import { useRouter, usePathname, locales, localeShortNames, type Locale } from '@/i18n/routing'
import { cn } from '@/lib/utils'
interface LanguageSwitcherProps {
className?: string
}
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
const locale = useLocale() as Locale
const router = useRouter()
const pathname = usePathname()
const handleSwitch = (newLocale: Locale) => {
if (newLocale === locale) return
router.replace(pathname, { locale: newLocale })
}
return (
<div
className={cn(
'px-1 py-1 flex items-center gap-1 rounded-button',
'bg-nothing-gray-100 border border-nothing-gray-200',
className
)}
>
{locales.map((loc) => {
const isActive = loc === locale
return (
<button
key={loc}
onClick={() => handleSwitch(loc)}
className={cn(
'px-3 py-1.5 rounded-sm font-mono text-xs transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-nothing-gray-400',
isActive
? 'bg-nothing-gray-900 text-nothing-white'
: 'text-nothing-gray-600 hover:text-nothing-gray-900 hover:bg-nothing-gray-200'
)}
aria-label={`Switch to ${localeShortNames[loc]}`}
aria-pressed={isActive}
>
{localeShortNames[loc]}
</button>
)
})}
</div>
)
}
/**
* LanguageSwitcherCompact - 更緊湊的版本
* 只顯示當前語系,點擊切換
*/
export function LanguageSwitcherCompact({ className }: LanguageSwitcherProps) {
const locale = useLocale() as Locale
const router = useRouter()
const pathname = usePathname()
const handleToggle = () => {
const currentIndex = locales.indexOf(locale)
const nextIndex = (currentIndex + 1) % locales.length
const newLocale = locales[nextIndex]
router.replace(pathname, { locale: newLocale })
}
return (
<button
onClick={handleToggle}
className={cn(
'px-3 py-1.5 rounded-button',
'bg-nothing-gray-100 border border-nothing-gray-200',
'font-mono text-xs text-nothing-gray-600',
'hover:text-nothing-gray-900 hover:bg-nothing-gray-200',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-nothing-gray-400',
className
)}
aria-label="Toggle language"
>
<span className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-status-healthy" />
{localeShortNames[locale]}
</span>
</button>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { cn } from '@/lib/utils'
type AgentStatus = 'idle' | 'thinking' | 'executing' | 'waiting_approval'
interface StatusOrbProps {
status: AgentStatus
size?: 'sm' | 'md' | 'lg'
className?: string
}
const statusConfig: Record<AgentStatus, { color: string; label: string; animate: boolean }> = {
idle: {
color: 'bg-nothing-gray-600',
label: 'Idle',
animate: false,
},
thinking: {
color: 'bg-status-warning',
label: 'Thinking',
animate: true,
},
executing: {
color: 'bg-status-healthy',
label: 'Executing',
animate: true,
},
waiting_approval: {
color: 'bg-status-critical',
label: 'Awaiting Approval',
animate: true,
},
}
const sizeConfig = {
sm: 'w-8 h-8',
md: 'w-16 h-16',
lg: 'w-24 h-24',
}
export function StatusOrb({ status, size = 'md', className }: StatusOrbProps) {
const config = statusConfig[status]
const sizeClass = sizeConfig[size]
return (
<div className={cn('relative', className)}>
{/* Outer glow */}
<div
className={cn(
'absolute inset-0 rounded-full blur-xl opacity-30',
config.color,
config.animate && 'animate-pulse-slow'
)}
/>
{/* Main orb */}
<div
className={cn(
'relative rounded-full border border-nothing-gray-700',
sizeClass,
config.color,
config.animate && 'animate-breathe'
)}
>
{/* Inner highlight */}
<div className="absolute inset-2 rounded-full bg-gradient-to-br from-white/20 to-transparent" />
</div>
{/* Label */}
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="font-mono text-xs text-nothing-gray-500">
{config.label}
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,188 @@
'use client'
/**
* ThinkingStreamTest - Tracer Bullet 測試元件 (企業級強化版)
*
* 修復項目:
* 1. Buffer 累積 - 防止 TCP 封包切斷 JSON
* 2. AbortController - 防止 Memory Leak
* 3. Zustand 整合 - 符合 ADR-004
* 4. Nothing.tech 美學 - 符合 ADR-002
*/
import { useCallback, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useAgentStore, type ThinkingStep } from '@/stores/agent.store'
interface StreamThinkingStep {
type: 'thinking' | 'result'
content: string
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/v1'
export function ThinkingStreamTest() {
// ADR-004: 使用 Zustand 全域狀態
const {
status,
thinkingStream,
setStatus,
appendThinking,
clearThinking,
} = useAgentStore()
const isStreaming = status === 'thinking'
// 防線 1: AbortController 用於中斷連線
const abortControllerRef = useRef<AbortController | null>(null)
// 防線 2: 組件卸載時自動清理
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
const startStream = useCallback(async () => {
// 中斷前一次未完成的請求
abortControllerRef.current?.abort()
abortControllerRef.current = new AbortController()
// 重置狀態 (透過 Zustand)
clearThinking()
setStatus('thinking')
try {
const response = await fetch(`${API_BASE_URL}/agent/thinking`, {
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('無法建立串流通道')
}
const decoder = new TextDecoder()
let buffer = '' // 防線 3: 字串緩衝區,防止 JSON 被切斷
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE 規範: 事件之間以 \n\n 分隔
const events = buffer.split('\n\n')
// 保留最後一個可能不完整的片段
buffer = events.pop() || ''
for (const event of events) {
if (event.startsWith('data: ')) {
const data = event.slice(6).trim()
// 結束標記
if (data === '[DONE]') {
setStatus('idle')
return
}
// 安全解析 JSON
try {
const step = JSON.parse(data) as StreamThinkingStep
// 派發到 Zustand (ADR-004)
appendThinking({
type: step.type === 'result' ? 'result' : 'thinking',
content: step.content,
timestamp: new Date(),
})
} catch (e) {
console.warn('JSON 解析錯誤,跳過片段:', data)
}
}
}
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('串流已手動中斷')
} else {
const message = err instanceof Error ? err.message : '未知錯誤'
appendThinking({
type: 'error',
content: message,
timestamp: new Date(),
})
}
} finally {
setStatus('idle')
}
}, [setStatus, appendThinking, clearThinking])
return (
<div className="glass-panel p-6 max-w-2xl w-full space-y-4">
{/* Header - Nothing.tech 風格 */}
<div className="flex items-center justify-between border-b border-nothing-gray-800 pb-4">
<h2 className="font-display text-display-sm">AWOOOI Terminal</h2>
<span className="font-mono text-xs text-nothing-gray-500">
v0.1.0 | SSE
</span>
</div>
{/* Control Button */}
<button
onClick={startStream}
disabled={isStreaming}
className={cn(
'w-full py-3 px-4 rounded-button font-mono text-sm transition-all border',
isStreaming
? 'bg-nothing-gray-800 text-nothing-gray-500 border-nothing-gray-700 cursor-not-allowed'
: 'bg-nothing-white text-nothing-black border-transparent hover:bg-nothing-gray-200'
)}
>
{isStreaming ? '>_ EXECUTING...' : 'INITIATE SYNC'}
</button>
{/* Terminal Output */}
<div className="bg-nothing-gray-900 rounded-card p-4 min-h-[200px] max-h-[300px] overflow-y-auto">
{thinkingStream.length === 0 && !isStreaming && (
<div className="font-mono text-sm text-nothing-gray-600">
{'>'} Waiting for command...
</div>
)}
{thinkingStream.map((step, index) => (
<div
key={index}
className={cn(
'font-mono text-sm py-0.5 animate-fade-in',
step.type === 'result' && 'text-status-healthy',
step.type === 'thinking' && 'text-nothing-gray-500',
step.type === 'error' && 'text-status-critical'
)}
>
[{step.type.toUpperCase()}] {step.content}
</div>
))}
{/* Cursor 動畫 */}
{isStreaming && (
<div className="flex items-center gap-1 mt-1">
<span className="font-mono text-sm text-nothing-gray-500">{'>'}</span>
<span className="w-2 h-4 bg-nothing-white animate-pulse" />
</div>
)}
</div>
{/* Connection Info */}
<div className="pt-2 border-t border-nothing-gray-800">
<p className="font-mono text-xs text-nothing-gray-600">
ENDPOINT: {API_BASE_URL}/agent/thinking
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
/**
* ActionTimeline - 行動日誌時間軸 (Phase 4 企業級)
* =================================================
* MVP 最終閉環 - Nothing.tech 骨架風格
*
* 視覺鐵律:
* - 極細灰色連接線
* - 透明背景
* - VT323 點陣字體
* - 微光色彩點綴 (非大面積發光)
*/
import { useTimelineEvents, useNewestExecEventId, type TimelineEvent, type TimelineEventType, type TimelineEventStatus } from '@/stores/timeline.store'
import { cn } from '@/lib/utils'
import {
Radio, // SYSTEM
Brain, // AGENT
ShieldAlert, // SECURITY
UserCheck, // HUMAN
Zap, // EXEC
} from 'lucide-react'
// =============================================================================
// Event Config (統帥規格)
// =============================================================================
interface EventConfig {
icon: React.ComponentType<{ className?: string }>
label: string
labelColor: string
}
const EVENT_CONFIG: Record<TimelineEventType, EventConfig> = {
system: {
icon: Radio,
label: 'SYSTEM',
labelColor: 'text-nothing-gray-500',
},
agent: {
icon: Brain,
label: 'AGENT',
labelColor: 'text-claw-blue',
},
security: {
icon: ShieldAlert,
label: 'SECURITY',
labelColor: 'text-status-critical',
},
human: {
icon: UserCheck,
label: 'HUMAN',
labelColor: 'text-status-healthy',
},
exec: {
icon: Zap,
label: 'EXEC',
labelColor: 'text-status-warning',
},
}
const STATUS_DOT_COLOR: Record<TimelineEventStatus, string> = {
info: 'bg-nothing-gray-400',
success: 'bg-status-healthy',
warning: 'bg-status-warning',
error: 'bg-status-critical',
}
// =============================================================================
// Helper Functions
// =============================================================================
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
// =============================================================================
// Timeline Event Item (Nothing.tech 骨架風格)
// =============================================================================
interface TimelineItemProps {
event: TimelineEvent
isLast: boolean
isNewest?: boolean
isPulsingExec?: boolean
}
function TimelineItem({ event, isLast, isNewest = false, isPulsingExec = false }: TimelineItemProps) {
const config = EVENT_CONFIG[event.type]
const Icon = config.icon
const dotColor = STATUS_DOT_COLOR[event.status]
// 閃爍條件: 是最新項目 OR 是新發現的 EXEC 事件
const shouldPulse = isNewest || isPulsingExec
return (
<div className={cn(
"relative flex gap-3 pb-3 last:pb-0",
shouldPulse && "animate-pulse-3s"
)}>
{/* Vertical Line (極細灰色) */}
{!isLast && (
<div className="absolute left-[5px] top-3 bottom-0 w-px bg-nothing-gray-200/60" />
)}
{/* Dot (微光點綴) */}
<div className="relative z-10 flex-shrink-0 mt-0.5">
<div className={cn(
'w-[11px] h-[11px] rounded-full',
dotColor,
event.status === 'error' && 'shadow-[0_0_6px_rgba(239,68,68,0.4)]',
event.status === 'success' && 'shadow-[0_0_6px_rgba(34,197,94,0.4)]',
)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 -mt-0.5">
{/* Header: Timestamp + Label */}
<div className="flex items-center gap-2 mb-0.5">
<span className="font-dot-matrix text-[10px] text-nothing-gray-400 tracking-wider tabular-nums">
{formatTimestamp(event.timestamp)}
</span>
<span className={cn(
'font-dot-matrix text-[9px] tracking-widest',
config.labelColor
)}>
[{config.label}]
</span>
</div>
{/* Title */}
<p className="font-mono text-[11px] text-nothing-gray-700 leading-tight">
{event.title}
</p>
{/* Error Description - Nothing.tech 點陣紅驚嘆號 */}
{event.status === 'error' && event.description && (
<div className="mt-1.5 flex items-start gap-1.5 p-2 rounded bg-nothing-red/5 border border-nothing-red/20">
<span className="font-dot-matrix text-nothing-red text-[14px] leading-none animate-pulse">!</span>
<pre className="font-mono text-[9px] text-nothing-red/90 leading-relaxed whitespace-pre-wrap break-all">
{event.description}
</pre>
</div>
)}
{/* Actor (if present) */}
{event.actor && (
<div className="flex items-center gap-1.5 mt-0.5">
<Icon className={cn('w-2.5 h-2.5', config.labelColor)} />
<span className="font-mono text-[9px] text-nothing-gray-500">
{event.actor}
</span>
{event.actorRole && (
<span className={cn(
'px-1 py-0.5 rounded text-[7px] font-mono font-bold uppercase',
event.actorRole === 'cto' || event.actorRole === 'ciso'
? 'bg-status-healthy/10 text-status-healthy'
: event.actorRole === 'ai'
? 'bg-claw-blue/10 text-claw-blue'
: 'bg-nothing-gray-100 text-nothing-gray-500'
)}>
{event.actorRole}
</span>
)}
</div>
)}
{/* Risk Badge */}
{event.riskLevel === 'critical' && (
<span className="inline-block mt-1 px-1 py-0.5 rounded text-[7px] font-mono font-bold uppercase bg-status-critical/10 text-status-critical">
CRITICAL
</span>
)}
</div>
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
interface ActionTimelineProps {
className?: string
maxDisplay?: number
}
export function ActionTimeline({ className, maxDisplay = 12 }: ActionTimelineProps) {
const events = useTimelineEvents()
const newestExecEventId = useNewestExecEventId()
const displayEvents = events.slice(0, maxDisplay)
return (
<div className={cn('', className)}>
{/* Header (VT323 點陣風格) */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-nothing-gray-100">
<h3 className="font-dot-matrix text-xs text-nothing-gray-600 tracking-widest">
ACTION LOG
</h3>
<span className="font-dot-matrix text-[9px] text-nothing-gray-400 tabular-nums">
{events.length}
</span>
</div>
{/* Timeline (透明背景) */}
{displayEvents.length === 0 ? (
<div className="py-8 text-center">
<Radio className="w-5 h-5 text-nothing-gray-300 mx-auto mb-2" />
<p className="font-dot-matrix text-[10px] text-nothing-gray-400 tracking-wider">
AWAITING EVENTS...
</p>
</div>
) : (
<div className="space-y-0">
{displayEvents.map((event, index) => (
<TimelineItem
key={event.id}
event={event}
isLast={index === displayEvents.length - 1}
isNewest={index === 0}
isPulsingExec={event.id === newestExecEventId}
/>
))}
</div>
)}
{/* More indicator */}
{events.length > maxDisplay && (
<div className="mt-2 pt-2 border-t border-nothing-gray-100">
<p className="font-dot-matrix text-[9px] text-nothing-gray-400 text-center tracking-wider">
+{events.length - maxDisplay} MORE
</p>
</div>
)}
</div>
)
}
export default ActionTimeline

View File

@@ -0,0 +1,7 @@
/**
* Timeline Components
* ===================
* Phase 4: Action Log Timeline
*/
export { ActionTimeline } from './action-timeline'

View File

@@ -0,0 +1,118 @@
'use client'
/**
* BorderBeam - Magic UI 流光邊框特效
* ===================================
* 用於 CRITICAL 風險等級的審核卡片
*
* Features:
* - 沿邊框流動的光束動畫
* - 可配置顏色、速度、寬度
* - 支援暫停/啟動
*/
import { cn } from '@/lib/utils'
import { type CSSProperties, type HTMLAttributes } from 'react'
// =============================================================================
// Types
// =============================================================================
export interface BorderBeamProps extends HTMLAttributes<HTMLDivElement> {
/** 動畫持續時間 (秒) */
duration?: number
/** 邊框寬度 (px) */
borderWidth?: number
/** 光束顏色 - 起始色 */
colorFrom?: string
/** 光束顏色 - 結束色 */
colorTo?: string
/** 動畫延遲 (秒) */
delay?: number
/** 光束尺寸 (px) */
size?: number
/** 是否暫停動畫 */
paused?: boolean
}
// =============================================================================
// Component
// =============================================================================
export function BorderBeam({
className,
duration = 6,
borderWidth = 2,
colorFrom = '#FF3300',
colorTo = '#FF6B35',
delay = 0,
size = 200,
paused = false,
...props
}: BorderBeamProps) {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 rounded-xl overflow-hidden',
'[mask-composite:intersect] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]',
className
)}
style={
{
'--duration': `${duration}s`,
'--delay': `${delay}s`,
'--color-from': colorFrom,
'--color-to': colorTo,
'--size': `${size}px`,
padding: `${borderWidth}px`,
} as CSSProperties
}
{...props}
>
<div
className={cn(
'absolute inset-0',
'bg-[conic-gradient(from_calc(var(--start,_0)*1turn),transparent_0%,var(--color-from)_10%,var(--color-to)_20%,transparent_30%)]',
'[animation:border-beam_var(--duration)_linear_infinite]',
paused && '[animation-play-state:paused]'
)}
style={{
width: `calc(100% + var(--size))`,
height: `calc(100% + var(--size))`,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
animationDelay: `var(--delay)`,
}}
/>
</div>
)
}
/**
* CriticalGlow - CRITICAL 風險等級的脈動光暈
*/
export function CriticalGlow({
className,
intensity = 'medium',
...props
}: HTMLAttributes<HTMLDivElement> & { intensity?: 'low' | 'medium' | 'high' }) {
const intensityStyles = {
low: 'opacity-20',
medium: 'opacity-40',
high: 'opacity-60',
}
return (
<div
className={cn(
'absolute inset-0 rounded-xl pointer-events-none',
'bg-gradient-to-br from-status-critical/20 via-transparent to-status-critical/10',
intensityStyles[intensity],
'animate-pulse',
className
)}
{...props}
/>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
/**
* DotMatrixBg - 工業風點陣背景
* ============================
* Nothing.tech 明亮工業風設計
*
* Features:
* - CSS Pattern 實作 (高效能)
* - 可調整點陣密度與顏色
* - 支援漸層遮罩
* - 固定背景或跟隨滾動
*
* Usage:
* <DotMatrixBg />
* <main>...content...</main>
*/
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export interface DotMatrixBgProps extends HTMLAttributes<HTMLDivElement> {
/** 點陣密度 (間距 px) */
spacing?: number
/** 點的大小 (px) */
dotSize?: number
/** 點的顏色 (CSS color) */
dotColor?: string
/** 背景顏色 */
bgColor?: string
/** 是否固定位置 (fixed) */
fixed?: boolean
/** 是否添加漸層遮罩 */
fade?: 'none' | 'bottom' | 'edges'
/** 透明度 (0-1) */
opacity?: number
}
// =============================================================================
// Component
// =============================================================================
export const DotMatrixBg = forwardRef<HTMLDivElement, DotMatrixBgProps>(
(
{
spacing = 24,
dotSize = 1,
dotColor = 'rgba(0, 0, 0, 0.08)',
bgColor = '#FAFAFA',
fixed = true,
fade = 'none',
opacity = 1,
className,
style,
...props
},
ref
) => {
// CSS Pattern for dot matrix
const dotPattern = `radial-gradient(circle, ${dotColor} ${dotSize}px, transparent ${dotSize}px)`
// Fade gradients
const fadeStyles: Record<typeof fade, string> = {
none: '',
bottom: 'mask-image: linear-gradient(to bottom, black 70%, transparent 100%)',
edges: 'mask-image: radial-gradient(ellipse at center, black 50%, transparent 100%)',
}
return (
<div
ref={ref}
aria-hidden="true"
className={cn(
// Positioning
fixed ? 'fixed' : 'absolute',
'inset-0 -z-10',
// Pointer events
'pointer-events-none',
// Custom
className
)}
style={{
backgroundColor: bgColor,
backgroundImage: dotPattern,
backgroundSize: `${spacing}px ${spacing}px`,
backgroundPosition: '0 0',
opacity,
...(fade !== 'none' && {
WebkitMaskImage:
fade === 'bottom'
? 'linear-gradient(to bottom, black 70%, transparent 100%)'
: 'radial-gradient(ellipse at center, black 50%, transparent 100%)',
maskImage:
fade === 'bottom'
? 'linear-gradient(to bottom, black 70%, transparent 100%)'
: 'radial-gradient(ellipse at center, black 50%, transparent 100%)',
}),
...style,
}}
{...props}
/>
)
}
)
DotMatrixBg.displayName = 'DotMatrixBg'
// =============================================================================
// Preset Variants
// =============================================================================
/** 淺色點陣 (預設) */
export function DotMatrixLight(props: Omit<DotMatrixBgProps, 'dotColor' | 'bgColor'>) {
return (
<DotMatrixBg
dotColor="rgba(0, 0, 0, 0.06)"
bgColor="#FAFAFA"
{...props}
/>
)
}
/** 極淺點陣 (更subtle) */
export function DotMatrixSubtle(props: Omit<DotMatrixBgProps, 'dotColor' | 'bgColor'>) {
return (
<DotMatrixBg
dotColor="rgba(0, 0, 0, 0.03)"
bgColor="#FFFFFF"
spacing={20}
{...props}
/>
)
}
/** 密集點陣 (更工業風) */
export function DotMatrixDense(props: Omit<DotMatrixBgProps, 'spacing'>) {
return (
<DotMatrixBg
spacing={16}
dotSize={1}
{...props}
/>
)
}
// =============================================================================
// Grid Lines Background (替代選項)
// =============================================================================
export interface GridLinesBgProps extends HTMLAttributes<HTMLDivElement> {
/** 網格大小 (px) */
gridSize?: number
/** 線條顏色 */
lineColor?: string
/** 背景顏色 */
bgColor?: string
/** 是否固定 */
fixed?: boolean
}
export const GridLinesBg = forwardRef<HTMLDivElement, GridLinesBgProps>(
(
{
gridSize = 40,
lineColor = 'rgba(0, 0, 0, 0.04)',
bgColor = '#FAFAFA',
fixed = true,
className,
style,
...props
},
ref
) => {
const gridPattern = `
linear-gradient(to right, ${lineColor} 1px, transparent 1px),
linear-gradient(to bottom, ${lineColor} 1px, transparent 1px)
`
return (
<div
ref={ref}
aria-hidden="true"
className={cn(
fixed ? 'fixed' : 'absolute',
'inset-0 -z-10 pointer-events-none',
className
)}
style={{
backgroundColor: bgColor,
backgroundImage: gridPattern,
backgroundSize: `${gridSize}px ${gridSize}px`,
...style,
}}
{...props}
/>
)
}
)
GridLinesBg.displayName = 'GridLinesBg'

View File

@@ -0,0 +1,224 @@
'use client'
/**
* GlassCard - 純白透光玻璃卡片
* ============================
* Nothing.tech 明亮工業風設計
*
* Features:
* - backdrop-blur 毛玻璃效果
* - 極淡灰色邊框 (border-black/5)
* - Hover 浮動效果
* - 支援多種尺寸與變體
*
* WCAG 2.1 AA: 背景對比度 4.5:1+
*/
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
/** 子元素 */
children: ReactNode
/** 變體樣式 */
variant?: 'default' | 'elevated' | 'copilot' | 'subtle'
/** 內距尺寸 */
padding?: 'none' | 'sm' | 'md' | 'lg'
/** 是否啟用 hover 效果 */
hoverable?: boolean
/** 是否可點擊 (啟用 cursor-pointer) */
clickable?: boolean
/** 無障礙標籤 */
'aria-label'?: string
}
// =============================================================================
// Styles
// =============================================================================
const variantStyles = {
default: [
// 白色毛玻璃
'bg-white/70',
'backdrop-blur-[16px]',
'-webkit-backdrop-blur-[16px]',
// 極淡邊框
'border border-black/[0.05]',
// 陰影
'shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
// 圓角
'rounded-xl',
],
elevated: [
'bg-white/80',
'backdrop-blur-[20px]',
'-webkit-backdrop-blur-[20px]',
'border border-black/[0.08]',
'shadow-[0_8px_32px_rgba(0,0,0,0.10)]',
'rounded-xl',
],
copilot: [
// AI Copilot 面板 - 帶紫色調
'bg-nothing-cream/90',
'backdrop-blur-[20px]',
'-webkit-backdrop-blur-[20px]',
'border border-status-thinking/20',
'shadow-[0_4px_24px_rgba(139,92,246,0.1)]',
'rounded-xl',
],
subtle: [
'bg-white/50',
'backdrop-blur-[12px]',
'-webkit-backdrop-blur-[12px]',
'border border-black/[0.03]',
'shadow-[0_2px_12px_rgba(0,0,0,0.04)]',
'rounded-lg',
],
}
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-5',
lg: 'p-6',
}
const hoverStyles = [
'transition-all duration-300 ease-out',
'hover:bg-white/80',
'hover:shadow-[0_8px_32px_rgba(0,0,0,0.10)]',
'hover:border-black/[0.08]',
'hover:-translate-y-0.5',
]
// =============================================================================
// Component
// =============================================================================
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
(
{
children,
variant = 'default',
padding = 'md',
hoverable = false,
clickable = false,
className,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
// Base styles
variantStyles[variant],
paddingStyles[padding],
// Hover effect
hoverable && hoverStyles,
// Clickable
clickable && 'cursor-pointer',
// Custom classes
className
)}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
{...props}
>
{children}
</div>
)
}
)
GlassCard.displayName = 'GlassCard'
// =============================================================================
// Sub-components
// =============================================================================
export interface GlassCardHeaderProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
}
export function GlassCardHeader({
children,
className,
...props
}: GlassCardHeaderProps) {
return (
<div
className={cn('flex items-center justify-between mb-4', className)}
{...props}
>
{children}
</div>
)
}
export interface GlassCardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
children: ReactNode
as?: 'h1' | 'h2' | 'h3' | 'h4'
}
export function GlassCardTitle({
children,
as: Tag = 'h3',
className,
...props
}: GlassCardTitleProps) {
return (
<Tag
className={cn(
'font-heading text-lg font-semibold text-nothing-gray-900',
className
)}
{...props}
>
{children}
</Tag>
)
}
export interface GlassCardContentProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
}
export function GlassCardContent({
children,
className,
...props
}: GlassCardContentProps) {
return (
<div className={cn('flex-1', className)} {...props}>
{children}
</div>
)
}
export interface GlassCardFooterProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
}
export function GlassCardFooter({
children,
className,
...props
}: GlassCardFooterProps) {
return (
<div
className={cn(
'mt-4 pt-3 border-t border-nothing-gray-200',
className
)}
{...props}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,36 @@
/**
* AWOOOI UI Component Library
* ===========================
* Nothing.tech 明亮工業風原子組件
*
* CPO-101: 原子組件庫
*/
// Glass Card
export {
GlassCard,
GlassCardHeader,
GlassCardTitle,
GlassCardContent,
GlassCardFooter,
type GlassCardProps,
} from './glass-card'
// Status Orb
export {
StatusOrb,
StatusOrbWithRing,
StatusBadge,
type StatusOrbProps,
type StatusType,
} from './status-orb'
// Dot Matrix Background
export {
DotMatrixBg,
DotMatrixLight,
DotMatrixSubtle,
DotMatrixDense,
GridLinesBg,
type DotMatrixBgProps,
} from './dot-matrix-bg'

View File

@@ -0,0 +1,253 @@
'use client'
/**
* StatusOrb - 狀態呼吸燈
* ======================
* Nothing.tech 明亮工業風設計
*
* Features:
* - 10px 預設尺寸 (可調整)
* - 4 種狀態: healthy (綠), warning (橘), critical (紅), thinking (紫)
* - 平滑 pulse 動畫
* - WCAG 2.1 AA 色彩對比度
*
* Accessibility:
* - 使用 aria-label 描述狀態
* - 色彩 + 動畫雙重指示 (不僅依賴顏色)
*/
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export type StatusType = 'healthy' | 'warning' | 'critical' | 'thinking' | 'idle' | 'syncing'
export interface StatusOrbProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
/** 狀態類型 */
status: StatusType
/** 尺寸 (px) */
size?: 'xs' | 'sm' | 'md' | 'lg'
/** 是否顯示脈衝動畫 */
pulse?: boolean
/** 是否顯示外發光 */
glow?: boolean
/** 無障礙標籤 (i18n) */
'aria-label'?: string
}
// =============================================================================
// Status Configuration
// =============================================================================
interface StatusConfig {
/** 背景色 */
bgColor: string
/** 發光色 */
glowColor: string
/** 是否預設動畫 */
defaultPulse: boolean
/** 無障礙標籤 (預設) */
label: string
}
const statusConfig: Record<StatusType, StatusConfig> = {
healthy: {
bgColor: 'bg-status-healthy', // #22C55E
glowColor: 'shadow-glow-green',
defaultPulse: false,
label: 'Healthy',
},
warning: {
bgColor: 'bg-status-warning', // #F59E0B
glowColor: 'shadow-[0_0_12px_rgba(245,158,11,0.5)]',
defaultPulse: true,
label: 'Warning',
},
critical: {
bgColor: 'bg-status-critical', // #FF3300
glowColor: 'shadow-glow-red',
defaultPulse: true,
label: 'Critical',
},
thinking: {
bgColor: 'bg-status-thinking', // #8B5CF6
glowColor: 'shadow-glow-purple',
defaultPulse: true,
label: 'Thinking',
},
idle: {
bgColor: 'bg-status-idle', // #A3A3A3
glowColor: '',
defaultPulse: false,
label: 'Idle',
},
syncing: {
bgColor: 'bg-status-syncing', // #3B82F6
glowColor: 'shadow-glow-blue',
defaultPulse: true,
label: 'Syncing',
},
}
// =============================================================================
// Size Configuration
// =============================================================================
const sizeConfig = {
xs: 'w-2 h-2', // 8px
sm: 'w-2.5 h-2.5', // 10px (default per spec)
md: 'w-3 h-3', // 12px
lg: 'w-4 h-4', // 16px
}
// =============================================================================
// Component
// =============================================================================
export const StatusOrb = forwardRef<HTMLSpanElement, StatusOrbProps>(
(
{
status,
size = 'sm',
pulse,
glow = false,
className,
'aria-label': ariaLabel,
...props
},
ref
) => {
const config = statusConfig[status]
const shouldPulse = pulse ?? config.defaultPulse
const label = ariaLabel || config.label
return (
<span
ref={ref}
role="status"
aria-label={label}
className={cn(
// Base
'inline-block rounded-full',
// Size
sizeConfig[size],
// Color
config.bgColor,
// Glow effect
glow && config.glowColor,
// Pulse animation
shouldPulse && 'animate-pulse',
// Transition
'transition-all duration-300',
// Custom
className
)}
{...props}
/>
)
}
)
StatusOrb.displayName = 'StatusOrb'
// =============================================================================
// Variant with Ring (更明顯的指示器)
// =============================================================================
export interface StatusOrbWithRingProps extends StatusOrbProps {
/** 是否顯示外環 */
showRing?: boolean
}
export const StatusOrbWithRing = forwardRef<HTMLSpanElement, StatusOrbWithRingProps>(
({ showRing = true, size = 'sm', status, className, ...props }, ref) => {
const config = statusConfig[status]
const shouldPulse = props.pulse ?? config.defaultPulse
// Ring sizes
const ringSizeConfig = {
xs: 'w-4 h-4',
sm: 'w-5 h-5',
md: 'w-6 h-6',
lg: 'w-8 h-8',
}
if (!showRing) {
return <StatusOrb ref={ref} status={status} size={size} className={className} {...props} />
}
return (
<span
ref={ref}
className={cn(
'relative inline-flex items-center justify-center',
ringSizeConfig[size],
className
)}
>
{/* Outer ring (animated) */}
{shouldPulse && (
<span
className={cn(
'absolute inset-0 rounded-full opacity-30 animate-ping',
config.bgColor
)}
/>
)}
{/* Inner orb */}
<StatusOrb status={status} size={size} pulse={false} {...props} />
</span>
)
}
)
StatusOrbWithRing.displayName = 'StatusOrbWithRing'
// =============================================================================
// Status Badge (狀態 + 文字標籤)
// =============================================================================
export interface StatusBadgeProps extends StatusOrbProps {
/** 顯示文字 (i18n) */
label?: string
/** 標籤位置 */
labelPosition?: 'right' | 'left'
}
export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
(
{
status,
label,
labelPosition = 'right',
size = 'sm',
className,
...props
},
ref
) => {
const config = statusConfig[status]
const displayLabel = label || config.label
return (
<span
ref={ref}
className={cn(
'inline-flex items-center gap-2',
labelPosition === 'left' && 'flex-row-reverse',
className
)}
>
<StatusOrb status={status} size={size} {...props} />
<span className="text-xs font-mono text-nothing-gray-600">
{displayLabel}
</span>
</span>
)
}
)
StatusBadge.displayName = 'StatusBadge'

View File

@@ -0,0 +1,191 @@
'use client'
/**
* AWOOOI Toast - 全局通知提示
* ============================
* Nothing.tech 設計語言
*
* 用法:
* import { toast, ToastContainer } from '@/components/ui/toast'
* toast.success('操作成功')
* toast.error('操作失敗')
* toast.info('K8s 指令發送中...')
*/
import { useState, useEffect, useCallback, createContext, useContext } from 'react'
import { cn } from '@/lib/utils'
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
type ToastType = 'success' | 'error' | 'info' | 'loading'
interface ToastItem {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastContextType {
toasts: ToastItem[]
addToast: (type: ToastType, message: string, duration?: number) => string
removeToast: (id: string) => void
}
// =============================================================================
// Context
// =============================================================================
const ToastContext = createContext<ToastContextType | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const addToast = useCallback((type: ToastType, message: string, duration = 4000) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
setToasts((prev) => [...prev, { id, type, message, duration }])
if (duration > 0 && type !== 'loading') {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, duration)
}
return id
}, [])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer />
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
// =============================================================================
// Toast Container
// =============================================================================
function ToastContainer() {
const context = useContext(ToastContext)
if (!context) return null
const { toasts, removeToast } = context
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
</div>
)
}
// =============================================================================
// Toast Item
// =============================================================================
const TOAST_CONFIG: Record<ToastType, { icon: typeof CheckCircle2; bg: string; border: string; text: string }> = {
success: {
icon: CheckCircle2,
bg: 'bg-status-healthy/10',
border: 'border-status-healthy/30',
text: 'text-status-healthy',
},
error: {
icon: XCircle,
bg: 'bg-status-critical/10',
border: 'border-status-critical/30',
text: 'text-status-critical',
},
info: {
icon: AlertCircle,
bg: 'bg-status-thinking/10',
border: 'border-status-thinking/30',
text: 'text-status-thinking',
},
loading: {
icon: Loader2,
bg: 'bg-nothing-gray-100',
border: 'border-nothing-gray-300',
text: 'text-nothing-gray-700',
},
}
function ToastItem({ toast, onClose }: { toast: ToastItem; onClose: () => void }) {
const config = TOAST_CONFIG[toast.type]
const Icon = config.icon
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md',
'animate-slide-in-up min-w-[280px] max-w-[400px]',
config.bg,
config.border
)}
onClick={onClose}
>
<Icon
className={cn(
'w-5 h-5 flex-shrink-0',
config.text,
toast.type === 'loading' && 'animate-spin'
)}
/>
<p className={cn('font-mono text-sm', config.text)}>{toast.message}</p>
</div>
)
}
// =============================================================================
// Global Toast API (for use outside React components)
// =============================================================================
let globalAddToast: ((type: ToastType, message: string, duration?: number) => string) | null = null
let globalRemoveToast: ((id: string) => void) | null = null
export function setGlobalToast(
addFn: (type: ToastType, message: string, duration?: number) => string,
removeFn: (id: string) => void
) {
globalAddToast = addFn
globalRemoveToast = removeFn
}
export const toast = {
success: (message: string, duration?: number) => globalAddToast?.('success', message, duration),
error: (message: string, duration?: number) => globalAddToast?.('error', message, duration),
info: (message: string, duration?: number) => globalAddToast?.('info', message, duration),
loading: (message: string) => globalAddToast?.('loading', message, 0),
dismiss: (id: string) => globalRemoveToast?.(id),
}
// =============================================================================
// Toast Initializer (放在 layout 中)
// =============================================================================
export function ToastInitializer() {
const { addToast, removeToast } = useToast()
useEffect(() => {
setGlobalToast(addToast, removeToast)
}, [addToast, removeToast])
return null
}