feat: add all application source code

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

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

View File

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

View File

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

View File

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

View File

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

View File

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