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:
31
node-network-app/src/App.tsx
Normal file
31
node-network-app/src/App.tsx
Normal 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;
|
||||
|
||||
101
node-network-app/src/components/Layout.tsx
Normal file
101
node-network-app/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
237
node-network-app/src/components/MonitorChat.tsx
Normal file
237
node-network-app/src/components/MonitorChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
24
node-network-app/src/index.css
Normal file
24
node-network-app/src/index.css
Normal 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;
|
||||
}
|
||||
|
||||
14
node-network-app/src/main.tsx
Normal file
14
node-network-app/src/main.tsx
Normal 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>
|
||||
);
|
||||
|
||||
249
node-network-app/src/pages/AgentDetailPage.tsx
Normal file
249
node-network-app/src/pages/AgentDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
231
node-network-app/src/pages/AgentsPage.tsx
Normal file
231
node-network-app/src/pages/AgentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
167
node-network-app/src/pages/ConnectNodePage.tsx
Normal file
167
node-network-app/src/pages/ConnectNodePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
555
node-network-app/src/pages/Dashboard.tsx
Normal file
555
node-network-app/src/pages/Dashboard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
node-network-app/src/pages/MetricsPage.tsx
Normal file
14
node-network-app/src/pages/MetricsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1033
node-network-app/src/pages/NodeDetailPage.tsx
Normal file
1033
node-network-app/src/pages/NodeDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
342
node-network-app/src/pages/NodesPage.tsx
Normal file
342
node-network-app/src/pages/NodesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
node-network-app/src/pages/StackPage.tsx
Normal file
174
node-network-app/src/pages/StackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user