Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
## 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>
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
'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>
|
||
)
|
||
}
|