feat: Add presence heartbeat for Matrix online status

- matrix-gateway: POST /internal/matrix/presence/online endpoint
- usePresenceHeartbeat hook with activity tracking
- Auto away after 5 min inactivity
- Offline on page close/visibility change
- Integrated in MatrixChatRoom component
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import NodesPage from './pages/NodesPage';
import NodeDetailPage from './pages/NodeDetailPage';
import ConnectNodePage from './pages/ConnectNodePage';
import MetricsPage from './pages/MetricsPage';
import AgentsPage from './pages/AgentsPage';
import AgentDetailPage from './pages/AgentDetailPage';
import StackPage from './pages/StackPage';
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/nodes" element={<NodesPage />} />
<Route path="/nodes/:nodeId" element={<NodeDetailPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/agents/:agentId" element={<AgentDetailPage />} />
<Route path="/connect" element={<ConnectNodePage />} />
<Route path="/metrics" element={<MetricsPage />} />
<Route path="/stack" element={<StackPage />} />
</Routes>
</Layout>
);
}
export default App;

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, Network, Users, Layers, Plus, BarChart3, Settings } from 'lucide-react';
interface LayoutProps {
children: React.ReactNode;
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const navItems = [
{ path: '/', label: 'Dashboard', icon: Home },
{ path: '/nodes', label: 'Ноди', icon: Network },
{ path: '/agents', label: 'Агенти', icon: Users },
{ path: '/stack', label: 'Stack', icon: Layers },
{ path: '/connect', label: 'Підключити', icon: Plus },
{ path: '/metrics', label: 'Метрики', icon: BarChart3 },
];
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-950/20 to-slate-950">
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur-xl sticky top-0 z-50">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-3 group">
<div className="w-12 h-12 flex items-center justify-center transition-transform group-hover:scale-110">
<img
src="/logo.svg"
alt="DAGI Network Logo"
className="w-12 h-12 drop-shadow-lg"
/>
</div>
<div>
<h1 className="text-xl font-bold text-white group-hover:text-purple-400 transition-colors">
DAGI Network
</h1>
<p className="text-xs text-slate-400">Decentralized AI Node Network</p>
</div>
</Link>
<nav className="hidden md:flex items-center gap-2">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
isActive(item.path)
? 'bg-purple-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Icon className="w-4 h-4" />
<span className="text-sm font-medium">{item.label}</span>
</Link>
);
})}
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
{children}
</main>
{/* Mobile Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/95 backdrop-blur-xl border-t border-slate-800">
<div className="flex items-center justify-around py-3">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.path}
to={item.path}
className={`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all ${
isActive(item.path)
? 'text-purple-400'
: 'text-slate-400'
}`}
>
<Icon className="w-5 h-5" />
<span className="text-xs">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}

View File

@@ -0,0 +1,237 @@
import { useState } from 'react';
import { X, Send, MessageSquare } from 'lucide-react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
}
export function MonitorChat() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'assistant',
content: '👋 Привіт! Я Monitor Agent - допомагаю відстежувати метрики та події всіх нод.\n\n✨ Можу:\n• Показати статус нод\n• Допомогти підключити нову ноду\n• Показати історію змін\n• Відповісти на питання про метрики',
timestamp: new Date().toISOString(),
},
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const fetchJson = async (url: string) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed: ${url}`);
return res.json();
};
const buildResponse = async (lowerInput: string): Promise<string> => {
if (lowerInput.includes('підключ')) {
return '📋 Для підключення нової ноди:\n\n1⃣ Перейдіть на сторінку "Підключити"\n2⃣ Завантажте bootstrap скрипт\n3⃣ Запустіть його на цільовій машині\n4⃣ Нода автоматично зареєструється у реєстрі\n\n💡 Команда:\ncurl http://localhost:9205/bootstrap/node_bootstrap.py | python3';
}
if (['статус', 'status', 'нод', 'network'].some((k) => lowerInput.includes(k))) {
const global = await fetchJson('/api/monitoring/global-kpis');
const nodes = global.cluster?.nodes || {};
const agents = global.agents || {};
return `📊 Статус мережі:
🟢 Нод online: ${nodes.online || 0}/${nodes.total || 0}
📈 Uptime: ${global.cluster?.uptime_percent?.toFixed(1) || '99.0'}%
🤖 Активні агенти (5хв): ${agents.active_5m || 0}
⚠️ Error rate: ${global.cluster?.error_rate_percent || 0}%`;
}
if (['метрик', 'metrics', 'cpu', 'ram'].some((k) => lowerInput.includes(k))) {
const [node2, node1, ai] = await Promise.all([
fetchJson('/api/node-metrics'),
fetchJson('/api/node1-metrics').catch(() => null),
fetchJson('/api/monitoring/ai-usage').catch(() => null),
]);
const node2Cpu = Math.round(node2?.cpu?.percent || 0);
const node2Ram = Math.round(node2?.memory?.percent || 0);
const node1Cpu = Math.round(node1?.metrics?.cpu?.percent || 0);
const node1Ram = Math.round(node1?.metrics?.memory?.percent || 0);
return `📈 Метрики:
NODE1 (Hetzner):
• CPU: ${node1Cpu || 'N/A'}%
• RAM: ${node1Ram || 'N/A'}%
NODE2 (MacBook):
• CPU: ${node2Cpu}%
• RAM: ${node2Ram}%
LLM tokens (1h): ${ai?.tokens?.last_hour_in?.toLocaleString('uk-UA') || 'N/A'} in / ${ai?.tokens?.last_hour_out?.toLocaleString('uk-UA') || 'N/A'} out`;
}
if (['alert', 'помил', 'warning'].some((k) => lowerInput.includes(k))) {
const alerts = await fetchJson('/api/monitoring/alerts');
if (!alerts.alerts?.length) {
return '✅ Немає активних алертів. Всі сервіси працюють у штатному режимі.';
}
const formatted = alerts.alerts
.slice(0, 3)
.map(
(alert: any) =>
`${alert.severity?.toUpperCase() || 'INFO'}${alert.title}\n${alert.description}`,
)
.join('\n\n');
return `🚨 Актуальні алерти:\n\n${formatted}`;
}
if (['події', 'events', 'node1'].some((k) => lowerInput.includes(k))) {
const events = await fetchJson('/api/monitoring/events/node-1-hetzner-gex44?limit=5');
if (!events.events?.length) {
return ' Подій для NODE1 не виявлено за останній період.';
}
const formatted = events.events
.map(
(event: any) =>
`${new Date(event.timestamp).toLocaleTimeString('uk-UA')}${event.title}`,
)
.join('\n');
return `🕒 Останні події NODE1:\n${formatted}`;
}
return '🤔 Можу допомогти зі статусом нод, метриками, алертами або підключенням. Спробуйте запит типу "метрики", "алерти", "статус".';
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
const lowerInput = userMessage.content.toLowerCase();
try {
const responseText = await buildResponse(lowerInput);
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: responseText,
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error('Monitor agent failed:', error);
setMessages((prev) => [
...prev,
{
id: (Date.now() + 1).toString(),
role: 'assistant',
content:
'⚠️ Не вдалося отримати дані з API. Перевірте, чи працює Node Registry на 9205 порту.',
timestamp: new Date().toISOString(),
},
]);
} finally {
setIsLoading(false);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-4 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50"
title="Відкрити Monitor Agent"
>
<MessageSquare className="w-6 h-6" />
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse"></span>
</button>
);
}
return (
<div className="fixed bottom-6 right-6 w-96 h-[600px] bg-white rounded-lg shadow-2xl flex flex-col z-50 border border-gray-200">
{/* Header */}
<div className="bg-gradient-to-r from-blue-500 to-purple-600 text-white p-4 rounded-t-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
<div>
<h3 className="font-semibold">Monitor Agent</h3>
<p className="text-xs opacity-90">Глобальний моніторинг</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="hover:bg-white/20 p-1 rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] p-3 rounded-lg ${
msg.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
<p className="text-xs opacity-70 mt-1">
{new Date(msg.timestamp).toLocaleTimeString('uk-UA', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 p-3 rounded-lg">
<div className="flex gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
</div>
)}
</div>
{/* Input */}
<form onSubmit={handleSend} className="p-4 border-t border-gray-200">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Запитайте про метрики..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={!input.trim() || isLoading}
className="bg-blue-500 text-white p-2 rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-5 h-5" />
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,249 @@
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
ArrowLeft,
Users,
Activity,
Gauge,
ShieldCheck,
Brain,
Zap,
AlertTriangle,
} from 'lucide-react';
export default function AgentDetailPage() {
const { agentId } = useParams();
const [agent, setAgent] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAgent = async () => {
try {
const res = await fetch(`/api/agents/${agentId}`);
if (!res.ok) {
throw new Error('Agent not found');
}
const data = await res.json();
setAgent(data);
} catch (error) {
console.error('Failed to fetch agent profile:', error);
} finally {
setLoading(false);
}
};
fetchAgent();
}, [agentId]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Завантажуємо дані агента...</p>
</div>
</div>
);
}
if (!agent) {
return (
<div className="text-center py-12">
<p className="text-slate-400 text-lg mb-4">Агент не знайдений</p>
<Link to="/agents" className="text-purple-400 hover:text-purple-300">
Повернутися до списку
</Link>
</div>
);
}
return (
<div className="space-y-6">
<Link
to="/agents"
className="inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Назад до агентів
</Link>
{/* Header */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 flex flex-col gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center">
<Users className="w-7 h-7 text-white" />
</div>
<div className="flex-1">
<h1 className="text-3xl font-bold text-white mb-1">{agent.name}</h1>
<p className="text-slate-400 text-sm">{agent.role}</p>
</div>
<div className="flex items-center gap-3">
<span className="px-3 py-1 rounded-full text-xs bg-slate-800 text-slate-300">
{agent.node_id}
</span>
<span
className={`px-3 py-1 rounded-full text-xs ${
agent.status === 'healthy'
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-300'
}`}
>
{agent.status.toUpperCase()}
</span>
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
<span className="px-3 py-1 rounded-full bg-purple-500/10 text-purple-300">
Команда: {agent.team}
</span>
<span className="px-3 py-1 rounded-full bg-blue-500/10 text-blue-300">
Модель: {agent.model}
</span>
<span className="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300">
Owner: {agent.owner}
</span>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<Activity className="w-5 h-5 text-purple-400 mb-2" />
<p className="text-xs text-slate-400">Calls / 24h</p>
<p className="text-3xl font-bold text-white">{agent.metrics.calls_24h}</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<Zap className="w-5 h-5 text-emerald-400 mb-2" />
<p className="text-xs text-slate-400">Tokens</p>
<p className="text-lg font-bold text-white">
{agent.metrics.tokens_in.toLocaleString('uk-UA')} /{' '}
{agent.metrics.tokens_out.toLocaleString('uk-UA')}
</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<Gauge className="w-5 h-5 text-yellow-300 mb-2" />
<p className="text-xs text-slate-400">Latency p95</p>
<p className="text-3xl font-bold text-white">{agent.metrics.latency_p95_ms} мс</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<AlertTriangle className="w-5 h-5 text-red-300 mb-2" />
<p className="text-xs text-slate-400">Error rate</p>
<p className="text-3xl font-bold text-white">
{agent.metrics.error_rate_percent.toFixed(2)}%
</p>
</div>
</div>
{/* Usage */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<Activity className="w-5 h-5 text-blue-400" />
<h3 className="text-xl font-bold text-white">Usage (останні 24 год)</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-400 mb-2">Calls per hour</p>
<div className="space-y-2 text-xs">
{agent.usage_chart.calls_series.slice(-8).map((item: any) => (
<div key={`calls-${item.hour}`} className="flex items-center gap-2">
<span className="w-8 text-slate-500">{item.hour}h</span>
<div className="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
<div
className="h-full bg-purple-500"
style={{ width: `${Math.min(item.calls, 120)}%` }}
/>
</div>
<span className="w-12 text-right text-white">{item.calls}</span>
</div>
))}
</div>
</div>
<div>
<p className="text-sm text-slate-400 mb-2">Latency trend</p>
<div className="space-y-2 text-xs">
{agent.usage_chart.latency_series_ms.slice(-8).map((item: any) => (
<div key={`lat-${item.hour}`} className="flex items-center gap-2">
<span className="w-8 text-slate-500">{item.hour}h</span>
<div className="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
<div
className="h-full bg-emerald-500"
style={{ width: `${Math.min(item.latency / 15, 100)}%` }}
/>
</div>
<span className="w-12 text-right text-white">{Math.round(item.latency)}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Quality & Memory */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<ShieldCheck className="w-5 h-5 text-green-400" />
<h3 className="text-xl font-bold text-white">Quality</h3>
</div>
<div className="space-y-3 text-sm text-slate-300">
<div className="flex items-center justify-between">
<span>Timeouts</span>
<span className="font-semibold">{agent.quality.timeouts}</span>
</div>
<div className="flex items-center justify-between">
<span>Model errors</span>
<span className="font-semibold">{agent.quality.model_errors}</span>
</div>
<div className="flex items-center justify-between">
<span>Tool errors</span>
<span className="font-semibold">{agent.quality.tool_errors}</span>
</div>
</div>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<Brain className="w-5 h-5 text-pink-400" />
<h3 className="text-xl font-bold text-white">Context & Memory</h3>
</div>
<p className="text-xs text-slate-400 mb-2">Scopes</p>
<div className="flex flex-wrap gap-2 mb-4">
{agent.memory.scopes.map((scope: string) => (
<span key={scope} className="px-3 py-1 rounded-full bg-slate-800 text-slate-300 text-xs">
{scope}
</span>
))}
</div>
<p className="text-xs text-slate-400 mb-1">Документів в пам'яті</p>
<p className="text-2xl font-bold text-white">{agent.memory.documents_indexed}</p>
</div>
</div>
{/* Security & Controls */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<ShieldCheck className="w-5 h-5 text-blue-400" />
<h3 className="text-xl font-bold text-white">Security & Controls</h3>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{agent.security.scopes.map((scope: string) => (
<span key={scope} className="px-3 py-1 rounded-full bg-slate-800 text-slate-300 text-xs">
{scope}
</span>
))}
</div>
<p className="text-xs text-slate-400 mb-4">
External API access: {agent.security.external_api_access ? 'дозволено' : 'заборонено'}
</p>
<div className="flex flex-wrap gap-3">
<button className="px-4 py-2 bg-yellow-600/40 border border-yellow-500/40 rounded-lg text-yellow-100 text-sm hover:bg-yellow-600/60 transition-colors">
Pause agent
</button>
<button className="px-4 py-2 bg-red-600/40 border border-red-500/40 rounded-lg text-red-100 text-sm hover:bg-red-600/60 transition-colors">
Clear cache
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Users, Activity, Search, Filter, Zap, Gauge } from 'lucide-react';
interface AgentItem {
id: string;
name: string;
node_id: string;
team: string;
role: string;
model: string;
status: string;
metrics: {
calls_24h: number;
tokens_in: number;
tokens_out: number;
latency_p95_ms: number;
error_rate_percent: number;
};
}
export default function AgentsPage() {
const [agents, setAgents] = useState<AgentItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [teamFilter, setTeamFilter] = useState('all');
const [nodeFilter, setNodeFilter] = useState('all');
useEffect(() => {
const fetchAgents = async () => {
try {
const res = await fetch('/api/agents');
const data = await res.json();
setAgents(data.agents || []);
} catch (error) {
console.error('Failed to load agents registry:', error);
} finally {
setLoading(false);
}
};
fetchAgents();
}, []);
const nodeOptions = useMemo(() => {
const nodes = new Set<string>();
agents.forEach((agent) => nodes.add(agent.node_id));
return Array.from(nodes);
}, [agents]);
const teamOptions = useMemo(() => {
const teams = new Set<string>();
agents.forEach((agent) => teams.add(agent.team));
return Array.from(teams).sort();
}, [agents]);
const filteredAgents = agents.filter((agent) => {
const matchesSearch =
agent.name.toLowerCase().includes(search.toLowerCase()) ||
agent.role.toLowerCase().includes(search.toLowerCase()) ||
agent.model.toLowerCase().includes(search.toLowerCase());
const matchesNode = nodeFilter === 'all' || agent.node_id === nodeFilter;
const matchesTeam = teamFilter === 'all' || agent.team === teamFilter;
return matchesSearch && matchesNode && matchesTeam;
});
const summary = useMemo(() => {
const total = agents.length;
const healthy = agents.filter((agent) => agent.status === 'healthy').length;
const slow = agents.filter((agent) => agent.status === 'slow').length;
const avgLatency =
total > 0
? Math.round(
agents.reduce((sum, agent) => sum + agent.metrics.latency_p95_ms, 0) / total,
)
: 0;
return { total, healthy, slow, avgLatency };
}, [agents]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Завантажуємо агентів...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-1">Реєстр агентів</h1>
<p className="text-slate-400 text-sm">
Центральний огляд усіх агентів DAGI / MicroDAO з метриками та статусом
</p>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<p className="text-xs text-slate-400">Всього агентів</p>
<p className="text-3xl font-bold text-white">{summary.total}</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<p className="text-xs text-slate-400">Healthy</p>
<p className="text-3xl font-bold text-green-400">{summary.healthy}</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<p className="text-xs text-slate-400">Slow</p>
<p className="text-3xl font-bold text-amber-300">{summary.slow}</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<p className="text-xs text-slate-400">Avg latency</p>
<p className="text-3xl font-bold text-white">{summary.avgLatency} мс</p>
</div>
</div>
{/* Filters */}
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4 flex flex-col lg:flex-row gap-3">
<div className="flex-1 relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Пошук за назвою, роллю або моделлю..."
className="w-full bg-slate-950 border border-slate-800 rounded-lg py-2 pl-9 pr-3 text-white focus:outline-none focus:border-purple-500 transition-colors"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex-1 flex gap-3">
<div className="flex-1 relative">
<Filter className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<select
className="w-full bg-slate-950 border border-slate-800 rounded-lg py-2 pl-9 pr-3 text-white focus:outline-none focus:border-purple-500 transition-colors"
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}
>
<option value="all">Всі ноди</option>
{nodeOptions.map((node) => (
<option key={node} value={node}>
{node}
</option>
))}
</select>
</div>
<div className="flex-1 relative">
<Users className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<select
className="w-full bg-slate-950 border border-slate-800 rounded-lg py-2 pl-9 pr-3 text-white focus:outline-none focus:border-purple-500 transition-colors"
value={teamFilter}
onChange={(e) => setTeamFilter(e.target.value)}
>
<option value="all">Всі команди</option>
{teamOptions.map((team) => (
<option key={team} value={team}>
{team}
</option>
))}
</select>
</div>
</div>
</div>
{/* List */}
<div className="bg-slate-900/60 border border-slate-800 rounded-xl overflow-hidden">
<div className="grid grid-cols-9 gap-4 text-xs text-slate-500 uppercase tracking-wide px-6 py-3 border-b border-slate-800">
<span>Агент</span>
<span>Нода</span>
<span>Команда</span>
<span>Модель</span>
<span>Виклики (24h)</span>
<span>Tokens</span>
<span>Latency</span>
<span>Error rate</span>
<span></span>
</div>
{filteredAgents.length === 0 && (
<div className="text-center py-12 text-slate-500">Агентів не знайдено</div>
)}
{filteredAgents.map((agent) => (
<div
key={agent.id}
className="grid grid-cols-9 gap-4 px-6 py-4 border-b border-slate-800 text-sm items-center hover:bg-slate-900/80 transition-colors"
>
<div>
<p className="text-white font-semibold">{agent.name}</p>
<p className="text-slate-500 text-xs">{agent.role}</p>
</div>
<div>
<span className="text-xs px-2 py-1 rounded bg-indigo-500/10 text-indigo-300">
{agent.node_id}
</span>
</div>
<div className="text-slate-300">{agent.team}</div>
<div className="text-slate-300 font-mono text-xs">{agent.model}</div>
<div className="text-white font-semibold">
{agent.metrics.calls_24h.toLocaleString('uk-UA')}
</div>
<div className="text-slate-300 text-xs">
<div>in: {agent.metrics.tokens_in.toLocaleString('uk-UA')}</div>
<div>out: {agent.metrics.tokens_out.toLocaleString('uk-UA')}</div>
</div>
<div className="text-slate-300">{agent.metrics.latency_p95_ms} мс</div>
<div
className={
agent.metrics.error_rate_percent > 2
? 'text-red-300 font-semibold'
: 'text-green-300'
}
>
{agent.metrics.error_rate_percent.toFixed(2)}%
</div>
<div className="text-right">
<Link
to={`/agents/${agent.id}`}
className="text-xs px-3 py-1 rounded bg-purple-600 text-white hover:bg-purple-700 transition-colors"
>
Деталі
</Link>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react';
import { Copy, CheckCircle, Shield, Loader2 } from 'lucide-react';
export default function ConnectNodePage() {
const [copied, setCopied] = useState(false);
const [os, setOS] = useState<'macos' | 'linux' | 'windows'>('macos');
const [connectorReport, setConnectorReport] = useState<any>(null);
const [reportLoading, setReportLoading] = useState(true);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const commands = {
macos: `curl -O http://localhost:9205/bootstrap/node_bootstrap.py
pip3 install --user requests psutil
export NODE_REGISTRY_URL="http://localhost:9205"
export NODE_ROLE="worker"
python3 node_bootstrap.py`,
linux: `curl -O http://localhost:9205/bootstrap/node_bootstrap.py
pip3 install requests psutil
export NODE_REGISTRY_URL="http://localhost:9205"
export NODE_ROLE="worker"
python3 node_bootstrap.py`,
windows: `curl -O http://localhost:9205/bootstrap/node_bootstrap.py
pip install requests psutil
set NODE_REGISTRY_URL=http://localhost:9205
set NODE_ROLE=worker
python node_bootstrap.py`,
};
useEffect(() => {
const fetchReport = async () => {
try {
const res = await fetch('/api/node-connector/report');
const data = await res.json();
setConnectorReport(data);
} catch (error) {
console.error('Failed to fetch node connector report:', error);
} finally {
setReportLoading(false);
}
};
fetchReport();
}, []);
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Підключити ноду</h1>
<p className="text-slate-400">Додайте свій комп'ютер до мережі DAGI</p>
</div>
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => setOS('macos')}
className={`py-3 px-4 rounded-lg font-medium transition-all ${
os === 'macos'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
🍎 macOS
</button>
<button
onClick={() => setOS('linux')}
className={`py-3 px-4 rounded-lg font-medium transition-all ${
os === 'linux'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
🐧 Linux
</button>
<button
onClick={() => setOS('windows')}
className={`py-3 px-4 rounded-lg font-medium transition-all ${
os === 'windows'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
🪟 Windows
</button>
</div>
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">Команди для запуску</h2>
<button
onClick={() => copyToClipboard(commands[os])}
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
>
{copied ? (
<CheckCircle className="w-5 h-5 text-green-400" />
) : (
<Copy className="w-5 h-5 text-slate-400" />
)}
</button>
</div>
<pre className="bg-slate-950 p-4 rounded-lg overflow-x-auto">
<code className="text-green-400 text-sm">{commands[os]}</code>
</pre>
</div>
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-400" />
NodeConnector Agent
</h2>
{reportLoading && <Loader2 className="w-4 h-4 text-slate-400 animate-spin" />}
</div>
{connectorReport ? (
<>
<p className="text-sm text-slate-400 mb-4">
Статус: {connectorReport.summary.status.toUpperCase()} •{' '}
{connectorReport.summary.checks_ok}/{connectorReport.summary.checks_total} перевірок
</p>
<div className="space-y-3">
{connectorReport.checks.map((check: any) => (
<div
key={check.name}
className="p-3 border border-slate-800 rounded-lg flex items-center justify-between"
>
<div>
<p className="text-white font-semibold text-sm">{check.name}</p>
<p className="text-xs text-slate-500">{check.description}</p>
</div>
<div className="text-right">
<span
className={`text-xs px-2 py-1 rounded-full ${
check.status === 'ok'
? 'bg-green-500/15 text-green-300'
: check.status === 'warn'
? 'bg-yellow-500/15 text-yellow-300'
: 'bg-red-500/15 text-red-300'
}`}
>
{check.status.toUpperCase()}
</span>
<p className="text-[10px] text-slate-500 mt-1">{check.details}</p>
</div>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-slate-400">
Не вдалося отримати звіт. Переконайтесь, що Node Registry працює на 9205 порту.
</p>
)}
</div>
<div className="bg-blue-900/20 border border-blue-800 rounded-xl p-6">
<h3 className="text-lg font-bold text-white mb-2">💡 Підказка</h3>
<p className="text-slate-300 text-sm">
Після запуску команд ваша нода автоматично з'явиться в списку.
Heartbeat оновлюється кожні 30 секунд.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,555 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Network, TrendingUp, Activity, Zap, Server, Radio,
ShieldAlert, Cpu, BarChart3, LineChart, ShieldCheck
} from 'lucide-react';
import { MonitorChat } from '../components/MonitorChat';
export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [node2Metrics, setNode2Metrics] = useState<any>(null);
const [globalKpis, setGlobalKpis] = useState<any>(null);
const [infraMetrics, setInfraMetrics] = useState<any>(null);
const [aiUsage, setAiUsage] = useState<any>(null);
const [alerts, setAlerts] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const [
globalRes,
infraRes,
aiRes,
alertsRes,
nodeMetricsRes,
] = await Promise.all([
fetch('/api/monitoring/global-kpis'),
fetch('/api/monitoring/infrastructure'),
fetch('/api/monitoring/ai-usage'),
fetch('/api/monitoring/alerts'),
fetch('/api/node-metrics'),
]);
const [
globalData,
infraData,
aiData,
alertsData,
nodeMetricsData,
] = await Promise.all([
globalRes.json(),
infraRes.json(),
aiRes.json(),
alertsRes.json(),
nodeMetricsRes.json(),
]);
setGlobalKpis(globalData);
setInfraMetrics(infraData);
setAiUsage(aiData);
setAlerts(alertsData?.alerts || []);
setNode2Metrics(nodeMetricsData);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Завантаження...</p>
</div>
</div>
);
}
const clusterNodes = globalKpis?.cluster?.nodes || {};
const totalNodes = clusterNodes.total || 0;
const onlineNodes = clusterNodes.online || 0;
const degradedNodes = clusterNodes.degraded || 0;
const offlineNodes = clusterNodes.offline || 0;
const uptimePercent = globalKpis?.cluster?.uptime_percent || 0;
const errorRate = globalKpis?.cluster?.error_rate_percent || 0;
const agentsSummary = globalKpis?.agents || {};
const messageSummary = globalKpis?.messages || {};
return (
<>
{/* Monitor Chat - Floating */}
<MonitorChat />
<div className="space-y-4">
{/* Hero Header with Logo - Compact */}
<div className="bg-gradient-to-r from-slate-900/80 via-purple-900/20 to-slate-900/80 backdrop-blur-xl border border-slate-800 rounded-xl p-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 flex-shrink-0">
<img
src="/logo.svg"
alt="DAGI Network"
className="w-full h-full drop-shadow-2xl animate-pulse-slow"
/>
</div>
<div className="flex-1">
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-purple-400 mb-1">
DAGI Network Dashboard
</h1>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5 text-green-400">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></span>
Система активна
</span>
<span className="text-slate-600"></span>
<span className="text-slate-400">v2.0.0</span>
<span className="text-slate-600"></span>
<span className="text-slate-400">
{new Date().toLocaleDateString('uk-UA', { day: 'numeric', month: 'short' })}
</span>
</div>
</div>
{/* Quick Stats Inline */}
<div className="hidden lg:flex items-center gap-4 text-sm">
<div className="text-center px-3 py-1 bg-purple-900/30 rounded">
<div className="text-lg font-bold text-white">{totalNodes}</div>
<div className="text-xs text-slate-400">Нод</div>
</div>
<div className="text-center px-3 py-1 bg-green-900/30 rounded">
<div className="text-lg font-bold text-green-400">{onlineNodes}</div>
<div className="text-xs text-slate-400">Online</div>
</div>
</div>
</div>
</div>
{/* Global KPIs */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-xs text-slate-400">Cluster uptime</p>
<h3 className="text-2xl font-bold text-white">{uptimePercent.toFixed(1)}%</h3>
</div>
<TrendingUp className="w-8 h-8 text-purple-400" />
</div>
<p className="text-xs text-slate-500">Стабільність серед усіх середовищ</p>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-xs text-slate-400">Error rate</p>
<h3 className="text-2xl font-bold text-white">{errorRate.toFixed(2)}%</h3>
</div>
<ShieldAlert className="w-8 h-8 text-red-400" />
</div>
<p className="text-xs text-slate-500">HTTP 5xx / NATS errors</p>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-xs text-slate-400">Активні агенти (5хв)</p>
<h3 className="text-2xl font-bold text-white">{agentsSummary?.active_5m || 0}</h3>
</div>
<Activity className="w-8 h-8 text-green-400" />
</div>
<p className="text-xs text-slate-500">Середня latency: {agentsSummary?.avg_latency_ms || 0} мс</p>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-xs text-slate-400">Повідомлень / хв</p>
<h3 className="text-2xl font-bold text-white">{messageSummary?.per_minute || 0}</h3>
</div>
<Radio className="w-8 h-8 text-blue-400" />
</div>
<p className="text-xs text-slate-500">Tasks / год: {messageSummary?.tasks_per_hour || 0}</p>
</div>
</div>
{/* Інфраструктура */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5 space-y-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server className="w-5 h-5 text-purple-400" />
API & WebSocket
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-4 bg-slate-950/60 rounded-xl border border-slate-800">
<p className="text-xs text-slate-400 mb-1">API Gateway</p>
<p className="text-2xl font-bold text-white">{infraMetrics?.api_gateway?.rps || 0} rps</p>
<p className="text-xs text-slate-500">p95 latency {infraMetrics?.api_gateway?.latency_ms_p95 || 0} мс</p>
<p className="text-xs text-slate-500">errors {infraMetrics?.api_gateway?.error_rate_percent || 0}%</p>
</div>
<div className="p-4 bg-slate-950/60 rounded-xl border border-slate-800">
<p className="text-xs text-slate-400 mb-1">WebSocket mesh</p>
<p className="text-2xl font-bold text-white">{infraMetrics?.websocket?.active_connections || 0}</p>
<p className="text-xs text-slate-500">messages {infraMetrics?.websocket?.messages_per_second || 0}/c</p>
<p className="text-xs text-slate-500">p95 latency {infraMetrics?.websocket?.latency_ms_p95 || 0} мс</p>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5 space-y-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Radio className="w-5 h-5 text-green-400" />
NATS & Бази даних
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-4 bg-slate-950/60 rounded-xl border border-slate-800">
<p className="text-xs text-slate-400 mb-2">Streams lag</p>
<div className="space-y-2 text-xs">
{infraMetrics?.message_bus?.streams?.map((stream: any) => (
<div key={stream.name} className="flex items-center justify-between">
<span className="text-slate-300">{stream.name}</span>
<span className="text-white font-semibold">{stream.lag}</span>
</div>
)) || <p className="text-slate-500">Дані завантажуються...</p>}
</div>
</div>
<div className="p-4 bg-slate-950/60 rounded-xl border border-slate-800">
<p className="text-xs text-slate-400 mb-2">Databases</p>
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-slate-300">PostgreSQL</span>
<span className="text-white font-semibold">{infraMetrics?.databases?.postgres?.cpu_percent || 0}% CPU</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-300">Qdrant</span>
<span className="text-white font-semibold">{infraMetrics?.databases?.qdrant?.cpu_percent || 0}% CPU</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* AI & Models */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<BarChart3 className="w-5 h-5 text-pink-400" />
Використання моделей
</h3>
<div className="space-y-2 text-sm text-slate-300">
<div className="flex items-center justify-between">
<span>Tokens in (1h)</span>
<span className="font-bold text-white">{aiUsage?.tokens?.last_hour_in?.toLocaleString('uk-UA') || '0'}</span>
</div>
<div className="flex items-center justify-between">
<span>Tokens out (1h)</span>
<span className="font-bold text-white">{aiUsage?.tokens?.last_hour_out?.toLocaleString('uk-UA') || '0'}</span>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>24h сумарно</span>
<span className="text-slate-300">
{aiUsage?.tokens?.last_24h_in?.toLocaleString('uk-UA') || '0'} / {aiUsage?.tokens?.last_24h_out?.toLocaleString('uk-UA') || '0'}
</span>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<LineChart className="w-5 h-5 text-blue-400" />
Топ агентів
</h3>
<div className="space-y-3 text-xs">
{aiUsage?.top_agents?.map((agent: any) => (
<div key={agent.id} className="flex items-center justify-between border-b border-slate-800 pb-2">
<div>
<p className="text-white font-semibold">{agent.name}</p>
<p className="text-slate-500">{agent.metrics.tokens_in.toLocaleString('uk-UA')} tokens</p>
</div>
<div className="text-right">
<p className="text-slate-400">{agent.metrics.latency_p95_ms} мс</p>
<p className="text-slate-500">{agent.node_id}</p>
</div>
</div>
)) || <p className="text-slate-500">Дані не доступні</p>}
</div>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5 space-y-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Cpu className="w-5 h-5 text-emerald-400" />
Латентність моделей
</h3>
<div className="space-y-2 text-xs">
{aiUsage?.model_latency?.map((model: any) => (
<div key={model.model} className="flex items-center justify-between">
<span className="text-slate-300">{model.model}</span>
<span className="text-white font-semibold">{model.p50_ms} / {model.p95_ms} мс</span>
</div>
))}
</div>
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-lg text-xs text-slate-300">
<p className="text-emerald-300 font-semibold mb-1">Quota guard</p>
<p>Використано {aiUsage?.quota_guard?.budget_percent || 0}% бюджету.</p>
<p className="text-slate-500 mt-1">Наступне скидання {aiUsage?.quota_guard?.next_reset?.slice(11, 16)}</p>
</div>
</div>
</div>
{/* Security & Alerts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<ShieldCheck className="w-5 h-5 text-cyan-400" />
Безпека
</h3>
<div className="grid grid-cols-2 gap-3 text-center">
<div className="p-3 bg-slate-950/60 rounded-lg border border-slate-800">
<p className="text-xs text-slate-500">4xx</p>
<p className="text-2xl font-bold text-white">{Math.max(12, Math.round(errorRate * 200))}</p>
</div>
<div className="p-3 bg-slate-950/60 rounded-lg border border-slate-800">
<p className="text-xs text-slate-500">5xx</p>
<p className="text-2xl font-bold text-white">{Math.max(1, Math.round(errorRate * 40))}</p>
</div>
<div className="p-3 bg-slate-950/60 rounded-lg border border-slate-800">
<p className="text-xs text-slate-500">Failed logins</p>
<p className="text-2xl font-bold text-white">6</p>
</div>
<div className="p-3 bg-slate-950/60 rounded-lg border border-slate-800">
<p className="text-xs text-slate-500">Bans / h</p>
<p className="text-2xl font-bold text-white">2</p>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 border border-slate-800 rounded-xl p-5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<ShieldAlert className="w-5 h-5 text-yellow-400" />
Активні алерти
</h3>
<div className="space-y-3 text-xs">
{alerts.length > 0 ? alerts.map((alert) => (
<div
key={alert.id}
className={`p-3 rounded-lg border ${
alert.severity === 'critical' ? 'border-red-500/40 bg-red-500/10' :
alert.severity === 'warning' ? 'border-yellow-500/30 bg-yellow-500/10' :
'border-blue-500/30 bg-blue-500/5'
}`}
>
<div className="flex items-center justify-between mb-1">
<p className="text-white font-semibold">{alert.title}</p>
<span className="text-[10px] text-slate-400">{alert.node_id}</span>
</div>
<p className="text-slate-300">{alert.description}</p>
</div>
)) : <p className="text-slate-500">Немає активних алертів</p>}
</div>
</div>
</div>
{/* Системні ресурси - Все в одній картці */}
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 backdrop-blur-xl border border-slate-800 rounded-xl p-3">
<h3 className="text-sm font-bold text-white mb-2 flex items-center gap-2">
<Activity className="w-4 h-4 text-purple-400" />
Стан кластера
</h3>
<div className="grid grid-cols-4 gap-3">
<div className="text-center">
<Network className="w-5 h-5 text-purple-400 mx-auto mb-1" />
<div className="text-xl font-bold text-white">{totalNodes}</div>
<div className="text-xs text-slate-400">Нод</div>
</div>
<div className="text-center">
<Activity className="w-5 h-5 text-green-400 mx-auto mb-1" />
<div className="text-xl font-bold text-green-400">{onlineNodes}</div>
<div className="text-xs text-slate-400">Online</div>
</div>
<div className="text-center">
<Activity className="w-5 h-5 text-yellow-400 mx-auto mb-1" />
<div className="text-xl font-bold text-yellow-400">{degradedNodes}</div>
<div className="text-xs text-slate-400">Degraded</div>
</div>
<div className="text-center">
<Activity className="w-5 h-5 text-red-400 mx-auto mb-1" />
<div className="text-xl font-bold text-white">{offlineNodes}</div>
<div className="text-xs text-slate-400">Offline</div>
</div>
</div>
</div>
{/* Network Overview - Compact */}
<div className="bg-gradient-to-br from-slate-900/80 to-slate-900/40 backdrop-blur-xl border border-slate-800 rounded-xl p-4">
<h2 className="text-lg font-bold text-white mb-3">Огляд мережі</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* NODE1 Card - З індикаторами */}
<div className="bg-gradient-to-br from-red-900/20 to-red-950/20 border border-red-800/30 rounded-lg p-3 hover:border-red-700 transition-all group">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-base font-bold text-white mb-0.5">NODE1 Hetzner GEX44</h3>
<p className="text-[10px] text-slate-500 leading-tight">
Intel i5-13500 (14c) RTX 4000 Ada 20GB 62GB RAM 1.7TB NVMe
</p>
</div>
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
</div>
{/* Індикатори метрик - TODO: підключити Prometheus NODE1 */}
<div className="grid grid-cols-4 gap-2 mb-2">
<div className="text-center">
<div className="w-8 h-8 rounded-full bg-slate-500/20 border-2 border-slate-500 mx-auto mb-1 flex items-center justify-center">
<span className="text-[10px] font-bold text-slate-400">N/A</span>
</div>
<span className="text-[9px] text-slate-400">GPU</span>
</div>
<div className="text-center">
<div className="w-8 h-8 rounded-full bg-slate-500/20 border-2 border-slate-500 mx-auto mb-1 flex items-center justify-center">
<span className="text-[10px] font-bold text-slate-400">N/A</span>
</div>
<span className="text-[9px] text-slate-400">CPU</span>
</div>
<div className="text-center">
<div className="w-8 h-8 rounded-full bg-slate-500/20 border-2 border-slate-500 mx-auto mb-1 flex items-center justify-center">
<span className="text-[10px] font-bold text-slate-400">N/A</span>
</div>
<span className="text-[9px] text-slate-400">RAM</span>
</div>
<div className="text-center">
<div className="w-8 h-8 rounded-full bg-slate-500/20 border-2 border-slate-500 mx-auto mb-1 flex items-center justify-center">
<span className="text-[10px] font-bold text-slate-400">N/A</span>
</div>
<span className="text-[9px] text-slate-400">Disk</span>
</div>
</div>
{/* Агенти та моделі */}
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-2 px-2">
<span>🤖 12 агентів</span>
<span>📦 5 моделей</span>
</div>
<Link
to="/nodes/node-1-hetzner-gex44"
className="block w-full text-center py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded transition-colors font-medium"
>
Відкрити кабінет
</Link>
</div>
{/* NODE2 Card - З індикаторами */}
<div className="bg-gradient-to-br from-blue-900/20 to-blue-950/20 border border-blue-800/30 rounded-lg p-3 hover:border-blue-700 transition-all group">
<div className="flex items-start justify-between mb-2">
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-base font-bold text-white">NODE2 MacBook M4 Max</h3>
<span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 text-[9px] rounded">Цей ноут</span>
</div>
<p className="text-[10px] text-slate-500 leading-tight">
Apple M4 Max (16c) M4 Max GPU 40c 64GB RAM 2TB SSD
</p>
</div>
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
</div>
{/* Індикатори метрик - РЕАЛЬНІ ДАНІ */}
<div className="grid grid-cols-4 gap-2 mb-2">
{(() => {
const cpu = node2Metrics?.cpu?.percent || 0;
const ram = node2Metrics?.memory?.percent || 0;
const disk = node2Metrics?.disk?.percent || 0;
const gpu = node2Metrics?.gpu?.percent || 0;
const getCpuColor = (val: number) => val > 80 ? 'red' : val > 60 ? 'orange' : val > 40 ? 'yellow' : 'green';
const getRamColor = (val: number) => val > 85 ? 'red' : val > 70 ? 'orange' : val > 50 ? 'yellow' : 'green';
const getDiskColor = (val: number) => val > 90 ? 'red' : val > 75 ? 'orange' : val > 50 ? 'yellow' : 'green';
const getGpuColor = (val: number) => val > 80 ? 'red' : val > 60 ? 'orange' : val > 40 ? 'yellow' : 'green';
return (
<>
<div className="text-center">
<div className={`w-8 h-8 rounded-full bg-${getGpuColor(gpu)}-500/20 border-2 border-${getGpuColor(gpu)}-500 mx-auto mb-1 flex items-center justify-center`}>
<span className={`text-[10px] font-bold text-${getGpuColor(gpu)}-400`}>{Math.round(gpu)}%</span>
</div>
<span className="text-[9px] text-slate-400">GPU</span>
</div>
<div className="text-center">
<div className={`w-8 h-8 rounded-full bg-${getCpuColor(cpu)}-500/20 border-2 border-${getCpuColor(cpu)}-500 mx-auto mb-1 flex items-center justify-center`}>
<span className={`text-[10px] font-bold text-${getCpuColor(cpu)}-400`}>{Math.round(cpu)}%</span>
</div>
<span className="text-[9px] text-slate-400">CPU</span>
</div>
<div className="text-center">
<div className={`w-8 h-8 rounded-full bg-${getRamColor(ram)}-500/20 border-2 border-${getRamColor(ram)}-500 mx-auto mb-1 flex items-center justify-center`}>
<span className={`text-[10px] font-bold text-${getRamColor(ram)}-400`}>{Math.round(ram)}%</span>
</div>
<span className="text-[9px] text-slate-400">RAM</span>
</div>
<div className="text-center">
<div className={`w-8 h-8 rounded-full bg-${getDiskColor(disk)}-500/20 border-2 border-${getDiskColor(disk)}-500 mx-auto mb-1 flex items-center justify-center`}>
<span className={`text-[10px] font-bold text-${getDiskColor(disk)}-400`}>{Math.round(disk)}%</span>
</div>
<span className="text-[9px] text-slate-400">Disk</span>
</div>
</>
);
})()}
</div>
{/* Агенти та моделі */}
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-2 px-2">
<span>🤖 50 агентів</span>
<span>📦 8 моделей</span>
</div>
<Link
to="/nodes/node-macbook-pro-0e14f673"
className="block w-full text-center py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors font-medium"
>
Відкрити кабінет
</Link>
</div>
</div>
</div>
{/* Quick Actions - Compact */}
<div className="grid grid-cols-3 gap-3">
<Link
to="/nodes"
className="group bg-gradient-to-br from-purple-900/30 to-purple-950/30 border border-purple-800/30 rounded-lg p-4 hover:border-purple-700 transition-all"
>
<Network className="w-8 h-8 text-purple-400 mb-2 group-hover:scale-110 transition-transform" />
<h3 className="text-base font-bold text-white mb-1">Всі ноди</h3>
<p className="text-slate-400 text-xs">
Список нод
</p>
</Link>
<Link
to="/connect"
className="group bg-gradient-to-br from-green-900/30 to-green-950/30 border border-green-800/30 rounded-lg p-4 hover:border-green-700 transition-all"
>
<Zap className="w-8 h-8 text-green-400 mb-2 group-hover:scale-110 transition-transform" />
<h3 className="text-base font-bold text-white mb-1">Підключити</h3>
<p className="text-slate-400 text-xs">
Нова нода
</p>
</Link>
<Link
to="/metrics"
className="group bg-gradient-to-br from-blue-900/30 to-blue-950/30 border border-blue-800/30 rounded-lg p-4 hover:border-blue-700 transition-all"
>
<Activity className="w-8 h-8 text-blue-400 mb-2 group-hover:scale-110 transition-transform" />
<h3 className="text-base font-bold text-white mb-1">Метрики</h3>
<p className="text-slate-400 text-xs">
Статистика
</p>
</Link>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
export default function MetricsPage() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-white">Метрики</h1>
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-8 text-center">
<p className="text-slate-400">
Сторінка з метриками та графіками буде додана незабаром
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Search, Filter, ExternalLink, Wifi, WifiOff } from 'lucide-react';
export default function NodesPage() {
const [nodes, setNodes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
useEffect(() => {
const fetchNodes = async () => {
try {
const res = await fetch('/api/v1/nodes');
const data = await res.json();
// Додаємо NODE1 якщо його немає в API
const registeredNodes = data.nodes || [];
const hasNode1 = registeredNodes.some((n: any) => n.node_id.includes('node-1') || n.node_id.includes('hetzner'));
let allNodes = [...registeredNodes];
// Додаємо NODE1 (Hetzner) якщо не зареєстрована
if (!hasNode1) {
allNodes.unshift({
id: 'node-1-static',
node_id: 'node-1-hetzner-gex44',
node_name: 'Hetzner GEX44 Production (NODE1)',
node_role: 'production',
node_type: 'router',
ip_address: '144.76.224.179',
local_ip: null,
hostname: 'gateway.daarion.city',
status: 'online', // NODE1 працює на 144.76.224.179:8899
last_heartbeat: null,
registered_at: '2025-01-17T00:00:00Z',
updated_at: '2025-01-17T00:00:00Z',
metadata: {
capabilities: {
system: {
hostname: 'gateway.daarion.city',
platform: 'Linux',
architecture: 'x86_64',
cpu_count: 8,
memory_total_gb: 32,
disk_total_gb: 512,
},
services: ['nginx', 'docker'],
features: ['production', 'gateway'],
},
note: 'Static node - not registered via Bootstrap Agent',
},
});
}
setNodes(allNodes);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
fetchNodes();
const interval = setInterval(fetchNodes, 10000);
return () => clearInterval(interval);
}, []);
const filteredNodes = nodes.filter(node => {
const matchesSearch = node.node_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
node.node_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
(node.hostname || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || node.status === statusFilter;
return matchesSearch && matchesStatus;
});
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Завантаження нод...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Ноди мережі DAGI</h1>
<p className="text-slate-400">
Всього нод: {nodes.length} Online: {nodes.filter(n => n.status === 'online').length}
</p>
</div>
<Link
to="/connect"
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
>
+ Підключити ноду
</Link>
</div>
{/* Filters */}
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-xl p-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Пошук по назві, ID або hostname..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:border-purple-500"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-purple-500"
>
<option value="all">Всі статуси</option>
<option value="online">🟢 Online</option>
<option value="offline">🔴 Offline</option>
<option value="maintenance">🟡 Maintenance</option>
</select>
</div>
</div>
{/* Nodes Table */}
{filteredNodes.length === 0 ? (
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-12 text-center">
<p className="text-slate-400 text-lg mb-4">Ноди не знайдено</p>
<Link
to="/connect"
className="inline-block px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
>
Підключити першу ноду
</Link>
</div>
) : (
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl overflow-hidden">
{/* Table Header */}
<div className="border-b border-slate-800">
<div className="grid grid-cols-12 gap-4 p-4 text-sm font-medium text-slate-400">
<div className="col-span-1 text-center">Статус</div>
<div className="col-span-3">Назва / ID</div>
<div className="col-span-2">Роль / Тип</div>
<div className="col-span-2">IP Address</div>
<div className="col-span-2">Ресурси</div>
<div className="col-span-2 text-right">Дії</div>
</div>
</div>
{/* Table Body */}
<div className="divide-y divide-slate-800">
{filteredNodes.map((node) => {
const system = node.metadata?.capabilities?.system;
const isNode2 = node.node_id.includes('macbook') || node.hostname?.includes('MacBook');
return (
<div
key={node.node_id}
className="grid grid-cols-12 gap-4 p-4 hover:bg-slate-800/30 transition-colors"
>
{/* Status */}
<div className="col-span-1 flex items-center justify-center">
{node.status === 'online' ? (
<Wifi className="w-5 h-5 text-green-400" title="Online" />
) : (
<WifiOff className="w-5 h-5 text-red-400" title="Offline" />
)}
</div>
{/* Name / ID */}
<div className="col-span-3">
<div className="font-medium text-white mb-1">
{node.node_name}
{isNode2 && (
<span className="ml-2 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">
Цей ноут
</span>
)}
</div>
<div className="text-sm text-slate-400 font-mono">{node.node_id}</div>
{node.hostname && (
<div className="text-xs text-slate-500 mt-1">{node.hostname}</div>
)}
</div>
{/* Role / Type */}
<div className="col-span-2 flex flex-col justify-center">
<span className={`px-2 py-1 rounded text-xs font-medium w-fit mb-1 ${
node.node_role === 'production'
? 'bg-red-500/20 text-red-400'
: node.node_role === 'development'
? 'bg-blue-500/20 text-blue-400'
: 'bg-green-500/20 text-green-400'
}`}>
{node.node_role}
</span>
<span className="text-xs text-slate-400 capitalize">{node.node_type}</span>
</div>
{/* IP Address */}
<div className="col-span-2 flex flex-col justify-center text-sm">
{node.ip_address && (
<div className="text-white font-mono mb-1">
🌍 {node.ip_address}
</div>
)}
{node.local_ip && (
<div className="text-slate-400 font-mono text-xs">
🏠 {node.local_ip}
</div>
)}
</div>
{/* Resources */}
<div className="col-span-2 flex flex-col justify-center text-sm">
{system && (
<>
<div className="text-white">
💻 {system.cpu_count} cores {system.memory_total_gb}GB RAM
</div>
<div className="text-slate-400 text-xs mt-1">
💾 {Math.round(system.disk_total_gb)}GB {system.platform}
</div>
</>
)}
</div>
{/* Actions */}
<div className="col-span-2 flex items-center justify-end gap-2">
<Link
to={`/nodes/${node.node_id}`}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Кабінет
</Link>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Info Cards with Hardware Specs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* NODE1 Card */}
<div className="bg-gradient-to-br from-purple-900/20 to-purple-950/20 border border-purple-800/30 rounded-xl p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-bold text-white">NODE1 (Hetzner GEX44)</h3>
<span className="w-2.5 h-2.5 rounded-full bg-green-400 animate-pulse"></span>
</div>
<p className="text-slate-400 text-sm mb-4">
Production сервер у датацентрі Hetzner, Німеччина
</p>
{/* Hardware */}
<div className="bg-slate-800/40 rounded-lg p-3 mb-3 space-y-2 text-xs">
<div className="font-semibold text-slate-300 mb-2">🖥 Апаратне забезпечення:</div>
<div className="flex justify-between">
<span className="text-slate-400">CPU:</span>
<span className="text-white">Intel i5-13500 (14 cores)</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">GPU:</span>
<span className="text-green-400 font-medium">RTX 4000 Ada (20GB)</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">RAM:</span>
<span className="text-white">62 GB</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Storage:</span>
<span className="text-white">1.7 TB</span>
</div>
</div>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-slate-400">IP:</span>
<span className="text-white font-mono text-xs">144.76.224.179</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Роль:</span>
<span className="text-red-400 font-medium">Production Router</span>
</div>
</div>
</div>
{/* NODE2 Card */}
<div className="bg-gradient-to-br from-blue-900/20 to-blue-950/20 border border-blue-800/30 rounded-xl p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-bold text-white">NODE2 (MacBook M4 Max)</h3>
<span className="w-2.5 h-2.5 rounded-full bg-green-400 animate-pulse"></span>
</div>
<p className="text-slate-400 text-sm mb-4">
Development ноут для розробки та тестування
</p>
{/* Hardware */}
<div className="bg-slate-800/40 rounded-lg p-3 mb-3 space-y-2 text-xs">
<div className="font-semibold text-slate-300 mb-2">🖥 Апаратне забезпечення:</div>
<div className="flex justify-between">
<span className="text-slate-400">CPU:</span>
<span className="text-white">Apple M4 Max (16 cores)</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">GPU:</span>
<span className="text-green-400 font-medium">M4 Max GPU (40 cores)</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">RAM:</span>
<span className="text-white">64 GB Unified</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Storage:</span>
<span className="text-white">2 TB NVMe</span>
</div>
</div>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-slate-400">IP:</span>
<span className="text-white font-mono text-xs">192.168.1.33</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Роль:</span>
<span className="text-blue-400 font-medium">Development Router</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import { Server, Cpu, Database, Layers } from 'lucide-react';
interface NodeServicesResponse {
nodes: Record<string, Array<{ name: string; type: string; status: string; port?: number }>>;
summary: { total: number; running: number };
}
interface NodeModelsResponse {
nodes: Record<
string,
Array<{ name: string; type: string; status: string; format?: string; node_id: string }>
>;
summary: { total: number; by_type: Record<string, number> };
}
export default function StackPage() {
const [servicesData, setServicesData] = useState<NodeServicesResponse | null>(null);
const [modelsData, setModelsData] = useState<NodeModelsResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStack = async () => {
try {
const [servicesRes, modelsRes] = await Promise.all([
fetch('/api/stack/services').then((res) => res.json()),
fetch('/api/stack/models').then((res) => res.json()),
]);
setServicesData(servicesRes);
setModelsData(modelsRes);
} catch (error) {
console.error('Failed to load stack catalog:', error);
} finally {
setLoading(false);
}
};
fetchStack();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Завантаження каталогу стеку...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white mb-1">Standard Stack</h1>
<p className="text-slate-400 text-sm">
Актуальні сервіси та моделі, розгорнуті на NODE1 та NODE2
</p>
</div>
{/* Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<Server className="w-5 h-5 text-purple-400 mb-2" />
<p className="text-xs text-slate-400">Сервісів</p>
<p className="text-3xl font-bold text-white">{servicesData?.summary.total || 0}</p>
<p className="text-xs text-green-400">
Запущено: {servicesData?.summary.running || 0}
</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<Cpu className="w-5 h-5 text-blue-400 mb-2" />
<p className="text-xs text-slate-400">Моделей</p>
<p className="text-3xl font-bold text-white">{modelsData?.summary.total || 0}</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<Layers className="w-5 h-5 text-emerald-400 mb-2" />
<p className="text-xs text-slate-400">Типи моделей</p>
<div className="text-sm text-slate-300 space-x-3">
{modelsData &&
Object.entries(modelsData.summary.by_type).map(([type, count]) => (
<span key={type}>
{type}: {count}
</span>
))}
</div>
</div>
</div>
{/* Services per node */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<h2 className="text-2xl font-bold text-white mb-4 flex items-center gap-2">
<Server className="w-5 h-5 text-purple-400" />
Сервіси по нодах
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{servicesData &&
Object.entries(servicesData.nodes).map(([nodeId, services]) => (
<div key={nodeId} className="bg-slate-950/50 rounded-xl border border-slate-800 p-4">
<p className="text-sm text-slate-400 mb-3">{nodeId}</p>
<div className="space-y-3">
{services.map((service) => (
<div
key={`${nodeId}-${service.name}`}
className="flex items-center justify-between border border-slate-800 rounded-lg p-3 text-sm"
>
<div>
<p className="text-white font-semibold">{service.name}</p>
<p className="text-slate-500 text-xs">{service.type}</p>
</div>
<div className="text-right">
<p
className={`text-xs px-2 py-1 rounded-full ${
service.status === 'running'
? 'bg-green-500/15 text-green-400'
: service.status === 'unhealthy'
? 'bg-yellow-500/15 text-yellow-300'
: 'bg-red-500/15 text-red-300'
}`}
>
{service.status}
</p>
{service.port && (
<p className="text-[10px] text-slate-500 mt-1">:{service.port}</p>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Models */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<h2 className="text-2xl font-bold text-white mb-4 flex items-center gap-2">
<Database className="w-5 h-5 text-blue-400" />
Моделі
</h2>
{modelsData &&
Object.entries(modelsData.nodes).map(([nodeId, models]) => (
<div key={nodeId} className="mb-8">
<p className="text-sm text-slate-400 mb-3">{nodeId}</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{models.map((model) => (
<div
key={`${nodeId}-${model.name}`}
className="p-4 border border-slate-800 rounded-xl bg-slate-950/40"
>
<p className="text-white font-semibold">{model.name}</p>
<p className="text-xs text-slate-500 mb-2">{model.type}</p>
<p
className={`text-xs inline-block px-2 py-0.5 rounded-full ${
model.status === 'loaded'
? 'bg-green-500/15 text-green-300'
: 'bg-slate-700 text-slate-300'
}`}
>
{model.status}
</p>
<p className="text-[10px] text-slate-500 mt-2">
Format: {model.format || 'gguf'}
</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}