- 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>
254 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|