feat: DAGI Router v2 - new endpoints, hooks, and UI card

This commit is contained in:
Apple
2025-12-01 05:21:43 -08:00
parent 53f31adbf0
commit e3accd4df0
221 changed files with 999 additions and 261 deletions

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL =
process.env.INTERNAL_API_URL ||
process.env.CITY_SERVICE_URL ||
'http://daarion-city-service:7001';
export const dynamic = 'force-dynamic';
// Fallback response when data is unavailable
const fallbackResponse = (nodeId: string) => ({
node_id: nodeId,
total: 0,
active: 0,
phantom: 0,
stale: 0,
agents: [],
});
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ nodeId: string }> }
) {
const { nodeId } = await params;
if (!nodeId) {
return NextResponse.json(
{ error: 'nodeId is required' },
{ status: 400 }
);
}
try {
const url = `${CITY_SERVICE_URL}/city/internal/node/${encodeURIComponent(nodeId)}/dagi-router/agents`;
console.log('[dagi-router/agents] Fetching:', url);
const upstream = await fetch(url, {
cache: 'no-store',
headers: {
'content-type': 'application/json',
},
});
console.log('[dagi-router/agents] Response status:', upstream.status);
if (!upstream.ok) {
return NextResponse.json(fallbackResponse(nodeId), { status: 200 });
}
const payload = await upstream.json();
return NextResponse.json(payload, { status: 200 });
} catch (error) {
console.error('[dagi-router/agents] Error:', error);
return NextResponse.json(fallbackResponse(nodeId), { status: 200 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL =
process.env.INTERNAL_API_URL ||
process.env.CITY_SERVICE_URL ||
'http://daarion-city-service:7001';
export const dynamic = 'force-dynamic';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ nodeId: string }> }
) {
const { nodeId } = await params;
if (!nodeId) {
return NextResponse.json(
{ error: 'nodeId is required' },
{ status: 400 }
);
}
try {
const url = `${CITY_SERVICE_URL}/city/internal/node/${encodeURIComponent(nodeId)}/dagi-router/health`;
console.log('[dagi-router/health] Fetching:', url);
const upstream = await fetch(url, {
cache: 'no-store',
headers: {
'content-type': 'application/json',
},
});
console.log('[dagi-router/health] Response status:', upstream.status);
if (!upstream.ok) {
// Return fallback instead of error
return NextResponse.json({
node_id: nodeId,
status: 'down',
version: null,
agent_count: 0,
latency_ms: null,
}, { status: 200 });
}
const payload = await upstream.json();
return NextResponse.json(payload, { status: 200 });
} catch (error) {
console.error('[dagi-router/health] Error:', error);
return NextResponse.json({
node_id: nodeId,
status: 'down',
version: null,
agent_count: 0,
latency_ms: null,
}, { status: 200 });
}
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL =
process.env.INTERNAL_API_URL ||
process.env.CITY_SERVICE_URL ||
'http://daarion-city-service:7001';
export const dynamic = 'force-dynamic';
// Fallback response when data is unavailable
const fallbackResponse = (nodeId: string) => ({
node_id: nodeId,
status: 'down',
version: null,
latency_ms: null,
router_agent_count: 0,
db_agent_count: 0,
active: 0,
phantom: 0,
stale: 0,
last_audit_at: null,
});
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ nodeId: string }> }
) {
const { nodeId } = await params;
if (!nodeId) {
return NextResponse.json(
{ error: 'nodeId is required' },
{ status: 400 }
);
}
try {
const url = `${CITY_SERVICE_URL}/city/internal/node/${encodeURIComponent(nodeId)}/dagi-router/summary`;
console.log('[dagi-router/summary] Fetching:', url);
const upstream = await fetch(url, {
cache: 'no-store',
headers: {
'content-type': 'application/json',
},
});
console.log('[dagi-router/summary] Response status:', upstream.status);
if (!upstream.ok) {
return NextResponse.json(fallbackResponse(nodeId), { status: 200 });
}
const payload = await upstream.json();
return NextResponse.json(payload, { status: 200 });
} catch (error) {
console.error('[dagi-router/summary] Error:', error);
return NextResponse.json(fallbackResponse(nodeId), { status: 200 });
}
}

View File

