feat: Node Self-Healing, DAGI Audit, Agent Prompts, Infra Invariants

### Backend (city-service)
- Node Registry + Self-Healing API (migration 039)
- Improved get_all_nodes() with robust fallback for node_registry/node_cache
- Agent Prompts Runtime API for DAGI Router integration
- DAGI Router Audit endpoints (phantom/stale detection)
- Node Agents API (Guardian/Steward)
- Node metrics extended (CPU/GPU/RAM/Disk)

### Frontend (apps/web)
- Node Directory with improved error handling
- Node Cabinet with metrics cards
- DAGI Router Card component
- Node Metrics Card component
- useDAGIAudit hook

### Scripts
- check-invariants.py - deploy verification
- node-bootstrap.sh - node self-registration
- node-guardian-loop.py - continuous self-healing
- dagi_agent_audit.py - DAGI audit utility

### Migrations
- 034: Agent prompts seed
- 035: Agent DAGI audit
- 036: Node metrics extended
- 037: Node agents complete
- 038: Agent prompts full coverage
- 039: Node registry self-healing

### Tests
- test_infra_smoke.py
- test_agent_prompts_runtime.py
- test_dagi_router_api.py

### Documentation
- DEPLOY_CHECKLIST_2024_11_30.md
- Multiple TASK_PHASE docs
This commit is contained in:
Apple
2025-11-30 13:52:01 -08:00
parent 0c7836af5a
commit bca81dc719
36 changed files with 10630 additions and 55 deletions

View File

