feat: DAGI Router v2 - new endpoints, hooks, and UI card
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">Натисніть "Запустити" для аудиту</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>
|
||||
|
||||
78
apps/web/src/hooks/useDAGIRouter.ts
Normal file
78
apps/web/src/hooks/useDAGIRouter.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user