@@ -15,17 +15,13 @@ import {
Search,
Filter,
ChevronDown,
Download,
Upload,
Brain
Zap
} from 'lucide-react';
import {
useDAGIRouterAgents,
runDAGIAudit,
syncPhantomAgents,
markStaleAgents,
type DAGIRouterAgent
} from '@/hooks/useDAGIAudit';
useDAGIRouterSummary,
useDAGIRouterAgents,
type DagiRouterAgent
} from '@/hooks/useDAGIRouter';
interface DAGIRouterCardProps {
nodeId: string;
@@ -65,18 +61,42 @@ const STATUS_CONFIG = {
}
};
const ROUTER_STATUS_CONFIG = {
up: {
label: 'Up',
bgClass: 'bg-emerald-500/20',
textClass: 'text-emerald-400',
dotClass: 'bg-emerald-400'
},
degraded: {
label: 'Degraded',
bgClass: 'bg-amber-500/20',
textClass: 'text-amber-400',
dotClass: 'bg-amber-400'
},
down: {
label: 'Down',
bgClass: 'bg-red-500/20',
textClass: 'text-red-400',
dotClass: 'bg-red-400'
}
};
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 { data: summary, error: summaryError, mutate: refreshSummary } = useDAGIRouterSummary(nodeId);
const { data: agentsData, error: agentsError, mutate: refreshAgents } = useDAGIRouterAgents(nodeId);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const agents = agentsData?.agents || [];
const isLoading = !summary && !summaryError;
// Filter agents
const filteredAgents = useMemo(() => {
return agents.filter(agent => {
return agents.filter((agent: DagiRouterAgent) => {
// Status filter
if (statusFilter !== 'all' && agent.status !== statusFilter) {
return false;
@@ -85,61 +105,27 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
agent.name.toLowerCase().includes(query) ||
agent.role?.toLowerCase().includes(query) ||
agent.id.toLowerCase().includes(query)
agent.id.toLowerCase().includes(query) ||
agent.name?.toLowerCase().includes(query) ||
agent.kind?.toLowerCase().includes(query)
);
}
return true;
});
}, [agents, statusFilter, searchQuery]);
const handleRunAudit = async () => {
setIsRunning(true);
setRunError(null);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await runDAGIAudit(nodeId);
await refresh();
} catch (e) {
setRunError(e instanceof Error ? e.message : 'Failed to run audit');
await Promise.all([refreshSummary(), refreshAgents()]);
} 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);
setIsRefreshing(false);
}
};
// Format timestamp
const formatTime = (timestamp: string) => {
const formatTime = (timestamp: string | null) => {
if (!timestamp) return '—';
try {
const date = new Date(timestamp);
return date.toLocaleString('uk-UA', {
@@ -164,6 +150,9 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
);
};
const routerStatus = summary?.status || 'down';
const routerConfig = ROUTER_STATUS_CONFIG[routerStatus as keyof typeof ROUTER_STATUS_CONFIG] || ROUTER_STATUS_CONFIG.down;
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
{/* Header */}
@@ -173,66 +162,64 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
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>
)}
{/* Router Status Badge */}
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${routerConfig.bgClass} ${routerConfig.textClass}`}>
<span className={`w-1.5 h-1.5 rounded-full ${routerConfig.dotClass}`} />
{routerConfig.label}
</span>
<button
onClick={handleRunAudit}
disabled={isRunning || isLoading || isSyncing}
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
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 ? 'Аудит...' : 'Запустити'}
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</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 || 'Помилка завантаження'}
{/* Router Info */}
{summary && (
<div className="flex items-center gap-4 mb-4 text-xs text-white/50">
{summary.version && (
<span className="flex items-center gap-1">
<Zap className="w-3 h-3" />
v{summary.version}
</span>
)}
{summary.latency_ms !== null && (
<span>{summary.latency_ms}ms</span>
)}
{summary.last_audit_at && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Audit: {formatTime(summary.last_audit_at)}
</span>
)}
</div>
)}
{/* Loading */}
{isLoading && agents.length === 0 && (
{isLoading && (
<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 Down */}
{!isLoading && routerStatus === 'down' && (
<div className="text-center py-6 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>
<p className="text-red-400">Router недоступний</p>
<p className="text-sm mt-1">Перевірте стан DAGI Router сервісу</p>
</div>
)}
{/* Content */}
{agents.length > 0 && (
{/* Content - when router is up or degraded */}
{!isLoading && routerStatus !== 'down' && (
<>
{/* 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
@@ -244,7 +231,7 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
}`}
>
<div className="text-2xl font-bold text-emerald-400">
{summary.active}
{summary?.active || 0}
</div>
<div className="text-xs text-white/50">Active</div>
</button>
@@ -253,13 +240,13 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
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'
: (summary?.phantom || 0) > 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 || 0) > 0 ? 'text-amber-400' : 'text-white/40'
}`}>
{summary.phantom}
{summary?.phantom || 0}
</div>
<div className="text-xs text-white/50">Phantom</div>
</button>
@@ -268,13 +255,13 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
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'
: (summary?.stale || 0) > 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 || 0) > 0 ? 'text-orange-400' : 'text-white/40'
}`}>
{summary.stale}
{summary?.stale || 0}
</div>
<div className="text-xs text-white/50">Stale</div>
</button>
@@ -282,178 +269,173 @@ export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps
{/* 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>
<span>Router: {summary?.router_agent_count || 0} агентів</span>
<span>DB: {summary?.db_agent_count || 0} агентів</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"
/>
{agents.length > 0 && (
<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>
<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>
))}
)}
{/* Agents Table */}
{agents.length > 0 && (
<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: DagiRouterAgent) => (
<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="font-medium text-white">{agent.name || agent.id}</div>
{agent.kind && (
<div className="text-xs text-white/40">{agent.kind}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{getStatusBadge(agent.status)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className="text-sm text-white/60">
{agent.runtime || '—'}
</span>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-white/50">
{formatTime(agent.last_seen_at)}
</span>
</td>
<td className="px-4 py-3 text-right">
{agent.has_db_record ? (
<Link
href={`/agents/${agent.id}`}
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>
</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>
{/* No agents */}
{agents.length === 0 && (
<div className="text-center py-6 text-white/40">
<Bot className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>Немає агентів у Router</p>
</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" />
Всі агенти синхронізовані
{agents.length > 0 && (
<div className="mt-4 flex items-center justify-between text-xs text-white/40">
<span>
Показано {filteredAgents.length} з {agents.length} агентів
</span>
)}
</div>
{(summary?.phantom || 0) === 0 && (summary?.stale || 0) === 0 && (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle className="w-3 h-3" />
Всі агенти синхронізовані
</span>
)}
</div>
)}
</>
)}
</div>

View File

@@ -0,0 +1,78 @@
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(res => res.json());
export interface DagiRouterHealth {
node_id: string;
status: 'up' | 'down' | 'degraded';
version: string | null;
agent_count: number;
latency_ms: number | null;
}
export interface DagiRouterAgent {
id: string;
name: string | null;
kind: string | null;
runtime: string | null;
node_id: string;
last_seen_at: string | null;
status: 'active' | 'phantom' | 'stale';
has_db_record: boolean;
}
export interface DagiRouterAgentsResponse {
node_id: string;
total: number;
active: number;
phantom: number;
stale: number;
agents: DagiRouterAgent[];
}
export interface DagiRouterSummary {
node_id: string;
status: 'up' | 'down' | 'degraded';
version: string | null;
latency_ms: number | null;
router_agent_count: number;
db_agent_count: number;
active: number;
phantom: number;
stale: number;
last_audit_at: string | null;
}
export function useDAGIRouterHealth(nodeId: string | null) {
return useSWR<DagiRouterHealth>(
nodeId ? `/api/node-internal/${nodeId}/dagi-router/health` : null,
fetcher,
{
refreshInterval: 30000, // Refresh every 30 seconds
revalidateOnFocus: false,
}
);
}
export function useDAGIRouterAgents(nodeId: string | null) {
return useSWR<DagiRouterAgentsResponse>(
nodeId ? `/api/node-internal/${nodeId}/dagi-router/agents` : null,
fetcher,
{
refreshInterval: 30000,
revalidateOnFocus: false,
}
);
}
export function useDAGIRouterSummary(nodeId: string | null) {
return useSWR<DagiRouterSummary>(
nodeId ? `/api/node-internal/${nodeId}/dagi-router/summary` : null,
fetcher,
{
refreshInterval: 30000,
revalidateOnFocus: false,
}
);
}