feat: add Node Directory with multi-node support (TASK 3)

This commit is contained in:
Apple
2025-11-28 05:08:55 -08:00
parent 319c7e4799
commit 7b9590da01
7 changed files with 596 additions and 95 deletions

View File

@@ -0,0 +1,269 @@
'use client';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Server, ArrowLeft, Cpu, Users, Activity, ExternalLink } from 'lucide-react';
import { useNodeProfile } from '@/hooks/useNodes';
import { useNodeDashboard } from '@/hooks/useNodeDashboard';
import {
NodeSummaryCard,
InfraCard,
AIServicesCard,
AgentsCard,
MatrixCard,
ModulesCard,
NodeStandardComplianceCard
} from '@/components/node-dashboard';
function getNodeLabel(nodeId: string): string {
if (nodeId.includes('node-1')) return 'НОДА1';
if (nodeId.includes('node-2')) return 'НОДА2';
return 'НОДА';
}
export default function NodeCabinetPage() {
const params = useParams();
const nodeId = params.nodeId as string;
const nodeLabel = getNodeLabel(nodeId);
// Basic node profile from node_cache
const { node: nodeProfile, isLoading: profileLoading, error: profileError } = useNodeProfile(nodeId);
// Full dashboard (if available - currently only for NODE1)
const { dashboard, isLoading: dashboardLoading, error: dashboardError, refresh, lastUpdated } = useNodeDashboard({
refreshInterval: 30000,
enabled: nodeId === 'node-1-hetzner-gex44' // Only enable for NODE1
});
const isLoading = profileLoading || dashboardLoading;
const error = profileError || (dashboardError && nodeId === 'node-1-hetzner-gex44');
const isProduction = nodeProfile?.environment === 'production';
if (isLoading && !nodeProfile && !dashboard) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-white/70">Loading node cabinet...</p>
</div>
</div>
</div>
</div>
);
}
if (error || (!nodeProfile && !dashboard)) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
<div className="max-w-7xl mx-auto">
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
<p className="text-red-400 text-lg mb-2">Failed to load node</p>
<p className="text-white/50 mb-4">{(error as Error)?.message || 'Node not found'}</p>
<Link
href="/nodes"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors inline-block"
>
Back to Nodes
</Link>
</div>
</div>
</div>
);
}
// If we have full dashboard (NODE1), show it
if (dashboard) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/nodes"
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-white" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">{nodeLabel}</h1>
<p className="text-white/50 text-sm">{dashboard.node.name}</p>
</div>
</div>
<div className="flex items-center gap-4">
{lastUpdated && (
<p className="text-white/30 text-sm">
Updated: {lastUpdated.toLocaleTimeString()}
</p>
)}
<button
onClick={refresh}
disabled={dashboardLoading}
className="px-4 py-2 bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 rounded-lg transition-colors disabled:opacity-50"
>
{dashboardLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<NodeSummaryCard node={dashboard.node} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InfraCard infra={dashboard.infra} />
<AgentsCard agents={dashboard.agents} />
</div>
<AIServicesCard ai={dashboard.ai} />
</div>
<div className="space-y-6">
<NodeStandardComplianceCard node={dashboard.node} />
<MatrixCard matrix={dashboard.matrix} />
<ModulesCard modules={dashboard.node.modules} />
</div>
</div>
{/* Link to agents */}
<div className="mt-8">
<Link
href={`/agents?node_id=${nodeId}`}
className="flex items-center gap-2 px-4 py-2 bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/30 rounded-lg text-violet-400 transition-colors w-fit"
>
<Users className="w-4 h-4" />
Агенти цієї ноди
<ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
</div>
);
}
// Basic profile view (for NODE2 and others)
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/nodes"
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-white" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">{nodeLabel}</h1>
<p className="text-white/50 text-sm">{nodeProfile?.name}</p>
</div>
</div>
{/* Node Card */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-8 mb-6">
<div className="flex items-start gap-6 mb-6">
<div className={`w-16 h-16 rounded-xl flex items-center justify-center ${
isProduction ? 'bg-emerald-500/20' : 'bg-amber-500/20'
}`}>
<Server className={`w-8 h-8 ${
isProduction ? 'text-emerald-400' : 'text-amber-400'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-white">{nodeProfile?.name}</h2>
<span className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs ${
nodeProfile?.status === 'online'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-red-500/20 text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${
nodeProfile?.status === 'online' ? 'bg-emerald-500' : 'bg-red-500'
}`} />
{nodeProfile?.status}
</span>
</div>
<p className="text-white/50 font-mono">{nodeProfile?.hostname}</p>
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase text-white/40 mb-1">Environment</p>
<p className={`text-lg ${
isProduction ? 'text-emerald-400' : 'text-amber-400'
}`}>
{nodeProfile?.environment}
</p>
</div>
<div>
<p className="text-xs uppercase text-white/40 mb-1">Node ID</p>
<p className="text-white font-mono text-sm">{nodeProfile?.node_id}</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs uppercase text-white/40 mb-1">Agents</p>
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-cyan-400" />
<span className="text-2xl font-bold text-white">{nodeProfile?.agents_total}</span>
<span className="text-white/40">total</span>
<span className="text-emerald-400 ml-2">{nodeProfile?.agents_online} online</span>
</div>
</div>
{nodeProfile?.last_heartbeat && (
<div>
<p className="text-xs uppercase text-white/40 mb-1">Last Heartbeat</p>
<p className="text-white/60 text-sm">
{new Date(nodeProfile.last_heartbeat).toLocaleString()}
</p>
</div>
)}
</div>
</div>
{/* Roles */}
{nodeProfile?.roles && nodeProfile.roles.length > 0 && (
<div className="mt-6 pt-6 border-t border-white/10">
<p className="text-xs uppercase text-white/40 mb-3">Roles</p>
<div className="flex flex-wrap gap-2">
{nodeProfile.roles.map((role) => (
<span
key={role}
className="px-3 py-1 bg-purple-500/10 text-purple-300 rounded-lg text-sm"
>
{role}
</span>
))}
</div>
</div>
)}
</div>
{/* Notice for non-NODE1 */}
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 mb-6">
<p className="text-amber-400 text-sm">
Детальний моніторинг доступний тільки для НОДА1 (Production).
Для цієї ноди показано базову інформацію з node_cache.
</p>
</div>
{/* Link to agents */}
<Link
href={`/agents?node_id=${nodeId}`}
className="flex items-center gap-2 px-4 py-2 bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/30 rounded-lg text-violet-400 transition-colors w-fit"
>
<Users className="w-4 h-4" />
Агенти цієї ноди
<ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
);
}