Files
awoooi/apps/web/src/components/dashboard/live-dashboard.tsx
Your Name d0591c54b0
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
fix(security): 體健修復 — 7項 Critical/Major 安全問題全修
## Critical 修復 (C1-C5)
- C1: git rm --cached 03-secrets.yaml(CHANGE_ME 模板不再追蹤)
- C2: git rm --cached awoooi.db + .gitignore 加 *.db(SQLite HARD_RULES 違規)
- C3: sentry-tunnel SENTRY_HOST 改為 process.env fallback
- C4: config.py DATABASE_URL 移除 changeme default,改為必填
- C5: run_migration.py 改為 os.environ["DATABASE_URL"]

## Major 修復 (M1-M4)
- M1: auto_repair /execute 加 CSRF 保護 + AutoRepairPanel.tsx 同步
- M2: drift /rollback /adopt 加 CSRF 保護(/internal/scan 保持無 CSRF)
- M3: terminal /intent 加 CSRF 保護 + terminal.store.ts 同步
- M4: live-dashboard HOST_IPS + host-grid VIP 改為 env var

## 其他
- 新增 apps/web/.env.example(6 個 env var 說明)
- K8s deployment-web 補入 3 個新 env var
- 整合測試:新增 aider_event_repository + ai_router_feedback 真實 DB 測試
- test_terminal.py CSRF dependency override 修復

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:27:39 +08:00

420 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* LiveDashboard - SSE 即時戰情室
* ==============================
* Phase 2.5: Magic UI 視覺升級
* i18n: 100% 使用 useTranslations禁止任何寫死字串
*/
import React from 'react'
import { useTranslations } from 'next-intl'
import { 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 = (process.env.NEXT_PUBLIC_HOST_IPS ?? '192.168.0.110,192.168.0.112,192.168.0.120,192.168.0.188').split(',')
// =============================================================================
// Component
// =============================================================================
interface LiveDashboardProps {
locale: string
}
export function LiveDashboard({ locale: _locale }: LiveDashboardProps) {
const t = useTranslations('dashboard')
const tBrand = useTranslations('brand')
const tHost = useTranslations('host')
// Phase 19 修復: SSE 連接已移至 AppLayout 全局管理
// 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 },
],
},
}
// Phase 19 修復: SSE 連接已移至 AppLayout 全局管理
// useEffect(() => {
// const apiBaseUrl = getApiBaseUrl()
// connect(apiBaseUrl)
// return () => 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-body uppercase tracking-wider">
{t('monitoring')}
</span>
</div>
</div>
<span className="text-[10px] text-nothing-gray-400 font-body 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-body 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-body 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>
)
}