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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user