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:
356
apps/web/src/components/agent/approval-card.tsx
Normal file
356
apps/web/src/components/agent/approval-card.tsx
Normal 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 }
|
||||
237
apps/web/src/components/agent/data-pincer.tsx
Normal file
237
apps/web/src/components/agent/data-pincer.tsx
Normal 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 ====================
|
||||
|
||||
// 狀態樣式配置 (不含 label,label 由 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>
|
||||
)
|
||||
}
|
||||
3
apps/web/src/components/agent/index.ts
Normal file
3
apps/web/src/components/agent/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './data-pincer'
|
||||
export * from './thinking-terminal'
|
||||
export * from './approval-card'
|
||||
390
apps/web/src/components/agent/thinking-terminal.tsx
Normal file
390
apps/web/src/components/agent/thinking-terminal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
apps/web/src/components/ai/ai-command-panel.tsx
Normal file
123
apps/web/src/components/ai/ai-command-panel.tsx
Normal 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
|
||||
248
apps/web/src/components/ai/ai-thinking-panel.tsx
Normal file
248
apps/web/src/components/ai/ai-thinking-panel.tsx
Normal 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
|
||||
439
apps/web/src/components/ai/clawbot-panel.tsx
Normal file
439
apps/web/src/components/ai/clawbot-panel.tsx
Normal 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
|
||||
558
apps/web/src/components/ai/clawbot-state-machine.tsx
Normal file
558
apps/web/src/components/ai/clawbot-state-machine.tsx
Normal 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
|
||||
498
apps/web/src/components/ai/hitl-section.tsx
Normal file
498
apps/web/src/components/ai/hitl-section.tsx
Normal 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
|
||||
21
apps/web/src/components/ai/index.ts
Normal file
21
apps/web/src/components/ai/index.ts
Normal 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
|
||||
// =============================================================================
|
||||
439
apps/web/src/components/ai/openclaw-panel.tsx
Normal file
439
apps/web/src/components/ai/openclaw-panel.tsx
Normal 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
|
||||
354
apps/web/src/components/ai/openclaw-state-machine.tsx
Normal file
354
apps/web/src/components/ai/openclaw-state-machine.tsx
Normal 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
|
||||
282
apps/web/src/components/ai/thinking-stream.tsx
Normal file
282
apps/web/src/components/ai/thinking-stream.tsx
Normal 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
|
||||
675
apps/web/src/components/approval/approval-card.tsx
Normal file
675
apps/web/src/components/approval/approval-card.tsx
Normal 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 }
|
||||
90
apps/web/src/components/approval/index.ts
Normal file
90
apps/web/src/components/approval/index.ts
Normal 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',
|
||||
}
|
||||
406
apps/web/src/components/approval/live-approval-panel.tsx
Normal file
406
apps/web/src/components/approval/live-approval-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
apps/web/src/components/charts/ai-process-stepper.tsx
Normal file
273
apps/web/src/components/charts/ai-process-stepper.tsx
Normal 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} />
|
||||
}
|
||||
223
apps/web/src/components/charts/global-pulse-chart.tsx
Normal file
223
apps/web/src/components/charts/global-pulse-chart.tsx
Normal 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} />
|
||||
}
|
||||
9
apps/web/src/components/charts/index.ts
Normal file
9
apps/web/src/components/charts/index.ts
Normal 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'
|
||||
195
apps/web/src/components/charts/time-series-chart.tsx
Normal file
195
apps/web/src/components/charts/time-series-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
apps/web/src/components/cyber/data-pincer-card.tsx
Normal file
148
apps/web/src/components/cyber/data-pincer-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
apps/web/src/components/cyber/index.ts
Normal file
14
apps/web/src/components/cyber/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* AWOOOI Cyber Components
|
||||
* =======================
|
||||
* Nothing.tech 純白工業風視覺組件庫
|
||||
*
|
||||
* 命名規範: Data Pincer (數據鉗) 系列
|
||||
*/
|
||||
|
||||
export {
|
||||
DataPincerCard,
|
||||
DataPincerPanel,
|
||||
DataPincerCompact,
|
||||
type CardStatus,
|
||||
} from './data-pincer-card'
|
||||
135
apps/web/src/components/dashboard/connection-status.tsx
Normal file
135
apps/web/src/components/dashboard/connection-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
253
apps/web/src/components/dashboard/host-card.tsx
Normal file
253
apps/web/src/components/dashboard/host-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
apps/web/src/components/dashboard/index.ts
Normal file
9
apps/web/src/components/dashboard/index.ts
Normal 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'
|
||||
422
apps/web/src/components/dashboard/live-dashboard.tsx
Normal file
422
apps/web/src/components/dashboard/live-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
411
apps/web/src/components/dashboard/live-host-card.tsx
Normal file
411
apps/web/src/components/dashboard/live-host-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
apps/web/src/components/incident/incident-card.tsx
Normal file
273
apps/web/src/components/incident/incident-card.tsx
Normal 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`
|
||||
}
|
||||
12
apps/web/src/components/incident/index.ts
Normal file
12
apps/web/src/components/incident/index.ts
Normal 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'
|
||||
391
apps/web/src/components/incident/thinking-terminal.tsx
Normal file
391
apps/web/src/components/incident/thinking-terminal.tsx
Normal 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 }
|
||||
121
apps/web/src/components/layout/app-layout.tsx
Normal file
121
apps/web/src/components/layout/app-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
apps/web/src/components/layout/header.tsx
Normal file
148
apps/web/src/components/layout/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
apps/web/src/components/layout/index.ts
Normal file
9
apps/web/src/components/layout/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Layout Components
|
||||
* =================
|
||||
* CPO-102: Global Layout 與側邊欄
|
||||
*/
|
||||
|
||||
export { Sidebar } from './sidebar'
|
||||
export { Header } from './header'
|
||||
export { AppLayout } from './app-layout'
|
||||
239
apps/web/src/components/layout/sidebar.tsx
Normal file
239
apps/web/src/components/layout/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
apps/web/src/components/shared/language-switcher.tsx
Normal file
98
apps/web/src/components/shared/language-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
apps/web/src/components/status-orb.tsx
Normal file
78
apps/web/src/components/status-orb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
188
apps/web/src/components/thinking-stream-test.tsx
Normal file
188
apps/web/src/components/thinking-stream-test.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
245
apps/web/src/components/timeline/action-timeline.tsx
Normal file
245
apps/web/src/components/timeline/action-timeline.tsx
Normal 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
|
||||
7
apps/web/src/components/timeline/index.ts
Normal file
7
apps/web/src/components/timeline/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Timeline Components
|
||||
* ===================
|
||||
* Phase 4: Action Log Timeline
|
||||
*/
|
||||
|
||||
export { ActionTimeline } from './action-timeline'
|
||||
118
apps/web/src/components/ui/border-beam.tsx
Normal file
118
apps/web/src/components/ui/border-beam.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
204
apps/web/src/components/ui/dot-matrix-bg.tsx
Normal file
204
apps/web/src/components/ui/dot-matrix-bg.tsx
Normal 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'
|
||||
224
apps/web/src/components/ui/glass-card.tsx
Normal file
224
apps/web/src/components/ui/glass-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
apps/web/src/components/ui/index.ts
Normal file
36
apps/web/src/components/ui/index.ts
Normal 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'
|
||||
253
apps/web/src/components/ui/status-orb.tsx
Normal file
253
apps/web/src/components/ui/status-orb.tsx
Normal 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'
|
||||
191
apps/web/src/components/ui/toast.tsx
Normal file
191
apps/web/src/components/ui/toast.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user