4 Commits

Author SHA1 Message Date
nikita 26323dfd15 super fix
ci-front / build (push) Successful in 2m28s
2026-04-05 10:41:12 +03:00
nikita 7d2f3d0f3a fix 2
ci-front / build (push) Successful in 2m21s
2026-04-05 10:34:33 +03:00
nikita 255fe2eaf3 fix
ci-front / build (push) Successful in 3m18s
2026-04-05 10:14:53 +03:00
nikita 915aa7018a feat: graph 2
ci-front / build (push) Successful in 3m39s
2026-04-05 09:19:39 +03:00
16 changed files with 1101 additions and 266 deletions
+13
View File
@@ -1,11 +1,24 @@
import { useState, useEffect } from "react";
import "@/shared/styles/index.css"; import "@/shared/styles/index.css";
import "primereact/resources/themes/lara-light-cyan/theme.css"; import "primereact/resources/themes/lara-light-cyan/theme.css";
import "primereact/resources/primereact.min.css"; import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css"; import "primeicons/primeicons.css";
import { PrimeReactProvider } from "primereact/api"; import { PrimeReactProvider } from "primereact/api";
import { Routing } from "./providers/routing/routing"; import { Routing } from "./providers/routing/routing";
import { AppLoader } from "./components/AppLoader";
function App() { function App() {
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 1800);
return () => clearTimeout(timer);
}, []);
if (loading) {
return <AppLoader />;
}
return ( return (
<PrimeReactProvider> <PrimeReactProvider>
<Routing /> <Routing />
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useState } from "react";
import { FaMicrochip, FaCode, FaNetworkWired, FaAtom } from "react-icons/fa";
export const AppLoader = () => {
const [progress, setProgress] = useState(0);
const [phase, setPhase] = useState(0);
useEffect(() => {
const phases = [
{ progress: 25, delay: 400 },
{ progress: 50, delay: 300 },
{ progress: 75, delay: 400 },
{ progress: 100, delay: 300 },
];
let timeouts: NodeJS.Timeout[] = [];
let currentDelay = 0;
phases.forEach((p, i) => {
currentDelay += p.delay;
timeouts.push(
setTimeout(() => {
setProgress(p.progress);
setPhase(i);
}, currentDelay),
);
});
return () => timeouts.forEach(clearTimeout);
}, []);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#0a0a0f",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 9999,
overflow: "hidden",
}}
>
{/* Background grid effect */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
animation: "gridMove 20s linear infinite",
}}
/>
{/* Glowing orbs */}
<div
style={{
position: "absolute",
width: "300px",
height: "300px",
borderRadius: "50%",
background:
"radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)",
filter: "blur(40px)",
animation: "orbFloat 6s ease-in-out infinite",
top: "20%",
left: "30%",
}}
/>
<div
style={{
position: "absolute",
width: "250px",
height: "250px",
borderRadius: "50%",
background:
"radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)",
filter: "blur(40px)",
animation: "orbFloat 8s ease-in-out infinite reverse",
bottom: "20%",
right: "30%",
}}
/>
{/* Main content */}
<div style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
{/* Logo with animation */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "16px",
marginBottom: "40px",
}}
>
<div
style={{
animation: "logoSpin 3s ease-in-out infinite",
}}
>
<FaAtom size={48} style={{ color: "#3b82f6" }} />
</div>
<h1
style={{
fontSize: "42px",
fontWeight: 800,
background:
"linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "4px",
animation: "titleGlow 2s ease-in-out infinite",
}}
>
HellreigN
</h1>
</div>
{/* Loading icons animation */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "24px",
marginBottom: "40px",
}}
>
{[
{ icon: FaMicrochip, delay: "0s" },
{ icon: FaNetworkWired, delay: "0.2s" },
{ icon: FaCode, delay: "0.4s" },
].map(({ icon: Icon, delay }, i) => (
<div
key={i}
style={{
width: "50px",
height: "50px",
borderRadius: "12px",
border: `2px solid ${
phase >= i
? "rgba(59, 130, 246, 0.6)"
: "rgba(255,255,255,0.1)"
}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor:
phase >= i ? "rgba(59, 130, 246, 0.1)" : "transparent",
animation: `iconPop 0.5s ease-out ${delay} both`,
transition: "all 0.3s ease",
}}
>
<Icon
size={22}
style={{
color: phase >= i ? "#3b82f6" : "#555",
transition: "color 0.3s ease",
}}
/>
</div>
))}
</div>
{/* Progress bar */}
<div
style={{
width: "320px",
height: "4px",
backgroundColor: "rgba(255,255,255,0.1)",
borderRadius: "2px",
overflow: "hidden",
marginBottom: "16px",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background:
"linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
borderRadius: "2px",
transition: "width 0.4s ease",
boxShadow: "0 0 20px rgba(59, 130, 246, 0.5)",
}}
/>
</div>
{/* Status text */}
<div
style={{
fontSize: "13px",
color: "rgba(255,255,255,0.5)",
fontFamily: "monospace",
letterSpacing: "2px",
}}
>
{phase === 0 && "INITIALIZING CORE..."}
{phase === 1 && "LOADING AGENTS..."}
{phase === 2 && "ESTABLISHING CONNECTIONS..."}
{phase === 3 && "READY"}
</div>
</div>
{/* CSS Animations */}
<style>{`
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(40px, 40px); }
}
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, -30px) scale(1.1); }
}
@keyframes logoSpin {
0%, 100% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(-10deg) scale(1.05); }
75% { transform: rotate(10deg) scale(1.05); }
}
@keyframes titleGlow {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.3); }
}
@keyframes iconPop {
0% { transform: scale(0.5) translateY(10px); opacity: 0; }
100% { transform: scale(1) translateY(0); opacity: 1; }
}
`}</style>
</div>
);
};
@@ -42,14 +42,31 @@ export const Navigation: React.FC<NavigationProps> = ({
const currentTheme = getCurrentTheme(); const currentTheme = getCurrentTheme();
const navItems = [ const navItems = [
{ path: "/templates", label: "Шаблоны", icon: FaCode }, { path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
{ path: "/add-agents", label: "Деплой", icon: FaRocket }, {
{ path: "/registration", label: "Регистрация", icon: FaKey }, path: "/add-agents",
{ path: "/logs", label: "Логи", icon: FaFileAlt }, label: "Деплой",
icon: FaRocket,
requireManageAgent: true,
},
{
path: "/registration",
label: "Регистрация",
icon: FaKey,
requireManageAgent: true,
},
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
]; ];
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
// Filter nav items based on user permissions
const filteredNavItems = navItems.filter((item) => {
if (item.requireView && !user?.permission_view) return false;
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
return true;
});
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if ( if (
@@ -77,12 +94,7 @@ export const Navigation: React.FC<NavigationProps> = ({
const renderNavItems = (showLabels: boolean, iconSize: number) => ( const renderNavItems = (showLabels: boolean, iconSize: number) => (
<div className="flex items-center gap-1 whitespace-nowrap"> <div className="flex items-center gap-1 whitespace-nowrap">
{navItems {filteredNavItems.map((item) => {
.filter((item) => {
if ((item as any).adminOnly && !user?.permission_admin) return false;
return true;
})
.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = isActive(item.path); const active = isActive(item.path);
return ( return (
@@ -375,14 +387,31 @@ export const BottomNav: React.FC = () => {
const { user } = useAuthStore(); const { user } = useAuthStore();
const navItems = [ const navItems = [
{ path: "/templates", label: "Шаблоны", icon: FaCode }, { path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
{ path: "/add-agents", label: "Деплой", icon: FaRocket }, {
{ path: "/registration", label: "Регистрация", icon: FaKey }, path: "/add-agents",
{ path: "/logs", label: "Логи", icon: FaFileAlt }, label: "Деплой",
icon: FaRocket,
requireManageAgent: true,
},
{
path: "/registration",
label: "Регистрация",
icon: FaKey,
requireManageAgent: true,
},
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
]; ];
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
// Filter nav items based on user permissions
const filteredNavItems = navItems.filter((item) => {
if (item.requireView && !user?.permission_view) return false;
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
return true;
});
return ( return (
<div <div
className="flex-shrink-0 border-t" className="flex-shrink-0 border-t"
@@ -392,13 +421,7 @@ export const BottomNav: React.FC = () => {
}} }}
> >
<div className="flex items-center justify-around px-2 py-2"> <div className="flex items-center justify-around px-2 py-2">
{navItems {filteredNavItems.map((item) => {
.filter((item) => {
if ((item as any).adminOnly && !user?.permission_admin)
return false;
return true;
})
.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = isActive(item.path); const active = isActive(item.path);
return ( return (
@@ -408,9 +431,7 @@ export const BottomNav: React.FC = () => {
className="flex items-center justify-center p-3 rounded-lg transition-all" className="flex items-center justify-center p-3 rounded-lg transition-all"
style={{ style={{
backgroundColor: active ? "var(--accent)" : "transparent", backgroundColor: active ? "var(--accent)" : "transparent",
color: active color: active ? "var(--accent-text)" : "var(--text-secondary)",
? "var(--accent-text)"
: "var(--text-secondary)",
}} }}
title={item.label} title={item.label}
> >
@@ -16,6 +16,7 @@ import { useNavigate } from "react-router-dom";
import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useAuthStore } from "@/modules/auth/store/useAuthStore"; import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { Graph, type GraphData } from "@/modules/graph"; import { Graph, type GraphData } from "@/modules/graph";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import { adminApi } from "@/modules/admin/api/admin.api"; import { adminApi } from "@/modules/admin/api/admin.api";
interface SidebarProps { interface SidebarProps {
@@ -82,38 +83,88 @@ export const Sidebar: React.FC<SidebarProps> = ({
); );
}, [agents, searchQuery]); }, [agents, searchQuery]);
const graphData: GraphData = useMemo(() => { const [graphData, setGraphData] = useState<GraphData>({
nodes: [],
links: [],
});
useEffect(() => {
const fetchGraph = () => {
agentApiService
.getGraph()
.then((apiData) => {
const nodes: any[] = []; const nodes: any[] = [];
const links: any[] = []; const links: any[] = [];
// Build a map of service statuses from agents
const serviceStatusMap = new Map<string, "up" | "down">();
agents.forEach((agent) => { agents.forEach((agent) => {
nodes.push({ const services = agent.services || [];
id: agent.label, services.forEach((svc: string) => {
name: agent.label, const parts = svc.split(":");
type: "agent" as const, const svcName = parts[0];
val: 8, const status = parts[1] === "down" ? "down" : "up";
description: `Агент: ${agent.label}`, serviceStatusMap.set(`${agent.label}-${svcName}`, status);
});
}); });
agent.services.forEach((service) => { Object.entries(apiData.nodes || {}).forEach(
const serviceId = `${agent.label}-${service}`; ([agentLabel, agentNode]: [string, any]) => {
nodes.push({
id: agentLabel,
name: agentLabel,
type: "agent" as const,
val: 8,
description: `Агент: ${agentLabel}`,
});
const services = agentNode?.services || {};
Object.entries(services).forEach(
([serviceName, serviceNode]: [string, any]) => {
const serviceId = `${agentLabel}-${serviceName}`;
const status = serviceStatusMap.get(serviceId) || "up";
nodes.push({ nodes.push({
id: serviceId, id: serviceId,
name: service, name: serviceName,
type: "service" as const, type: "service" as const,
val: 12, val: 12,
description: `Сервис: ${service}`, description: `Сервис: ${serviceName}`,
status,
}); });
links.push({ links.push({
source: agent.label, source: agentLabel,
target: serviceId, target: serviceId,
type: "hosts", type: "hosts",
}); });
});
});
return { nodes, links }; const dependencies = serviceNode?.dependencies || [];
dependencies.forEach((dep: any) => {
const targetName = dep?.target?.name;
if (targetName) {
links.push({
source: serviceId,
target: `${agentLabel}-${targetName}`,
type: dep.condition || "dependency",
});
}
});
},
);
},
);
setGraphData({ nodes, links });
})
.catch((e) => {
console.error("Failed to fetch graph:", e);
});
};
fetchGraph();
const interval = setInterval(fetchGraph, 30000);
return () => clearInterval(interval);
}, [agents]); }, [agents]);
const handleCopyToken = () => { const handleCopyToken = () => {
@@ -373,6 +424,11 @@ export const Sidebar: React.FC<SidebarProps> = ({
style={{ borderColor: "var(--border)" }} style={{ borderColor: "var(--border)" }}
> >
{agent.services.map((service) => { {agent.services.map((service) => {
// Parse "serviceName:up" or "serviceName:down"
const parts = service.split(":");
const serviceName = parts[0];
const isDown = parts[1] === "down";
return ( return (
<div <div
key={service} key={service}
@@ -380,25 +436,31 @@ export const Sidebar: React.FC<SidebarProps> = ({
> >
<span <span
className="text-xs" className="text-xs"
style={{ color: "var(--text-secondary)" }} style={{
color: isDown
? "#ef4444"
: "var(--text-secondary)",
}}
> >
{service} {serviceName}
</span> </span>
{/* Status indicator */} {/* Status indicator */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
className="w-1.5 h-1.5 rounded-full flex-shrink-0" className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ style={{
backgroundColor: "#4ade80", backgroundColor: isDown
? "#ef4444"
: "#4ade80",
}} }}
/> />
<span <span
className="text-[10px] font-medium" className="text-[10px] font-medium"
style={{ style={{
color: "#4ade80", color: isDown ? "#ef4444" : "#4ade80",
}} }}
> >
run {isDown ? "down" : "run"}
</span> </span>
</div> </div>
</div> </div>
@@ -1,12 +1,42 @@
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { interface ProtectedRouteProps {
const { isAuthenticated } = useAuthStore(); children: React.ReactNode;
requireView?: boolean;
requireManageAgent?: boolean;
requireAdmin?: boolean;
fallbackPath?: string;
}
// if (!isAuthenticated) { export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
// return <Navigate to="/auth" replace />; children,
// } requireView = false,
requireManageAgent = false,
requireAdmin = false,
fallbackPath = "/",
}) => {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated && user?.token) {
// User is authenticated based on token
}
if (!user) {
return <Navigate to="/auth" replace />;
}
if (requireView && !user.permission_view) {
return <Navigate to={fallbackPath} replace />;
}
if (requireManageAgent && !user.permission_manage_agent) {
return <Navigate to={fallbackPath} replace />;
}
if (requireAdmin && !user.permission_admin) {
return <Navigate to={fallbackPath} replace />;
}
return <>{children}</>; return <>{children}</>;
}; };
+75 -10
View File
@@ -15,6 +15,7 @@ import { LogsPage } from "@/pages/logs.page";
import { GraphsPage } from "@/pages/graphs.page"; import { GraphsPage } from "@/pages/graphs.page";
import { DashboardPage } from "@/pages/dashboard.page"; import { DashboardPage } from "@/pages/dashboard.page";
import { AgentDashboardPage } from "@/pages/agent-dashboard.page"; import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
import { ProtectedRoute } from "./helper/protected.route";
export const mockGraphData: GraphData = { export const mockGraphData: GraphData = {
nodes: [ nodes: [
@@ -122,18 +123,82 @@ export const Routing = () => {
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
<Route path="/" element={<TemplatesPage />} /> {/* Routes requiring 'view' permission */}
<Route path="/add-agents" element={<AddAgentsPage />} /> <Route
<Route path="/registration" element={<RegistrationTokenPage />} /> path="/"
<Route path="/logs" element={<LogsPage />} /> element={
<Route path="/admin" element={<AdminPage />} /> <ProtectedRoute requireView>
<Route path="/IDE" element={<IDEPage />} /> <TemplatesPage />
<Route path="/templates" element={<TemplatesPage />} /> </ProtectedRoute>
<Route path="/graphs" element={<GraphsPage />} /> }
{/* <Route path="/dashboard" element={<DashboardPage />} /> */} />
<Route
path="/logs"
element={
<ProtectedRoute requireView>
<LogsPage />
</ProtectedRoute>
}
/>
<Route
path="/graphs"
element={
<ProtectedRoute requireView>
<GraphsPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/dashboard/:agentLabel" path="/dashboard/:agentLabel"
element={<AgentDashboardPage />} element={
<ProtectedRoute requireView>
<AgentDashboardPage />
</ProtectedRoute>
}
/>
{/* Routes requiring 'manage_agent' permission */}
<Route
path="/add-agents"
element={
<ProtectedRoute requireManageAgent>
<AddAgentsPage />
</ProtectedRoute>
}
/>
<Route
path="/registration"
element={
<ProtectedRoute requireManageAgent>
<RegistrationTokenPage />
</ProtectedRoute>
}
/>
<Route
path="/templates"
element={
<ProtectedRoute requireView>
<TemplatesPage />
</ProtectedRoute>
}
/>
<Route
path="/IDE"
element={
<ProtectedRoute requireView>
<IDEPage />
</ProtectedRoute>
}
/>
{/* Admin route requiring 'admin' permission */}
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminPage />
</ProtectedRoute>
}
/> />
</Route> </Route>
@@ -15,6 +15,7 @@ import type {
DeployResponse, DeployResponse,
SystemMetrics, SystemMetrics,
} from "../types/agent.types"; } from "../types/agent.types";
import type { GraphApiResponse } from "@/modules/graph/types";
class AgentApiService { class AgentApiService {
private readonly basePath = "/agents"; private readonly basePath = "/agents";
@@ -170,6 +171,11 @@ class AgentApiService {
); );
return Array.isArray(response.data) ? response.data : []; return Array.isArray(response.data) ? response.data : [];
} }
async getGraph(): Promise<GraphApiResponse> {
const response = await apiClient.get<GraphApiResponse>("/graph");
return response.data;
}
} }
export const agentApiService = new AgentApiService(); export const agentApiService = new AgentApiService();
-7
View File
@@ -51,12 +51,6 @@ export const Graph: React.FC<GraphProps> = ({
setContextMenu({ x: event.clientX, y: event.clientY, node, link: null }); setContextMenu({ x: event.clientX, y: event.clientY, node, link: null });
}; };
const handleLinkRightClick = (link: GraphLink, event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ x: event.clientX, y: event.clientY, node: null, link });
};
if (!data || data.nodes.length === 0) { if (!data || data.nodes.length === 0) {
return ( return (
<div className="bg-gray-900 rounded-xl shadow-lg p-6"> <div className="bg-gray-900 rounded-xl shadow-lg p-6">
@@ -86,7 +80,6 @@ export const Graph: React.FC<GraphProps> = ({
ref={fgRef} ref={fgRef}
data={data} data={data}
onNodeRightClick={handleNodeRightClick} onNodeRightClick={handleNodeRightClick}
onLinkRightClick={handleLinkRightClick}
/> />
<GraphContextMenu <GraphContextMenu
@@ -13,11 +13,10 @@ import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
interface ForceGraphProps { interface ForceGraphProps {
data: GraphData; data: GraphData;
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void; onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
onLinkRightClick: (link: GraphLink, event: MouseEvent) => void;
} }
export const ForceGraph = forwardRef<any, ForceGraphProps>( export const ForceGraph = forwardRef<any, ForceGraphProps>(
({ data, onNodeRightClick, onLinkRightClick }, ref) => { ({ data, onNodeRightClick }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 480, height: 600 }); const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
@@ -87,9 +86,47 @@ export const ForceGraph = forwardRef<any, ForceGraphProps>(
}; };
const getNodeColor = (node: GraphNode) => { const getNodeColor = (node: GraphNode) => {
if (highlightNodes.has(node.id)) return "#fbbf24";
if (selectedNode?.id === node.id && isLinkMode) return "#f97316"; if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
if (node.type === "service" && node.status === "down") {
// Проверяем, есть ли зависимости этого сервиса, которые тоже упали
const hasDownDependency = data.links.some((link) => {
const sourceId =
typeof link.source === "object"
? (link.source as any).id
: link.source;
const targetId =
typeof link.target === "object"
? (link.target as any).id
: link.target;
if (sourceId !== node.id) return false;
const isDependency =
link.type === "dependency" || link.type === "started";
const targetIsDown = data.nodes.some(
(n) => n.id === targetId && n.status === "down",
);
return isDependency && targetIsDown;
});
// Если есть упавшая зависимость — не подсвечиваем красным
if (hasDownDependency) return "#3b82f6";
return "#ef4444";
}
if (node.type === "agent") {
// Проверяем, есть ли у агента хотя бы один упавший сервис
const hasDownService = data.nodes.some(
(n) =>
n.type === "service" &&
n.status === "down" &&
n.id.startsWith(`${node.id}-`),
);
if (hasDownService) return "#ef4444";
}
switch (node.type) { switch (node.type) {
case "service": case "service":
return "#3b82f6"; return "#3b82f6";
@@ -176,7 +213,6 @@ export const ForceGraph = forwardRef<any, ForceGraphProps>(
linkDirectionalParticles={0} linkDirectionalParticles={0}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
onNodeRightClick={onNodeRightClick} onNodeRightClick={onNodeRightClick}
onLinkRightClick={onLinkRightClick}
onNodeHover={handleNodeHover} onNodeHover={handleNodeHover}
cooldownTicks={50} cooldownTicks={50}
cooldownTime={2000} cooldownTime={2000}
@@ -1,11 +1,6 @@
import React from "react"; import React from "react";
import { FiLink, FiTrash2, FiMinusCircle } from "react-icons/fi"; import { FiLink, FiTrash2 } from "react-icons/fi";
import type { import type { ContextMenuState, GraphNode, GraphData } from "../types";
ContextMenuState,
GraphNode,
GraphLink,
GraphData,
} from "../types";
import { useGraphStore } from "../store/useGraphStore"; import { useGraphStore } from "../store/useGraphStore";
interface GraphContextMenuProps { interface GraphContextMenuProps {
@@ -20,7 +15,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
onClose, onClose,
}) => { }) => {
const removeNode = useGraphStore((s) => s.removeNode); const removeNode = useGraphStore((s) => s.removeNode);
const removeLink = useGraphStore((s) => s.removeLink);
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode); const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
const setSelectedNode = useGraphStore((s) => s.setSelectedNode); const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
@@ -31,11 +25,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
onClose(); onClose();
}; };
const handleDeleteLink = (link: GraphLink) => {
removeLink(link);
onClose();
};
const handleCreateLink = (node: GraphNode) => { const handleCreateLink = (node: GraphNode) => {
toggleLinkMode(); toggleLinkMode();
setSelectedNode(node); setSelectedNode(node);
@@ -92,40 +81,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
</button> </button>
</> </>
)} )}
{menu.link && (
<>
<div
className="px-3 py-1 text-xs border-b"
style={{
color: "var(--text-secondary)",
borderColor: "var(--border)",
}}
>
Связь:{" "}
{typeof menu.link.source === "string"
? menu.link.source
: (menu.link.source as any).name ||
(menu.link.source as any).id}{" "}
{" "}
{typeof menu.link.target === "string"
? menu.link.target
: (menu.link.target as any).name || (menu.link.target as any).id}
</div>
<button
onClick={() => handleDeleteLink(menu.link!)}
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
style={{ color: "#f87171" }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(248,113,113,0.1)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "transparent")
}
>
<FiMinusCircle size={14} /> Удалить связь
</button>
</>
)}
</div> </div>
); );
}; };
+23
View File
@@ -6,6 +6,7 @@ export interface GraphNode {
description?: string; description?: string;
x?: number; x?: number;
y?: number; y?: number;
status?: "up" | "down";
} }
export interface GraphLink { export interface GraphLink {
@@ -25,3 +26,25 @@ export interface ContextMenuState {
node: GraphNode | null; node: GraphNode | null;
link: GraphLink | null; link: GraphLink | null;
} }
// API response types for GET /graph
export interface GraphDependencyTarget {
name: string;
}
export interface GraphDependency {
condition: string;
target: GraphDependencyTarget;
}
export interface GraphServiceNode {
dependencies: GraphDependency[];
}
export interface GraphAgentNode {
services: Record<string, GraphServiceNode>;
}
export interface GraphApiResponse {
nodes: Record<string, GraphAgentNode>;
}
@@ -42,6 +42,12 @@ export interface RunScriptResponse {
wait_url: string; wait_url: string;
} }
export interface CreateInterpreterPayload {
argv: string[];
label: string;
name: string;
}
export interface JobWaitResponse { export interface JobWaitResponse {
command: string[]; command: string[];
id: number; id: number;
@@ -119,4 +125,14 @@ export const scriptsApi = {
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`); const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
return res.data; return res.data;
}, },
createInterpreter: async (
payload: CreateInterpreterPayload,
): Promise<Interpreter> => {
const res = await apiClient.post<Interpreter>(
"/scripts/interpreters",
payload,
);
return res.data;
},
}; };
@@ -0,0 +1,276 @@
import React, { useState, useRef } from "react";
import { MdClose, MdAdd } from "react-icons/md";
import { scriptsApi } from "../api/scripts.api";
import type { CreateInterpreterPayload } from "../api/scripts.api";
interface AddInterpreterModalProps {
onClose: () => void;
onSuccess: () => void;
}
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
onClose,
onSuccess,
}) => {
const [name, setName] = useState("");
const [label, setLabel] = useState("");
const [argv, setArgv] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
React.useEffect(() => {
nameRef.current?.focus();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !label.trim()) {
setError("Name and Label are required");
return;
}
setLoading(true);
setError(null);
try {
const payload: CreateInterpreterPayload = {
name: name.trim(),
label: label.trim(),
argv: argv
.split(" ")
.map((s) => s.trim())
.filter(Boolean),
};
await scriptsApi.createInterpreter(payload);
onSuccess();
onClose();
} catch (e: any) {
console.error("Failed to create interpreter:", e);
setError(e?.response?.data?.detail || "Failed to create interpreter");
} finally {
setLoading(false);
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
width: "420px",
maxWidth: "90vw",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
}}
>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: "14px",
fontWeight: 600,
}}
>
Add Interpreter
</h3>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "var(--text-secondary)",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
}}
>
<MdClose size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
{/* Name */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Name <span style={{ color: "#f44747" }}>*</span>
</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Python, Node.js, etc."
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Label */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Label <span style={{ color: "#f44747" }}>*</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="python3, node, etc."
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Args */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Arguments <span style={{ color: "#858585" }}>(optional)</span>
</label>
<input
type="text"
value={argv}
onChange={(e) => setArgv(e.target.value)}
placeholder="-u -O (space separated)"
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Error */}
{error && (
<div
style={{
padding: "8px 12px",
backgroundColor: "rgba(244, 71, 71, 0.1)",
border: "1px solid #f44747",
borderRadius: "4px",
color: "#f44747",
fontSize: "12px",
marginBottom: "16px",
}}
>
{error}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "10px",
backgroundColor: loading ? "#555" : "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
fontSize: "13px",
fontWeight: 500,
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
}}
>
{loading ? (
<>
<span
style={{
display: "inline-block",
animation: "spin 1s linear infinite",
}}
>
</span>
Creating...
</>
) : (
<>
<MdAdd size={16} />
Add Interpreter
</>
)}
</button>
</form>
</div>
</div>
);
};
@@ -392,55 +392,6 @@ export const AgentDashboardPage = () => {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Disk History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
Disk Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={diskHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#f59e0b"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div> </div>
</div> </div>
); );
+105 -14
View File
@@ -1,56 +1,147 @@
import { useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { import {
Graph, Graph,
type GraphData, type GraphData,
type GraphNode, type GraphNode,
type GraphLink, type GraphLink,
} from "@/modules/graph"; } from "@/modules/graph";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import { FaSpinner } from "react-icons/fa";
import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAgentStore } from "@/app/providers/layout/store/agent.store";
const buildGraphFromAgents = (): GraphData => { const buildGraphFromApi = (apiData: any, agents: any[]): GraphData => {
const agents = useAgentStore.getState().agents;
const nodes: GraphNode[] = []; const nodes: GraphNode[] = [];
const links: GraphLink[] = []; const links: GraphLink[] = [];
// Build a map of service statuses from agents
const serviceStatusMap = new Map<string, "up" | "down">();
agents.forEach((agent) => { agents.forEach((agent) => {
const services = agent.services || [];
services.forEach((svc: string) => {
// Parse "serviceName:up" or "serviceName:down"
const parts = svc.split(":");
const svcName = parts[0];
const status = parts[1] === "down" ? "down" : "up";
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
});
});
if (!apiData?.nodes) return { nodes, links };
Object.entries(apiData.nodes).forEach(
([agentLabel, agentNode]: [string, any]) => {
// Агент как узел // Агент как узел
nodes.push({ nodes.push({
id: agent.label, id: agentLabel,
name: agent.label, name: agentLabel,
type: "agent", type: "agent",
val: 8, val: 8,
description: `Агент: ${agent.label}`, description: `Агент: ${agentLabel}`,
}); });
// Сервисы агента как узлы + связи // Сервисы агента
agent.services.forEach((service) => { const services = agentNode?.services || {};
const serviceId = `${agent.label}-${service}`; Object.entries(services).forEach(
([serviceName, serviceNode]: [string, any]) => {
const serviceId = `${agentLabel}-${serviceName}`;
const status = serviceStatusMap.get(serviceId) || "up";
nodes.push({ nodes.push({
id: serviceId, id: serviceId,
name: service, name: serviceName,
type: "service", type: "service",
val: 12, val: 12,
description: `Сервис: ${service}`, description: `Сервис: ${serviceName}`,
status,
}); });
// Связь агент → сервис
links.push({ links.push({
source: agent.label, source: agentLabel,
target: serviceId, target: serviceId,
type: "hosts", type: "hosts",
}); });
// Зависимости между сервисами
const dependencies = serviceNode?.dependencies || [];
dependencies.forEach((dep: any) => {
const targetServiceName = dep?.target?.name;
if (targetServiceName) {
const targetServiceId = `${agentLabel}-${targetServiceName}`;
links.push({
source: serviceId,
target: targetServiceId,
type: dep.condition || "dependency",
}); });
}
}); });
},
);
},
);
return { nodes, links }; return { nodes, links };
}; };
export const GraphsPage = () => { export const GraphsPage = () => {
const agents = useAgentStore((s) => s.agents); const agents = useAgentStore((s) => s.agents);
const [graphData, setGraphData] = useState<GraphData>({
nodes: [],
links: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const graphData: GraphData = useMemo(() => { useEffect(() => {
return buildGraphFromAgents(); const fetchGraph = async () => {
setLoading(true);
setError(null);
try {
const apiData = await agentApiService.getGraph();
const data = buildGraphFromApi(apiData, agents);
setGraphData(data);
} catch (e) {
console.error("Failed to fetch graph:", e);
setError(e instanceof Error ? e.message : "Failed to load graph");
setGraphData({ nodes: [], links: [] });
} finally {
setLoading(false);
}
};
fetchGraph();
const interval = setInterval(fetchGraph, 30000);
return () => clearInterval(interval);
}, [agents]); }, [agents]);
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FaSpinner
className="animate-spin mx-auto mb-4"
size={32}
style={{ color: "var(--accent)" }}
/>
<p style={{ color: "var(--text-secondary)" }}>Загрузка графа...</p>
</div>
</div>
);
}
if (error && graphData.nodes.length === 0) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p style={{ color: "var(--error-text)", marginBottom: "12px" }}>
{error}
</p>
</div>
</div>
);
}
return ( return (
<div className="h-full"> <div className="h-full">
<Graph initialData={graphData} /> <Graph initialData={graphData} />
+52 -2
View File
@@ -1,11 +1,14 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FiEdit3 } from "react-icons/fi"; import { FiEdit3 } from "react-icons/fi";
import { MdAdd } from "react-icons/md";
import { FaSpinner } from "react-icons/fa"; import { FaSpinner } from "react-icons/fa";
import { FilePicker } from "../modules/ide"; import { FilePicker } from "../modules/ide";
import { RunScriptModal } from "../modules/ide/components/RunScriptModal"; import { RunScriptModal } from "../modules/ide/components/RunScriptModal";
import { AddInterpreterModal } from "../modules/ide/components/AddInterpreterModal";
import type { FileNode } from "../modules/ide"; import type { FileNode } from "../modules/ide";
import { scriptsApi } from "../modules/ide/api/scripts.api"; import { scriptsApi } from "../modules/ide/api/scripts.api";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
const convertTreeToFileNode = (data: any[]): FileNode => { const convertTreeToFileNode = (data: any[]): FileNode => {
const convertItem = (item: any): FileNode => { const convertItem = (item: any): FileNode => {
@@ -41,14 +44,18 @@ const convertTreeToFileNode = (data: any[]): FileNode => {
export const TemplatesPage = () => { export const TemplatesPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore();
const canManageAgent = user?.permission_manage_agent;
const [files, setFiles] = useState<FileNode | null>(null); const [files, setFiles] = useState<FileNode | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [runModal, setRunModal] = useState<{ const [runModal, setRunModal] = useState<{
scriptPath: string; scriptPath: string;
scriptId: number; scriptId: number;
} | null>(null); } | null>(null);
const [showAddInterpreter, setShowAddInterpreter] = useState(false);
useEffect(() => { const reloadTree = () => {
setLoading(true);
scriptsApi scriptsApi
.getTree() .getTree()
.then((data) => { .then((data) => {
@@ -59,6 +66,10 @@ export const TemplatesPage = () => {
setFiles({ name: "templates", type: "folder", children: [] }); setFiles({ name: "templates", type: "folder", children: [] });
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
};
useEffect(() => {
reloadTree();
}, []); }, []);
const handleRun = (path: string, id?: number) => { const handleRun = (path: string, id?: number) => {
@@ -85,12 +96,42 @@ export const TemplatesPage = () => {
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
padding: "12px 16px", padding: "12px 16px",
gap: "12px",
borderBottom: "1px solid var(--border)", borderBottom: "1px solid var(--border)",
backgroundColor: "var(--card-bg)", backgroundColor: "var(--card-bg)",
flexShrink: 0, flexShrink: 0,
}} }}
> >
{/* Open in Editor button */} {/* Add Interpreter button */}
<button
onClick={() => setShowAddInterpreter(true)}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 14px",
backgroundColor: "#238636",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2ea043";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#238636";
}}
>
<MdAdd size={14} />
Add Interpreter
</button>
{/* Open in Editor button — только с правом manage_agent */}
{canManageAgent && (
<button <button
onClick={() => navigate("/ide")} onClick={() => navigate("/ide")}
style={{ style={{
@@ -117,6 +158,7 @@ export const TemplatesPage = () => {
<FiEdit3 size={14} /> <FiEdit3 size={14} />
Open Editor Open Editor
</button> </button>
)}
</div> </div>
{/* File Picker (terminal встроен внутрь) */} {/* File Picker (terminal встроен внутрь) */}
@@ -175,6 +217,14 @@ export const TemplatesPage = () => {
onClose={() => setRunModal(null)} onClose={() => setRunModal(null)}
/> />
)} )}
{/* Add Interpreter Modal */}
{showAddInterpreter && (
<AddInterpreterModal
onClose={() => setShowAddInterpreter(false)}
onSuccess={reloadTree}
/>
)}
</div> </div>
); );
}; };