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,38 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || 'http://daarion-city-service:7001';
export async function GET(
_req: NextRequest,
{ params }: { params: { nodeId: string } }
) {
try {
const { nodeId } = params;
const response = await fetch(`${CITY_API_URL}/public/nodes/${nodeId}`, {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!response.ok) {
const text = await response.text();
console.error('Failed to fetch node:', response.status, text);
return NextResponse.json(
{ error: 'Failed to fetch node' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching node:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || 'http://daarion-city-service:7001';
export async function GET(_req: NextRequest) {
try {
const response = await fetch(`${CITY_API_URL}/public/nodes`, {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!response.ok) {
const text = await response.text();
console.error('Failed to fetch nodes:', response.status, text);
return NextResponse.json(
{ error: 'Failed to fetch nodes' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching nodes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

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>
);
}

View File

@@ -1,107 +1,202 @@
'use client';
import { useNodeDashboard } from '@/hooks/useNodeDashboard';
import {
NodeSummaryCard,
InfraCard,
AIServicesCard,
AgentsCard,
MatrixCard,
ModulesCard,
NodeStandardComplianceCard
} from '@/components/node-dashboard';
import Link from 'next/link';
import { Server, Cpu, Users, Activity, ExternalLink } from 'lucide-react';
import { useNodeList } from '@/hooks/useNodes';
import { NodeProfile } from '@/lib/types/nodes';
export default function NodeDashboardPage() {
const { dashboard, isLoading, error, refresh, lastUpdated } = useNodeDashboard({
refreshInterval: 30000 // 30 seconds
});
if (isLoading && !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-cyan-500 border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-white/70">Loading node dashboard...</p>
function getNodeLabel(nodeId: string): string {
if (nodeId.includes('node-1')) return 'НОДА1';
if (nodeId.includes('node-2')) return 'НОДА2';
return 'НОДА';
}
function NodeCard({ node }: { node: NodeProfile }) {
const isOnline = node.status === 'online';
const nodeLabel = getNodeLabel(node.node_id);
const isProduction = node.environment === 'production';
return (
<Link
href={`/nodes/${node.node_id}`}
className="group bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 hover:border-purple-500/50 transition-all hover:bg-white/10"
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isProduction ? 'bg-emerald-500/20' : 'bg-amber-500/20'
}`}>
<Server className={`w-6 h-6 ${
isProduction ? 'text-emerald-400' : 'text-amber-400'
}`} />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-purple-400 transition-colors">
{nodeLabel}
</h3>
<p className="text-sm text-white/50">{node.name}</p>
</div>
</div>
<span className={`flex items-center gap-1.5 text-xs ${
isOnline ? 'text-emerald-400' : 'text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${
isOnline ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'
}`} />
{isOnline ? 'online' : 'offline'}
</span>
</div>
{/* Info */}
<div className="space-y-3 mb-4">
{node.hostname && (
<div className="flex items-center gap-2 text-sm text-white/60">
<Activity className="w-4 h-4" />
<span className="font-mono">{node.hostname}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm text-white/60">
<Cpu className="w-4 h-4" />
<span>{node.environment}</span>
</div>
</div>
{/* Roles */}
{node.roles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{node.roles.map((role) => (
<span
key={role}
className="px-2 py-0.5 bg-purple-500/10 text-purple-300 rounded text-xs"
>
{role}
</span>
))}
</div>
)}
{/* Stats */}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-sm">
<Users className="w-4 h-4 text-cyan-400" />
<span className="text-white">{node.agents_total}</span>
<span className="text-white/40">агентів</span>
</div>
<div className="text-sm">
<span className="text-emerald-400">{node.agents_online}</span>
<span className="text-white/40"> online</span>
</div>
</div>
<span className="text-purple-400 text-sm group-hover:translate-x-1 transition-transform">
Open
</span>
</div>
</Link>
);
}
export default function NodesPage() {
const { nodes, total, isLoading, error } = useNodeList();
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-900 to-slate-950">
<div className="max-w-7xl mx-auto px-4 py-12">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Server className="w-8 h-8 text-purple-400" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Node Directory
</h1>
</div>
<p className="text-white/60 text-lg">
Всі ноди мережі DAARION
</p>
<p className="text-purple-400 mt-2">
Знайдено нод: {total}
</p>
</div>
{/* Legend */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-4 mb-8">
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-white/60">Production</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-white/60">Development</span>
</div>
</div>
</div>
</div>
);
}
if (error) {
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 dashboard</p>
<p className="text-white/50 mb-4">{error.message}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
>
Retry
</button>
{/* Content */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center mb-8">
<p className="text-red-400">Помилка завантаження нод</p>
</div>
</div>
</div>
);
}
if (!dashboard) return null;
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>
<h1 className="text-2xl font-bold text-white">Node Dashboard</h1>
<p className="text-white/50 text-sm">
Real-time monitoring and status
)}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className="bg-white/5 rounded-2xl border border-white/10 p-6 animate-pulse"
>
<div className="flex items-start gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-white/10" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-white/10 rounded w-1/3" />
<div className="h-4 bg-white/10 rounded w-2/3" />
</div>
</div>
<div className="h-4 bg-white/10 rounded w-full mb-2" />
<div className="h-4 bg-white/10 rounded w-1/2" />
</div>
))}
</div>
) : nodes.length === 0 ? (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-12 text-center">
<Server className="w-16 h-16 text-white/20 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">
Ноди не знайдені
</h2>
<p className="text-white/50">
Наразі немає зареєстрованих нод.
</p>
</div>
<div className="flex items-center gap-4">
{lastUpdated && (
<p className="text-white/30 text-sm">
Updated: {lastUpdated.toLocaleTimeString()}
</p>
)}
<button
onClick={refresh}
disabled={isLoading}
className="px-4 py-2 bg-cyan-500/20 hover:bg-cyan-500/30 text-cyan-400 rounded-lg transition-colors disabled:opacity-50"
>
{isLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Node Summary */}
<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>
{/* Right Column */}
<div className="space-y-6">
<NodeStandardComplianceCard node={dashboard.node} />
<MatrixCard matrix={dashboard.matrix} />
<ModulesCard modules={dashboard.node.modules} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{nodes.map((node) => (
<NodeCard key={node.node_id} node={node} />
))}
</div>
)}
{/* Links */}
<div className="mt-12 flex flex-wrap gap-4">
<Link
href="/agents"
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"
>
<Users className="w-4 h-4" />
Agent Console
<ExternalLink className="w-3 h-3" />
</Link>
<Link
href="/citizens"
className="flex items-center gap-2 px-4 py-2 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-lg text-cyan-400 transition-colors"
>
<Users className="w-4 h-4" />
Публічні громадяни
<ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import useSWR from 'swr';
import { NodeProfile, NodeListResponse } from '@/lib/types/nodes';
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
throw new Error('Failed to fetch');
}
return res.json();
};
export function useNodeList() {
const { data, error, isLoading, mutate } = useSWR<NodeListResponse>(
'/api/nodes/list',
fetcher,
{
revalidateOnFocus: false,
}
);
return {
nodes: data?.items || [],
total: data?.total || 0,
isLoading,
error,
mutate,
};
}
export function useNodeProfile(nodeId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<NodeProfile>(
nodeId ? `/api/nodes/${nodeId}` : null,
fetcher,
{
revalidateOnFocus: false,
}
);
return {
node: data,
isLoading,
error,
mutate,
};
}

View File

@@ -1,4 +1,4 @@
import { HomeNodeInfo } from './citizens';
import { HomeNode } from './citizens';
export interface AgentMicrodaoMembership {
microdao_id: string;
@@ -18,7 +18,7 @@ export interface AgentSummary {
public_slug?: string | null;
public_title?: string | null;
district?: string | null;
home_node?: HomeNodeInfo | null;
home_node?: HomeNode | null;
microdao_memberships: AgentMicrodaoMembership[];
}
@@ -39,7 +39,7 @@ export interface AgentDashboard {
public_tagline?: string | null;
public_skills: string[];
district?: string | null;
home_node?: HomeNodeInfo | null;
home_node?: HomeNode | null;
microdao_memberships: AgentMicrodaoMembership[];
system_prompts?: {
core?: string;

View File

@@ -0,0 +1,18 @@
export interface NodeProfile {
node_id: string;
name: string;
hostname?: string | null;
roles: string[];
environment: string;
status: string;
gpu_info?: string | null;
agents_total: number;
agents_online: number;
last_heartbeat?: string | null;
}
export interface NodeListResponse {
items: NodeProfile[];
total: number;
}