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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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">
|
||||
|
||||
463
apps/web/src/components/node-dashboard/DAGIRouterCard.tsx
Normal file
463
apps/web/src/components/node-dashboard/DAGIRouterCard.tsx
Normal 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">Натисніть "Запустити" для аудиту</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;
|
||||
247
apps/web/src/components/node-dashboard/NodeMetricsCard.tsx
Normal file
247
apps/web/src/components/node-dashboard/NodeMetricsCard.tsx
Normal 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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
Кабінет
|
||||
|
||||
362
apps/web/src/hooks/useDAGIAudit.ts
Normal file
362
apps/web/src/hooks/useDAGIAudit.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user