@@ -3,29 +3,35 @@ 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) {
const url = `${CITY_API_URL}/public/nodes`;
console.log('[API/nodes/list] Fetching from:', url);
try {
const response = await fetch(`${CITY_API_URL}/public/nodes`, {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
console.log('[API/nodes/list] Response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('Failed to fetch nodes:', response.status, text);
console.error('[API/nodes/list] Failed:', response.status, text);
return NextResponse.json(
{ error: 'Failed to fetch nodes' },
{ error: 'Failed to fetch nodes', status: response.status, detail: text.substring(0, 500) },
{ status: response.status }
);
}
const data = await response.json();
console.log('[API/nodes/list] Success, nodes count:', data?.items?.length || data?.total || 0);
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching nodes:', error);
console.error('[API/nodes/list] Exception:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ error: 'Failed to connect to city service', detail: String(error) },
{ status: 500 }
);
}

View File

@@ -5,6 +5,7 @@ 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 { useNodeAgents } from '@/hooks/useDAGIAudit';
import {
NodeSummaryCard,
InfraCard,
@@ -12,7 +13,9 @@ import {
AgentsCard,
MatrixCard,
ModulesCard,
NodeStandardComplianceCard
NodeStandardComplianceCard,
DAGIRouterCard,
NodeMetricsCard
} from '@/components/node-dashboard';
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
@@ -33,6 +36,9 @@ export default function NodeCabinetPage() {
// Basic node profile from node_cache
const { node: nodeProfile, isLoading: profileLoading, error: profileError } = useNodeProfile(nodeId);
// Node agents (Guardian, Steward, etc.)
const { guardian, steward, agents: nodeAgents, total: nodeAgentsTotal } = useNodeAgents(nodeId);
// Full dashboard (if available - currently only for NODE1)
const { dashboard, isLoading: dashboardLoading, error: dashboardError, refresh, lastUpdated } = useNodeDashboard({
refreshInterval: 30000,
@@ -129,14 +135,20 @@ export default function NodeCabinetPage() {
<AgentsCard agents={dashboard.agents} />
</div>
{/* DAGI Router Audit */}
<DAGIRouterCard nodeId={nodeId} />
<AIServicesCard ai={dashboard.ai} />
</div>
<div className="space-y-6">
{/* Node Metrics */}
<NodeMetricsCard nodeId={nodeId} />
{/* Node Guardian & Steward Agents */}
<NodeGuardianCard
guardian={nodeProfile?.guardian_agent}
steward={nodeProfile?.steward_agent}
guardian={guardian ? { id: guardian.id, name: guardian.name, kind: guardian.kind, slug: guardian.slug } : nodeProfile?.guardian_agent}
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
/>
{/* MicroDAO Presence */}
@@ -293,11 +305,21 @@ export default function NodeCabinetPage() {
{/* Node Guardian & Steward Agents */}
<div className="mb-6">
<NodeGuardianCard
guardian={nodeProfile?.guardian_agent}
steward={nodeProfile?.steward_agent}
guardian={guardian ? { id: guardian.id, name: guardian.name, kind: guardian.kind, slug: guardian.slug } : nodeProfile?.guardian_agent}
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
/>
</div>
{/* Node Metrics (GPU/CPU/RAM/Disk) */}
<div className="mb-6">
<NodeMetricsCard nodeId={nodeId} />
</div>
{/* DAGI Router Audit */}
<div className="mb-6">
<DAGIRouterCard nodeId={nodeId} />
</div>
{/* MicroDAO Presence */}
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 mb-6">
@@ -323,14 +345,6 @@ export default function NodeCabinetPage() {
</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}`}

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { Server, Cpu, Users, Activity, ExternalLink } from 'lucide-react';
import { Server, Cpu, Users, Activity, ExternalLink, Zap, HardDrive, MemoryStick } from 'lucide-react';
import { useNodeList } from '@/hooks/useNodes';
import { NodeProfile } from '@/lib/types/nodes';
@@ -11,10 +11,32 @@ function getNodeLabel(nodeId: string): string {
return 'НОДА';
}
function formatMB(mb: number): string {
if (mb >= 1024 * 1024) return `${(mb / (1024 * 1024)).toFixed(0)}TB`;
if (mb >= 1024) return `${(mb / 1024).toFixed(0)}GB`;
return `${mb}MB`;
}
function MiniBar({ value, max, color = 'purple' }: { value: number; max: number; color?: string }) {
const percent = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const colors: Record<string, string> = {
purple: 'bg-purple-500',
emerald: 'bg-emerald-500',
cyan: 'bg-cyan-500',
amber: 'bg-amber-500'
};
return (
<div className="w-12 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div className={`h-full ${colors[color]} transition-all`} style={{ width: `${percent}%` }} />
</div>
);
}
function NodeCard({ node }: { node: NodeProfile }) {
const isOnline = node.status === 'online';
const nodeLabel = getNodeLabel(node.node_id);
const isProduction = node.environment === 'production';
const m = node.metrics;
return (
<Link
@@ -76,12 +98,62 @@ function NodeCard({ node }: { node: NodeProfile }) {
</div>
)}
{/* Metrics */}
{m && (m.gpu_model || m.ram_total > 0) && (
<div className="grid grid-cols-4 gap-2 mb-4 p-3 bg-slate-800/30 rounded-xl">
{/* GPU */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Zap className="w-3 h-3 text-purple-400" />
<span className="text-[10px] text-white/40">GPU</span>
</div>
<MiniBar value={m.gpu_vram_used} max={m.gpu_vram_total} color="purple" />
<div className="text-[10px] text-white/50 mt-1">
{m.gpu_vram_total > 0 ? formatMB(m.gpu_vram_total) : '—'}
</div>
</div>
{/* CPU */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Cpu className="w-3 h-3 text-cyan-400" />
<span className="text-[10px] text-white/40">CPU</span>
</div>
<MiniBar value={m.cpu_usage} max={100} color="cyan" />
<div className="text-[10px] text-white/50 mt-1">
{m.cpu_cores > 0 ? `${m.cpu_cores}c` : '—'}
</div>
</div>
{/* RAM */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<MemoryStick className="w-3 h-3 text-emerald-400" />
<span className="text-[10px] text-white/40">RAM</span>
</div>
<MiniBar value={m.ram_used} max={m.ram_total} color="emerald" />
<div className="text-[10px] text-white/50 mt-1">
{m.ram_total > 0 ? formatMB(m.ram_total) : '—'}
</div>
</div>
{/* Disk */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<HardDrive className="w-3 h-3 text-amber-400" />
<span className="text-[10px] text-white/40">Disk</span>
</div>
<MiniBar value={m.disk_used} max={m.disk_total} color="amber" />
<div className="text-[10px] text-white/50 mt-1">
{m.disk_total > 0 ? formatMB(m.disk_total) : '—'}
</div>
</div>
</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">{m?.agent_count_system || node.agents_total}</span>
<span className="text-white/40">агентів</span>
</div>
<div className="text-sm">

View File

@@ -0,0 +1,463 @@
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import {
Router,
CheckCircle,
Ghost,
Archive,
RefreshCw,
Bot,
AlertTriangle,
Clock,
ExternalLink,
Search,
Filter,
ChevronDown,
Download,
Upload,
Brain
} from 'lucide-react';
import {
useDAGIRouterAgents,
runDAGIAudit,
syncPhantomAgents,
markStaleAgents,
type DAGIRouterAgent
} from '@/hooks/useDAGIAudit';
interface DAGIRouterCardProps {
nodeId: string;
expanded?: boolean;
}
type StatusFilter = 'all' | 'active' | 'phantom' | 'stale';
const STATUS_CONFIG = {
active: {
label: 'Active',
color: 'emerald',
bgClass: 'bg-emerald-500/20',
textClass: 'text-emerald-400',
icon: CheckCircle
},
phantom: {
label: 'Phantom',
color: 'amber',
bgClass: 'bg-amber-500/20',
textClass: 'text-amber-400',
icon: Ghost
},
stale: {
label: 'Stale',
color: 'orange',
bgClass: 'bg-orange-500/20',
textClass: 'text-orange-400',
icon: Archive
},
error: {
label: 'Error',
color: 'red',
bgClass: 'bg-red-500/20',
textClass: 'text-red-400',
icon: AlertTriangle
}
};
export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps) {
const { agents, summary, lastAuditAt, isLoading, error, refresh } = useDAGIRouterAgents(nodeId);
const [isRunning, setIsRunning] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilterMenu, setShowFilterMenu] = useState(false);
// Filter agents
const filteredAgents = useMemo(() => {
return agents.filter(agent => {
// Status filter
if (statusFilter !== 'all' && agent.status !== statusFilter) {
return false;
}
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
agent.name.toLowerCase().includes(query) ||
agent.role?.toLowerCase().includes(query) ||
agent.id.toLowerCase().includes(query)
);
}
return true;
});
}, [agents, statusFilter, searchQuery]);
const handleRunAudit = async () => {
setIsRunning(true);
setRunError(null);
try {
await runDAGIAudit(nodeId);
await refresh();
} catch (e) {
setRunError(e instanceof Error ? e.message : 'Failed to run audit');
} finally {
setIsRunning(false);
}
};
const handleSyncPhantom = async () => {
const phantomIds = agents.filter(a => a.status === 'phantom').map(a => a.id);
if (phantomIds.length === 0) return;
setIsSyncing(true);
try {
await syncPhantomAgents(nodeId, phantomIds);
await runDAGIAudit(nodeId);
await refresh();
} catch (e) {
setRunError(e instanceof Error ? e.message : 'Failed to sync');
} finally {
setIsSyncing(false);
}
};
const handleMarkStale = async () => {
const staleIds = agents.filter(a => a.status === 'stale').map(a => a.id);
if (staleIds.length === 0) return;
setIsSyncing(true);
try {
await markStaleAgents(nodeId, staleIds);
await refresh();
} catch (e) {
setRunError(e instanceof Error ? e.message : 'Failed to mark stale');
} finally {
setIsSyncing(false);
}
};
// Format timestamp
const formatTime = (timestamp: string) => {
try {
const date = new Date(timestamp);
return date.toLocaleString('uk-UA', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return timestamp;
}
};
const getStatusBadge = (status: string) => {
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.error;
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.bgClass} ${config.textClass}`}>
<Icon className="w-3 h-3" />
{config.label}
</span>
);
};
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<Router className="w-5 h-5 text-purple-400" />
DAGI Router
</h2>
<div className="flex items-center gap-2">
{summary.phantom > 0 && (
<button
onClick={handleSyncPhantom}
disabled={isSyncing || isRunning}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-500/20 hover:bg-amber-500/30
text-amber-400 rounded-lg transition-colors text-xs disabled:opacity-50"
title="Синхронізувати phantom агентів"
>
<Download className="w-3.5 h-3.5" />
Sync {summary.phantom}
</button>
)}
<button
onClick={handleRunAudit}
disabled={isRunning || isLoading || isSyncing}
className="flex items-center gap-2 px-3 py-1.5 bg-purple-500/20 hover:bg-purple-500/30
text-purple-400 rounded-lg transition-colors text-sm disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isRunning ? 'animate-spin' : ''}`} />
{isRunning ? 'Аудит...' : 'Запустити'}
</button>
</div>
</div>
{/* Error */}
{(error || runError) && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
<AlertTriangle className="w-4 h-4 inline mr-2" />
{runError || error?.message || 'Помилка завантаження'}
</div>
)}
{/* Loading */}
{isLoading && agents.length === 0 && (
<div className="text-center py-8 text-white/40">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
Завантаження...
</div>
)}
{/* No data */}
{!isLoading && agents.length === 0 && !error && (
<div className="text-center py-8 text-white/40">
<Router className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>Ще немає даних аудиту</p>
<p className="text-sm mt-1">Натисніть &quot;Запустити&quot; для аудиту</p>
</div>
)}
{/* Content */}
{agents.length > 0 && (
<>
{/* Timestamp */}
{lastAuditAt && (
<div className="flex items-center gap-2 text-xs text-white/40 mb-4">
<Clock className="w-3 h-3" />
Останній аудит: {formatTime(lastAuditAt)}
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<button
onClick={() => setStatusFilter(statusFilter === 'active' ? 'all' : 'active')}
className={`rounded-lg p-3 text-center transition-all ${
statusFilter === 'active'
? 'bg-emerald-500/30 ring-2 ring-emerald-500/50'
: 'bg-emerald-500/10 hover:bg-emerald-500/20'
}`}
>
<div className="text-2xl font-bold text-emerald-400">
{summary.active}
</div>
<div className="text-xs text-white/50">Active</div>
</button>
<button
onClick={() => setStatusFilter(statusFilter === 'phantom' ? 'all' : 'phantom')}
className={`rounded-lg p-3 text-center transition-all ${
statusFilter === 'phantom'
? 'bg-amber-500/30 ring-2 ring-amber-500/50'
: summary.phantom > 0 ? 'bg-amber-500/10 hover:bg-amber-500/20' : 'bg-slate-800/50'
}`}
>
<div className={`text-2xl font-bold ${
summary.phantom > 0 ? 'text-amber-400' : 'text-white/40'
}`}>
{summary.phantom}
</div>
<div className="text-xs text-white/50">Phantom</div>
</button>
<button
onClick={() => setStatusFilter(statusFilter === 'stale' ? 'all' : 'stale')}
className={`rounded-lg p-3 text-center transition-all ${
statusFilter === 'stale'
? 'bg-orange-500/30 ring-2 ring-orange-500/50'
: summary.stale > 0 ? 'bg-orange-500/10 hover:bg-orange-500/20' : 'bg-slate-800/50'
}`}
>
<div className={`text-2xl font-bold ${
summary.stale > 0 ? 'text-orange-400' : 'text-white/40'
}`}>
{summary.stale}
</div>
<div className="text-xs text-white/50">Stale</div>
</button>
</div>
{/* Source counts */}
<div className="flex justify-between text-xs text-white/40 mb-4 px-1">
<span>Router: {summary.router_total} агентів</span>
<span>System: {summary.system_total} агентів</span>
</div>
{/* Search & Filter */}
<div className="flex gap-2 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
placeholder="Пошук агентів..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-800/50 border border-white/10 rounded-lg
text-sm text-white placeholder-white/30 focus:outline-none focus:ring-2
focus:ring-purple-500/50"
/>
</div>
<div className="relative">
<button
onClick={() => setShowFilterMenu(!showFilterMenu)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
statusFilter !== 'all'
? 'bg-purple-500/20 border-purple-500/50 text-purple-400'
: 'bg-slate-800/50 border-white/10 text-white/60 hover:bg-slate-800'
}`}
>
<Filter className="w-4 h-4" />
<ChevronDown className="w-3 h-3" />
</button>
{showFilterMenu && (
<div className="absolute right-0 top-full mt-1 bg-slate-800 border border-white/10
rounded-lg shadow-xl z-10 min-w-[120px] py-1">
{(['all', 'active', 'phantom', 'stale'] as const).map((status) => (
<button
key={status}
onClick={() => {
setStatusFilter(status);
setShowFilterMenu(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-white/5 ${
statusFilter === status ? 'text-purple-400' : 'text-white/70'
}`}
>
{status === 'all' ? 'All' : STATUS_CONFIG[status].label}
</button>
))}
</div>
)}
</div>
</div>
{/* Agents Table */}
<div className="overflow-hidden rounded-lg border border-white/10">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-800/50 text-left">
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider">
Agent
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider hidden md:table-cell">
Runtime
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider hidden lg:table-cell">
Last Seen
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider text-right">
Cabinet
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredAgents.map((agent) => (
<tr
key={agent.id}
className="hover:bg-white/5 transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
STATUS_CONFIG[agent.status as keyof typeof STATUS_CONFIG]?.bgClass || 'bg-slate-700'
}`}>
<Bot className={`w-4 h-4 ${
STATUS_CONFIG[agent.status as keyof typeof STATUS_CONFIG]?.textClass || 'text-white/50'
}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{agent.name}</span>
{agent.has_prompts && (
<span
className="text-purple-400"
title="System Prompts налаштовані"
>
<Brain className="w-3.5 h-3.5" />
</span>
)}
{!agent.has_prompts && agent.status === 'active' && (
<span
className="text-amber-400/60"
title="System Prompts відсутні"
>
<Brain className="w-3.5 h-3.5 opacity-50" />
</span>
)}
</div>
{agent.role && (
<div className="text-xs text-white/40">{agent.role}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{getStatusBadge(agent.status)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="text-sm text-white/60">
{agent.gpu && <div>{agent.gpu}</div>}
{agent.cpu && <div className="text-xs text-white/40">{agent.cpu}</div>}
</div>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-white/50">
{agent.last_seen_at ? formatTime(agent.last_seen_at) : '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
{agent.has_cabinet && agent.cabinet_slug ? (
<Link
href={`/agents/${agent.cabinet_slug}`}
className="inline-flex items-center gap-1 px-2.5 py-1.5 bg-purple-500/10
hover:bg-purple-500/20 text-purple-400 rounded-lg text-xs
transition-colors"
>
Open
<ExternalLink className="w-3 h-3" />
</Link>
) : (
<span className="text-xs text-white/30"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Empty state for filtered results */}
{filteredAgents.length === 0 && agents.length > 0 && (
<div className="text-center py-8 text-white/40">
<Search className="w-6 h-6 mx-auto mb-2 opacity-50" />
<p>Немає агентів за цим фільтром</p>
</div>
)}
</div>
{/* Footer stats */}
<div className="mt-4 flex items-center justify-between text-xs text-white/40">
<span>
Показано {filteredAgents.length} з {agents.length} агентів
</span>
{summary.phantom === 0 && summary.stale === 0 && (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle className="w-3 h-3" />
Всі агенти синхронізовані
</span>
)}
</div>
</>
)}
</div>
);
}
export default DAGIRouterCard;

View File

@@ -0,0 +1,247 @@
'use client';
import {
Cpu,
HardDrive,
MemoryStick,
Zap,
Users,
Clock,
RefreshCw
} from 'lucide-react';
import { useNodeMetrics } from '@/hooks/useDAGIAudit';
interface NodeMetricsCardProps {
nodeId: string;
}
function formatBytes(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(1)} TB`;
}
if (mb >= 1024) {
return `${(mb / 1024).toFixed(1)} GB`;
}
return `${mb} MB`;
}
function ProgressBar({
value,
max,
color = 'purple'
}: {
value: number;
max: number;
color?: string;
}) {
const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const colorClasses: Record<string, string> = {
purple: 'bg-purple-500',
emerald: 'bg-emerald-500',
cyan: 'bg-cyan-500',
amber: 'bg-amber-500'
};
return (
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full ${colorClasses[color] || colorClasses.purple} transition-all duration-300`}
style={{ width: `${percentage}%` }}
/>
</div>
);
}
export function NodeMetricsCard({ nodeId }: NodeMetricsCardProps) {
const { metrics, isLoading, error, refresh } = useNodeMetrics(nodeId);
const formatTime = (timestamp: string | null | undefined) => {
if (!timestamp) return '—';
try {
const date = new Date(timestamp);
return date.toLocaleString('uk-UA', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return timestamp;
}
};
if (isLoading && !metrics) {
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-white/40" />
</div>
</div>
);
}
if (error && !metrics) {
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<div className="text-center text-white/40">
<p>Не вдалося завантажити метрики</p>
</div>
</div>
);
}
if (!metrics) {
return null;
}
const gpuUsagePercent = metrics.gpu_memory_total > 0
? (metrics.gpu_memory_used / metrics.gpu_memory_total) * 100
: 0;
const ramUsagePercent = metrics.ram_total > 0
? (metrics.ram_used / metrics.ram_total) * 100
: 0;
const diskUsagePercent = metrics.disk_total > 0
? (metrics.disk_used / metrics.disk_total) * 100
: 0;
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-400" />
Node Metrics
</h2>
<button
onClick={() => refresh()}
className="p-2 hover:bg-white/5 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4 text-white/40" />
</button>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-4">
{/* GPU */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center">
<Zap className="w-4 h-4 text-purple-400" />
</div>
<div>
<div className="text-xs text-white/40">GPU</div>
<div className="text-sm font-medium text-white truncate max-w-[120px]" title={metrics.gpu_model || 'Unknown'}>
{metrics.gpu_model || 'Unknown'}
</div>
</div>
</div>
<ProgressBar
value={metrics.gpu_memory_used}
max={metrics.gpu_memory_total}
color="purple"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{formatBytes(metrics.gpu_memory_used)}</span>
<span>{formatBytes(metrics.gpu_memory_total)}</span>
</div>
</div>
{/* CPU */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-cyan-500/20 rounded-lg flex items-center justify-center">
<Cpu className="w-4 h-4 text-cyan-400" />
</div>
<div>
<div className="text-xs text-white/40">CPU</div>
<div className="text-sm font-medium text-white truncate max-w-[120px]" title={metrics.cpu_model || 'Unknown'}>
{metrics.cpu_cores} cores
</div>
</div>
</div>
<ProgressBar
value={metrics.cpu_usage}
max={100}
color="cyan"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{metrics.cpu_usage.toFixed(1)}%</span>
<span>100%</span>
</div>
</div>
{/* RAM */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-emerald-500/20 rounded-lg flex items-center justify-center">
<MemoryStick className="w-4 h-4 text-emerald-400" />
</div>
<div>
<div className="text-xs text-white/40">RAM</div>
<div className="text-sm font-medium text-white">
{ramUsagePercent.toFixed(0)}% used
</div>
</div>
</div>
<ProgressBar
value={metrics.ram_used}
max={metrics.ram_total}
color="emerald"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{formatBytes(metrics.ram_used)}</span>
<span>{formatBytes(metrics.ram_total)}</span>
</div>
</div>
{/* Disk */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-amber-500/20 rounded-lg flex items-center justify-center">
<HardDrive className="w-4 h-4 text-amber-400" />
</div>
<div>
<div className="text-xs text-white/40">Disk</div>
<div className="text-sm font-medium text-white">
{diskUsagePercent.toFixed(0)}% used
</div>
</div>
</div>
<ProgressBar
value={metrics.disk_used}
max={metrics.disk_total}
color="amber"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{formatBytes(metrics.disk_used)}</span>
<span>{formatBytes(metrics.disk_total)}</span>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-white/40" />
<span className="text-sm text-white/60">
<span className="text-white font-medium">{metrics.agent_count_router}</span> Router
<span className="mx-1">/</span>
<span className="text-white font-medium">{metrics.agent_count_system}</span> System
</span>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-white/40">
<Clock className="w-3 h-3" />
{formatTime(metrics.last_heartbeat)}
</div>
</div>
</div>
);
}
export default NodeMetricsCard;

View File

@@ -7,4 +7,6 @@ export { AgentsCard } from './AgentsCard';
export { MatrixCard } from './MatrixCard';
export { ModulesCard } from './ModulesCard';
export { NodeStandardComplianceCard } from './NodeStandardComplianceCard';
export { DAGIRouterCard } from './DAGIRouterCard';
export { NodeMetricsCard } from './NodeMetricsCard';

View File

@@ -102,7 +102,7 @@ function AgentMiniCard({ title, description, agent, accentClass, icon }: AgentMi
</Link>
)}
<Link
href={`/agents/${agent.id}`}
href={`/agents/${agent.slug || agent.id}`}
className="px-2 py-1 rounded-lg border border-white/10 bg-white/5 text-white/70 hover:bg-white/10 hover:text-white transition-colors flex items-center gap-1"
>
Кабінет

View File

@@ -0,0 +1,362 @@
/**
* Hook для DAGI Agent Audit та Router Agents
* Отримує дані про стан агентів в контексті DAGI Router
*/
import useSWR from 'swr';
// Types
export interface DAGIAuditSummary {
node_id: string;
timestamp: string;
router_total: number;
db_total: number;
active_count: number;
phantom_count: number;
stale_count: number;
triggered_by?: string;
}
export interface DAGIActiveAgent {
router_id: string;
router_name: string;
db_id: string;
db_name: string;
db_external_id?: string;
kind?: string;
status: string;
}
export interface DAGIPhantomAgent {
router_id: string;
router_name: string;
description?: string;
reason: string;
}
export interface DAGIStaleAgent {
db_id: string;
db_name: string;
db_external_id?: string;
kind?: string;
reason: string;
}
export interface DAGIAuditFull {
summary: DAGIAuditSummary;
active_agents: DAGIActiveAgent[];
phantom_agents: DAGIPhantomAgent[];
stale_agents: DAGIStaleAgent[];
report_data?: unknown;
}
export interface DAGIAuditHistory {
node_id: string;
history: DAGIAuditSummary[];
}
// Router Agents Types (for Table)
export interface DAGIRouterAgent {
id: string;
name: string;
role?: string;
status: 'active' | 'phantom' | 'stale' | 'error';
node_id?: string;
models: string[];
gpu?: string;
cpu?: string;
last_seen_at?: string;
has_cabinet: boolean;
cabinet_slug?: string;
description?: string;
has_prompts?: boolean; // Чи є системні промти в БД
}
export interface DAGIRouterAgentsSummary {
active: number;
phantom: number;
stale: number;
router_total: number;
system_total: number;
}
export interface DAGIRouterAgentsResponse {
node_id: string;
last_audit_at?: string;
summary: DAGIRouterAgentsSummary;
agents: DAGIRouterAgent[];
}
// Node Metrics Types
export interface NodeMetrics {
node_id: string;
node_name?: string;
hostname?: string;
status?: string;
environment?: string;
cpu_model?: string;
cpu_cores: number;
cpu_usage: number;
gpu_model?: string;
gpu_memory_total: number;
gpu_memory_used: number;
ram_total: number;
ram_used: number;
disk_total: number;
disk_used: number;
agent_count_router: number;
agent_count_system: number;
last_heartbeat?: string;
}
// API URL
const CITY_SERVICE_URL = process.env.NEXT_PUBLIC_CITY_SERVICE_URL || '';
// Fetcher
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch: ${res.status}`);
}
return res.json();
};
/**
* Отримати останній DAGI audit summary
*/
export function useDAGIAuditSummary(nodeId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<DAGIAuditSummary | null>(
nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit` : null,
fetcher,
{
refreshInterval: 60000, // Оновлювати кожну хвилину
revalidateOnFocus: false
}
);
return {
summary: data,
isLoading,
error,
refresh: mutate
};
}
/**
* Отримати повний DAGI audit з деталями
*/
export function useDAGIAuditFull(nodeId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<DAGIAuditFull | null>(
nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit/full` : null,
fetcher,
{
refreshInterval: 60000,
revalidateOnFocus: false
}
);
return {
audit: data,
isLoading,
error,
refresh: mutate
};
}
/**
* Отримати агентів DAGI Router для таблиці
*/
export function useDAGIRouterAgents(nodeId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<DAGIRouterAgentsResponse>(
nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-router/agents` : null,
fetcher,
{
refreshInterval: 30000, // Оновлювати кожні 30 сек
revalidateOnFocus: true
}
);
return {
data,
agents: data?.agents || [],
summary: data?.summary || { active: 0, phantom: 0, stale: 0, router_total: 0, system_total: 0 },
lastAuditAt: data?.last_audit_at,
isLoading,
error,
refresh: mutate
};
}
/**
* Отримати історію DAGI audits
*/
export function useDAGIAuditHistory(nodeId: string | undefined, limit: number = 10) {
const { data, error, isLoading } = useSWR<DAGIAuditHistory>(
nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit/history?limit=${limit}` : null,
fetcher
);
return {
history: data?.history || [],
isLoading,
error
};
}
/**
* Отримати метрики ноди
*/
export function useNodeMetrics(nodeId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<NodeMetrics>(
nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/metrics/current` : null,
fetcher,
{
refreshInterval: 30000,
revalidateOnFocus: true
}
);
return {
metrics: data,
isLoading,
error,
refresh: mutate
};
}
/**
* Запустити DAGI audit
*/
export async function runDAGIAudit(nodeId: string): Promise<{
status: string;
report_id: string;
summary: {
router_total: number;
db_total: number;
active_count: number;
phantom_count: number;
stale_count: number;
};
message: string;
}> {
const res = await fetch(
`${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit/run`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Failed to run audit');
}
return res.json();
}
/**
* Синхронізувати phantom агентів (створити в БД)
*/
export async function syncPhantomAgents(
nodeId: string,
agentIds: string[]
): Promise<{
status: string;
created_count: number;
created_agents: Array<{ id: string; name: string; external_id: string }>;
}> {
const res = await fetch(
`${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-router/phantom/sync`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_ids: agentIds })
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Failed to sync phantom agents');
}
return res.json();
}
/**
* Позначити агентів як stale
*/
export async function markStaleAgents(
nodeId: string,
agentIds: string[]
): Promise<{
status: string;
marked_count: number;
}> {
const res = await fetch(
`${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-router/stale/mark`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_ids: agentIds })
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Failed to mark stale agents');
}
return res.json();
}
// =============================================================================
// Node Agents API
// =============================================================================
export interface NodeAgent {
id: string;
name: string;
slug?: string;
kind?: string;
role?: string;
status: string;
dagi_status?: string;
last_seen_at?: string;
is_guardian: boolean;
is_steward: boolean;
}
export interface NodeAgentsResponse {
node_id: string;
total: number;
guardian?: NodeAgent;
steward?: NodeAgent;
agents: NodeAgent[];
}
/**
* Отримати агентів ноди (Guardian, Steward, runtime agents)
*/
export function useNodeAgents(nodeId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<NodeAgentsResponse>(
nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/agents` : null,
fetcher,
{
refreshInterval: 60000,
revalidateOnFocus: false
}
);
return {
data,
guardian: data?.guardian,
steward: data?.steward,
agents: data?.agents || [],
total: data?.total || 0,
isLoading,
error,
refresh: mutate
};
}

View File

@@ -12,6 +12,22 @@ export interface NodeMicrodaoSummary {
rooms_count: number;
}
export interface NodeMetrics {
cpu_model?: string | null;
cpu_cores: number;
cpu_usage: number;
gpu_model?: string | null;
gpu_vram_total: number;
gpu_vram_used: number;
ram_total: number;
ram_used: number;
disk_total: number;
disk_used: number;
agent_count_router: number;
agent_count_system: number;
dagi_router_url?: string | null;
}
export interface NodeProfile {
node_id: string;
name: string;
@@ -28,6 +44,7 @@ export interface NodeProfile {
guardian_agent?: NodeAgentSummary | null;
steward_agent?: NodeAgentSummary | null;
microdaos?: NodeMicrodaoSummary[];
metrics?: NodeMetrics | null;
}
export interface NodeListResponse {