Files
awoooi/apps/web/src/components/dashboard/host-card.tsx
OG T 196d269b92 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>
2026-03-22 18:57:44 +08:00

254 lines
7.8 KiB
TypeScript

'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>
)
}