14 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
nikita c175461634 fix: adaptive #4
ci-front / build (push) Successful in 2m29s
2026-04-05 08:27:14 +03:00
nikita 5b90447984 fix: adaptive #3 2026-04-05 08:23:50 +03:00
nikita 9f6defd25c fix: adaptive #2
ci-front / build (push) Successful in 2m28s
2026-04-05 08:04:42 +03:00
nikita 5f6c4303db fix: adaptive #1
ci-front / build (push) Successful in 2m39s
2026-04-05 07:41:34 +03:00
nikita 17d4770de6 feat: dashboard
ci-front / build (push) Successful in 2m18s
2026-04-05 07:17:33 +03:00
nikita 337e5891f3 feat: update tamplates
ci-front / build (push) Successful in 2m18s
2026-04-05 07:07:14 +03:00
nikita 2bc3da21fd feat: launch scripts
ci-front / build (push) Successful in 2m19s
2026-04-05 06:54:33 +03:00
nikita d6512d6c97 feat: update button run scripts
ci-front / build (push) Successful in 2m35s
2026-04-05 04:57:16 +03:00
nikita f14490c076 feat: rename
ci-front / build (push) Successful in 2m24s
2026-04-05 03:59:08 +03:00
nikita 178c3b53f7 feat: remove folders & create folder 2026-04-05 03:28:31 +03:00
32 changed files with 3025 additions and 449 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
"Bash(dir)", "Bash(dir)",
"Bash(move *)", "Bash(move *)",
"Bash(findstr *)", "Bash(findstr *)",
"Bash(del *)" "Bash(del *)",
"Bash(mkdir *)"
] ]
}, },
"$version": 3 "$version": 3
+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>
);
};
+49 -5
View File
@@ -1,12 +1,44 @@
import { useState, useEffect, type ReactNode } from "react"; import { useState, useEffect, type ReactNode } from "react";
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar"; import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
import { Navigation } from "@/app/providers/layout/navigation/navigation"; import {
Navigation,
BottomNav,
} from "@/app/providers/layout/navigation/navigation";
import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAgentStore } from "@/app/providers/layout/store/agent.store";
export const Layout = ({ children }: { children: ReactNode }) => { export const Layout = ({ children }: { children: ReactNode }) => {
const [isOpen, setOpen] = useState(true); const [mobileOpen, setMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 856 : false,
);
const [isVerySmall, setIsVerySmall] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 600 : false,
);
const { fetchAgents } = useAgentStore(); const { fetchAgents } = useAgentStore();
const sidebarOpen = isMobile ? mobileOpen : true;
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 856;
setIsMobile(mobile);
if (!mobile) {
setMobileOpen(false);
}
setIsVerySmall(window.innerWidth < 600);
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, []);
const toggleSidebar = () => {
if (isMobile) {
setMobileOpen((prev) => !prev);
}
};
useEffect(() => { useEffect(() => {
fetchAgents(); fetchAgents();
}, [fetchAgents]); }, [fetchAgents]);
@@ -20,11 +52,23 @@ export const Layout = ({ children }: { children: ReactNode }) => {
}, [fetchAgents]); }, [fetchAgents]);
return ( return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--bg-primary)" }}> <div
<Sidebar isOpen={isOpen} onToggle={() => setOpen(!isOpen)} /> className="flex h-screen overflow-hidden"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<Sidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Navigation /> <Navigation
onToggleSidebar={toggleSidebar}
isMobile={isMobile}
isVerySmall={isVerySmall}
/>
<div className="flex-1 overflow-auto p-4">{children}</div> <div className="flex-1 overflow-auto p-4">{children}</div>
{isVerySmall && <BottomNav />}
</div> </div>
</div> </div>
); );
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { FaCode } from "react-icons/fa"; import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
import { import {
FaHome, FaHome,
FaServer, FaServer,
@@ -8,161 +9,436 @@ import {
FaRocket, FaRocket,
FaKey, FaKey,
FaFileAlt, FaFileAlt,
FaSun, FaPalette,
FaMoon, FaSignOutAlt,
FaShieldAlt,
} from "react-icons/fa"; } from "react-icons/fa";
import { useAuthStore } from "@/modules/auth/store/useAuthStore"; import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store"; import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
import { themes } from "@/modules/theme-changer/config/theme.config";
import {
applyTheme,
getCurrentTheme,
} from "@/modules/theme-changer/utils/apply.theme";
export const Navigation = () => { interface NavigationProps {
onToggleSidebar?: () => void;
isMobile?: boolean;
isVerySmall?: boolean;
}
export const Navigation: React.FC<NavigationProps> = ({
onToggleSidebar,
isMobile,
isVerySmall = false,
}) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { toggleTheme, theme } = useThemeStore(); const { setTheme } = useThemeStore();
const [dropdownOpen, setDropdownOpen] = useState(false);
const isDark = theme === "dark"; const [themePickerOpen, setThemePickerOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const currentTheme = getCurrentTheme();
const navItems = [ const navItems = [
{ path: "/", label: "Главная", icon: FaHome }, { path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
{ path: "/add-agents", label: "Агенты", icon: FaServer }, {
{ path: "/templates", label: "Шаблоны", icon: FaCode }, path: "/add-agents",
{ path: "/add-agents", label: "Деплой", icon: FaRocket }, label: "Деплой",
{ path: "/registration", label: "Регистрация", icon: FaKey }, icon: FaRocket,
{ path: "/logs", label: "Логи", icon: FaFileAlt }, requireManageAgent: true,
{ path: "/admin", label: "Админка", icon: FaUsers, adminOnly: 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(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
setThemePickerOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = () => {
logout();
navigate("/auth");
};
const handleThemeChange = (themeId: string) => {
applyTheme(themeId);
setTheme(themeId as any);
setThemePickerOpen(false);
};
const renderNavItems = (showLabels: boolean, iconSize: number) => (
<div className="flex items-center gap-1 whitespace-nowrap">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg font-medium transition-all flex-shrink-0"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active ? "var(--accent-text)" : "var(--text-secondary)",
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
}
}}
title={item.label}
>
<Icon size={iconSize} />
{showLabels && <span className="text-xs">{item.label}</span>}
</button>
);
})}
</div>
);
return (
<>
{/* Верхний бар */}
<div
className="flex-shrink-0 border-b"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between px-4 py-2.5">
{/* Бургер — только на мобильных */}
{isMobile && (
<button
onClick={onToggleSidebar}
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
aria-label="Открыть sidebar"
>
<FaBars size={14} />
</button>
)}
{/* Название по центру — только на очень маленьких экранах */}
{isVerySmall && (
<div className="flex-1 text-center mx-4">
<span
className="text-sm font-bold"
style={{ color: "var(--text-primary)" }}
>
HellreigN
</span>
</div>
)}
{/* Навигация — только если НЕ очень маленький экран */}
{!isVerySmall && (
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
{renderNavItems(true, 12)}
</div>
)}
{/* Профиль пользователя — дропдаун */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
style={{
backgroundColor: dropdownOpen
? "var(--bg-secondary)"
: "transparent",
border: "1px solid var(--border)",
}}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
</div>
<span
className="text-xs font-medium"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login || "Пользователь"}
</span>
<FaChevronDown
size={10}
style={{
color: "var(--text-secondary)",
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{dropdownOpen && (
<div
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "220px",
}}
>
<div
className="px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2 mb-1">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser
size={12}
style={{ color: "var(--accent-text)" }}
/>
</div>
<div>
<p
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login}
</p>
<p
className="text-[10px]"
style={{ color: "var(--text-muted)" }}
>
{user?.login}
</p>
</div>
</div>
</div>
<div className="relative">
<button
onClick={() => setThemePickerOpen(!themePickerOpen)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaPalette
size={12}
style={{ color: "var(--text-secondary)" }}
/>
<span className="flex-1 text-left">
Тема: {themes.find((t) => t.id === currentTheme)?.name}
</span>
<FaChevronDown
size={9}
style={{
color: "var(--text-muted)",
transform: themePickerOpen
? "rotate(180deg)"
: "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{themePickerOpen && (
<div
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "180px",
}}
>
{themes.map((t) => (
<button
key={t.id}
onClick={() => handleThemeChange(t.id)}
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
style={{
color:
currentTheme === t.id
? "var(--accent)"
: "var(--text-primary)",
backgroundColor:
currentTheme === t.id
? "var(--bg-secondary)"
: "transparent",
}}
onMouseEnter={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}
}}
onMouseLeave={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"transparent";
}
}}
>
<div
className="w-4 h-4 rounded-full border"
style={{
backgroundColor: t.colors.primary,
borderColor: "var(--border)",
}}
/>
<span>{t.name}</span>
</button>
))}
</div>
)}
</div>
{user?.permission_admin && (
<button
onClick={() => {
setDropdownOpen(false);
navigate("/admin");
}}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
<span>Админка</span>
</button>
)}
<div
className="my-1 border-b"
style={{ borderColor: "var(--border)" }}
/>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
style={{ color: "var(--error-text)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"rgba(239, 68, 68, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaSignOutAlt size={12} />
<span>Выйти</span>
</button>
</div>
)}
</div>
</div>
</div>
</>
);
};
export const BottomNav: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const navItems = [
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
{
path: "/add-agents",
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;
// 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-b" className="flex-shrink-0 border-t"
style={{ style={{
backgroundColor: "var(--card-bg)", backgroundColor: "var(--card-bg)",
borderColor: "var(--border)", borderColor: "var(--border)",
}} }}
> >
<div className="flex items-center justify-between px-4 py-2.5"> <div className="flex items-center justify-around px-2 py-2">
{/* Навигация с горизонтальным скроллом */} {filteredNavItems.map((item) => {
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide"> const Icon = item.icon;
<div className="flex items-center gap-1 whitespace-nowrap"> const active = isActive(item.path);
{navItems return (
.filter((item) => { <button
if (item.adminOnly && !user?.permission_admin) return false; key={item.path}
return true; onClick={() => navigate(item.path)}
}) className="flex items-center justify-center p-3 rounded-lg transition-all"
.map((item) => { style={{
const Icon = item.icon; backgroundColor: active ? "var(--accent)" : "transparent",
const active = isActive(item.path); color: active ? "var(--accent-text)" : "var(--text-secondary)",
return ( }}
<button title={item.label}
key={item.path} >
onClick={() => navigate(item.path)} <Icon size={20} />
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex-shrink-0" </button>
style={{ );
backgroundColor: active ? "var(--accent)" : "transparent", })}
color: active
? "var(--accent-text)"
: "var(--text-secondary)",
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
}
}}
>
<Icon size={12} />
<span>{item.label}</span>
</button>
);
})}
</div>
</div>
{/* Профиль пользователя */}
<div className="flex items-center gap-2">
{user && (
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FaUser size={12} style={{ color: "var(--accent)" }} />
</div>
<span
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
{user.name}
</span>
</div>
)}
{/* Переключатель темы */}
<button
onClick={toggleTheme}
className="p-1.5 rounded-lg transition-colors"
style={{
backgroundColor: "var(--bg-secondary)",
color: isDark ? "#fbbf24" : "#3b82f6",
border: "1px solid var(--border)",
}}
title={isDark ? "Светлая тема" : "Тёмная тема"}
>
{isDark ? <FaSun size={12} /> : <FaMoon size={12} />}
</button>
{/* Админка */}
<button
onClick={() => navigate("/admin")}
className="p-1.5 rounded-lg transition-colors"
style={{
backgroundColor:
location.pathname === "/admin"
? "var(--accent)"
: "var(--bg-secondary)",
color:
location.pathname === "/admin"
? "var(--accent-text)"
: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
title="Админка"
>
<FaUsers size={12} />
</button>
<button
onClick={() => {
logout();
navigate("/auth");
}}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: "var(--error-bg)",
color: "var(--error-text)",
border: "1px solid var(--error-border)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--error-text)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--error-bg)";
e.currentTarget.style.color = "var(--error-text)";
}}
>
Выйти
</button>
</div>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState, useRef, useEffect } from "react";
import { import {
FaBars, FaBars,
FaMicrochip, FaMicrochip,
@@ -12,20 +12,25 @@ import {
FaTrash, FaTrash,
FaArrowLeft, FaArrowLeft,
} from "react-icons/fa"; } from "react-icons/fa";
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 {
isOpen?: boolean; isOpen?: boolean;
onToggle?: () => void; onToggle?: () => void;
isMobile?: boolean;
} }
export const Sidebar: React.FC<SidebarProps> = ({ export const Sidebar: React.FC<SidebarProps> = ({
isOpen = true, isOpen = true,
onToggle, onToggle,
isMobile = false,
}) => { }) => {
const navigate = useNavigate();
const { agents, isLoading, error, fetchAgents, removeAgent } = const { agents, isLoading, error, fetchAgents, removeAgent } =
useAgentStore(); useAgentStore();
const { token } = useAuthStore(); const { token } = useAuthStore();
@@ -33,10 +38,26 @@ export const Sidebar: React.FC<SidebarProps> = ({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false); const [showTokenModal, setShowTokenModal] = useState(false);
const [showGraphs, setShowGraphs] = useState(false); const [showGraphs, setShowGraphs] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(288);
const sidebarRef = useRef<HTMLDivElement>(null);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>( const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
new Set(agents.map((a) => a.label)), new Set(agents.map((a) => a.label)),
); );
// Рассчитываем максимальную ширину при переключении на графы
useEffect(() => {
const updateWidth = () => {
const targetWidth = showGraphs ? 500 : 288;
const maxWidth = window.innerWidth - 200;
const finalWidth = Math.min(targetWidth, maxWidth);
setSidebarWidth(Math.max(finalWidth, 250));
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}, [showGraphs]);
// Token generation state // Token generation state
const [tokenLabel, setTokenLabel] = useState(""); const [tokenLabel, setTokenLabel] = useState("");
const [generatedToken, setGeneratedToken] = useState<string | null>(null); const [generatedToken, setGeneratedToken] = useState<string | null>(null);
@@ -62,38 +83,88 @@ export const Sidebar: React.FC<SidebarProps> = ({
); );
}, [agents, searchQuery]); }, [agents, searchQuery]);
const graphData: GraphData = useMemo(() => { const [graphData, setGraphData] = useState<GraphData>({
const nodes: any[] = []; nodes: [],
const links: any[] = []; links: [],
});
agents.forEach((agent) => { useEffect(() => {
nodes.push({ const fetchGraph = () => {
id: agent.label, agentApiService
name: agent.label, .getGraph()
type: "agent" as const, .then((apiData) => {
val: 8, const nodes: any[] = [];
description: `Агент: ${agent.label}`, const links: any[] = [];
});
agent.services.forEach((service) => { // Build a map of service statuses from agents
const serviceId = `${agent.label}-${service}`; const serviceStatusMap = new Map<string, "up" | "down">();
nodes.push({ agents.forEach((agent) => {
id: serviceId, const services = agent.services || [];
name: service, services.forEach((svc: string) => {
type: "service" as const, const parts = svc.split(":");
val: 12, const svcName = parts[0];
description: `Сервис: ${service}`, const status = parts[1] === "down" ? "down" : "up";
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
});
});
Object.entries(apiData.nodes || {}).forEach(
([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({
id: serviceId,
name: serviceName,
type: "service" as const,
val: 12,
description: `Сервис: ${serviceName}`,
status,
});
links.push({
source: agentLabel,
target: serviceId,
type: "hosts",
});
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);
}); });
};
links.push({ fetchGraph();
source: agent.label, const interval = setInterval(fetchGraph, 30000);
target: serviceId, return () => clearInterval(interval);
type: "hosts",
});
});
});
return { nodes, links };
}, [agents]); }, [agents]);
const handleCopyToken = () => { const handleCopyToken = () => {
@@ -130,35 +201,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
}; };
if (!isOpen) { if (!isOpen) {
return ( return null;
<button
onClick={onToggle}
className="fixed top-4 left-4 z-50 p-2.5 rounded-lg shadow-lg transition-colors md:hidden"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
aria-label="Открыть sidebar"
>
<FaBars size={18} />
</button>
);
} }
return ( return (
<> <>
{/* Overlay для мобильных */} {/* Overlay — только на мобильных (< 856px) */}
<div {isMobile && (
className="fixed inset-0 bg-black/50 z-40 md:hidden" <div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
onClick={onToggle} )}
/>
<aside <aside
className={`fixed md:relative z-50 transition-all duration-300 ease-in-out flex flex-col ${ ref={sidebarRef}
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
}`}
style={{ style={{
width: showGraphs ? "500px" : "288px", width: `${sidebarWidth}px`,
height: "100vh", height: "100vh",
backgroundColor: "var(--card-bg)", backgroundColor: "var(--card-bg)",
borderRight: "1px solid var(--border)", borderRight: "1px solid var(--border)",
@@ -189,8 +246,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div> </div>
<button <button
onClick={onToggle} onClick={onToggle}
className="p-1 rounded transition-colors md:hidden" className={`p-1 rounded transition-colors ${isMobile ? "" : "hidden"}`}
style={{ color: "var(--text-secondary)" }} style={{ color: "var(--text-secondary)" }}
aria-label="Закрыть sidebar"
> >
<FaTimes size={14} /> <FaTimes size={14} />
</button> </button>
@@ -298,8 +356,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
style={{ color: "var(--accent)" }} style={{ color: "var(--accent)" }}
/> />
<span <span
className="text-sm font-medium flex-1 truncate" className="text-sm font-medium flex-1 truncate cursor-pointer"
style={{ color: "var(--text-primary)" }} style={{ color: "var(--text-primary)" }}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/${agent.label}`);
}}
title="Открыть дашборд агента"
> >
{agent.label} {agent.label}
</span> </span>
@@ -361,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}
@@ -368,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>
@@ -10,29 +10,8 @@ interface AgentState {
removeAgent: (name: string) => void; removeAgent: (name: string) => void;
} }
const mockAgents: AgentInfo[] = [
{
label: "agent-core-01",
token: "tok_a1b2c3d4e5f6g7h8",
services: ["postgres", "redis", "log-collector"],
connected_at: "2026-04-04 15:25:09",
},
{
label: "agent-worker-02",
token: "tok_x9y8z7w6v5u4t3s2",
services: ["celery-worker", "flower"],
connected_at: "2026-04-04 15:25:09",
},
{
label: "agent-monitor-03",
token: "tok_m1n2o3p4q5r6s7t8",
services: ["prometheus", "grafana", "alertmanager"],
connected_at: "2026-04-04 15:25:09",
},
];
export const useAgentStore = create<AgentState>()((set, get) => ({ export const useAgentStore = create<AgentState>()((set, get) => ({
agents: mockAgents, agents: [],
isLoading: false, isLoading: false,
error: null, error: null,
@@ -0,0 +1,50 @@
import { create } from "zustand";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import type { SystemMetrics } from "@/modules/agent/types/agent.types";
interface MetricsState {
metrics: SystemMetrics[];
isLoading: boolean;
error: string | null;
lastUpdated: number | null;
}
const POLLING_INTERVAL = 30_000;
let _pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useMetricsStore = create<MetricsState>(() => ({
metrics: [],
isLoading: false,
error: null,
lastUpdated: null,
}));
export const startMetricsPolling = async () => {
if (_pollingTimer) return;
const fetchMetrics = async () => {
try {
const data = await agentApiService.getSystemMetrics();
useMetricsStore.setState({
metrics: data,
isLoading: false,
error: null,
lastUpdated: Date.now(),
});
} catch (e) {
useMetricsStore.setState({
error: e instanceof Error ? e.message : "Failed to fetch metrics",
isLoading: false,
});
}
};
await fetchMetrics();
_pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL);
};
export const stopMetricsPolling = () => {
if (_pollingTimer) {
clearInterval(_pollingTimer);
_pollingTimer = null;
}
};
@@ -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}</>;
}; };
+79 -9
View File
@@ -14,6 +14,8 @@ import { RegistrationTokenPage } from "@/pages/registration.page";
import { LogsPage } from "@/pages/logs.page"; 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 { ProtectedRoute } from "./helper/protected.route";
export const mockGraphData: GraphData = { export const mockGraphData: GraphData = {
nodes: [ nodes: [
@@ -121,15 +123,83 @@ export const Routing = () => {
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
<Route path="/" element={<HomePage />} /> {/* 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
path="/dashboard/:agentLabel"
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>
<Route path="/test" element={<TestPage />} /> <Route path="/test" element={<TestPage />} />
@@ -13,7 +13,9 @@ import type {
RegistrationRequest, RegistrationRequest,
DeployAgentsRequest, DeployAgentsRequest,
DeployResponse, DeployResponse,
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";
@@ -162,6 +164,18 @@ class AgentApiService {
); );
return response.data; return response.data;
} }
async getSystemMetrics(): Promise<SystemMetrics[]> {
const response = await apiClient.get<SystemMetrics[]>(
`${this.basePath}/system-metrics`,
);
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();
@@ -118,3 +118,14 @@ export interface DeployResponse {
message?: string; message?: string;
results: DeployResult[]; results: DeployResult[];
} }
export interface SystemMetrics {
connected_at: string;
cpu_percent: number;
disk_percent: number;
id: string;
label: string;
memory_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
}
-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>;
}
+6
View File
@@ -65,6 +65,12 @@ export const IDE: React.FC<IDEProps> = ({
const initialize = useIDEStore((state) => state.initialize); const initialize = useIDEStore((state) => state.initialize);
const isInitialized = useIDEStore((state) => state.isInitialized); const isInitialized = useIDEStore((state) => state.isInitialized);
const fetchTree = useIDEStore((state) => state.fetchTree); const fetchTree = useIDEStore((state) => state.fetchTree);
const fetchInterpreters = useIDEStore((state) => state.fetchInterpreters);
// Загружаем интерпретаторы при инициализации
useEffect(() => {
fetchInterpreters();
}, []);
// Обработка Ctrl+S // Обработка Ctrl+S
useEffect(() => { useEffect(() => {
@@ -1,4 +1,5 @@
import { apiClient } from "@/shared/api/axios.instance"; import { apiClient } from "@/shared/api/axios.instance";
import type { Interpreter } from "../types";
export interface ScriptNodeDto { export interface ScriptNodeDto {
id: number; id: number;
@@ -30,8 +31,39 @@ export interface UpdateScriptPayload {
path: string; path: string;
} }
export interface RunScriptPayload {
stdin?: string;
token: string;
}
export interface RunScriptResponse {
command: string[];
id: number;
wait_url: string;
}
export interface CreateInterpreterPayload {
argv: string[];
label: string;
name: string;
}
export interface JobWaitResponse {
command: string[];
id: number;
status: number;
stderr: string;
stdin: string;
stdout: string;
}
// apiClient уже имеет интерсептор для Authorization header // apiClient уже имеет интерсептор для Authorization header
export const scriptsApi = { export const scriptsApi = {
getInterpreters: async (): Promise<Interpreter[]> => {
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
return res.data;
},
getTree: async (): Promise<ScriptNodeDto[]> => { getTree: async (): Promise<ScriptNodeDto[]> => {
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree"); const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
return res.data; return res.data;
@@ -55,4 +87,52 @@ export const scriptsApi = {
deleteScript: async (id: number): Promise<void> => { deleteScript: async (id: number): Promise<void> => {
await apiClient.delete(`/scripts/${id}`); await apiClient.delete(`/scripts/${id}`);
}, },
createFolder: async (path: string): Promise<{ path: string }> => {
const res = await apiClient.post<{ path: string }>("/scripts/folder", {
path,
});
return res.data;
},
deleteFolder: async (path: string): Promise<void> => {
await apiClient.delete(`/scripts/folder`, { data: { path } });
},
rename: async (payload: {
old_path: string;
new_path: string;
}): Promise<{ path: string }> => {
const res = await apiClient.post<{ path: string }>(
"/scripts/rename",
payload,
);
return res.data;
},
runScript: async (
id: number,
payload: RunScriptPayload,
): Promise<RunScriptResponse> => {
const res = await apiClient.post<RunScriptResponse>(
`/scripts/${id}/run`,
payload,
);
return res.data;
},
waitJob: async (id: number): Promise<JobWaitResponse> => {
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
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>
);
};
@@ -46,6 +46,10 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
const handleEmptyContextMenu = (e: React.MouseEvent) => { const handleEmptyContextMenu = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Загружаем интерпретаторы перед открытием меню
if (store.interpreters.length === 0) {
store.fetchInterpreters();
}
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null }); store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
}; };
@@ -55,6 +59,13 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
store.setContextMenu({ x: e.clientX, y: e.clientY, node }); store.setContextMenu({ x: e.clientX, y: e.clientY, node });
}; };
// Загружаем интерпретаторы при монтировании компонента
useEffect(() => {
if (store.interpreters.length === 0) {
store.fetchInterpreters();
}
}, []);
const filteredFiles = store.searchQuery const filteredFiles = store.searchQuery
? (files.children || []) ? (files.children || [])
.map((child) => filterTree(child, store.searchQuery)) .map((child) => filterTree(child, store.searchQuery))
@@ -320,8 +331,13 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
? store.dialog.node.name ? store.dialog.node.name
: "" : ""
} }
onConfirm={store.handleDialogConfirm} onConfirm={(value, interpreterId) => {
store.handleDialogConfirm(value, interpreterId);
}}
onCancel={() => store.setDialog(null)} onCancel={() => store.setDialog(null)}
interpreters={
store.dialog.type === "newFile" ? store.interpreters : undefined
}
/> />
)} )}
</div> </div>
@@ -2,23 +2,24 @@ import React from "react";
import type { FileNode } from "../types"; import type { FileNode } from "../types";
import { FilePickerItem } from "./FilePickerItem"; import { FilePickerItem } from "./FilePickerItem";
import { useFilePickerStore } from "../store/useFilePickerStore"; import { useFilePickerStore } from "../store/useFilePickerStore";
import { TerminalOutput } from "@/modules/terminal";
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
interface FilePickerProps { interface FilePickerProps {
files: FileNode; files: FileNode;
onRun?: (path: string) => void;
} }
const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({ const FilePickerTree: React.FC<{
node, node: FileNode;
level, level: number;
}) => { onRun?: (path: string) => void;
}> = ({ node, level, onRun }) => {
const expandedFolders = useFilePickerStore((s) => s.expandedFolders); const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
const selectedPaths = useFilePickerStore((s) => s.selectedPaths);
const toggleSelection = useFilePickerStore((s) => s.toggleSelection);
const toggleFolder = useFilePickerStore((s) => s.toggleFolder); const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
const nodePath = node.path || node.name; const nodePath = node.path || node.name;
const isExpanded = expandedFolders.has(nodePath); const isExpanded = expandedFolders.has(nodePath);
const isSelected = node.type === "file" && selectedPaths.has(nodePath);
if (node.type === "file") { if (node.type === "file") {
return ( return (
@@ -26,9 +27,8 @@ const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({
name={node.name} name={node.name}
type="file" type="file"
path={nodePath} path={nodePath}
isSelected={isSelected}
level={level} level={level}
onToggleSelect={toggleSelection} onRun={onRun}
/> />
); );
} }
@@ -44,14 +44,22 @@ const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({
onToggleFolder={toggleFolder} onToggleFolder={toggleFolder}
> >
{node.children?.map((child, idx) => ( {node.children?.map((child, idx) => (
<FilePickerTree key={idx} node={child} level={level + 1} /> <FilePickerTree
key={idx}
node={child}
level={level + 1}
onRun={onRun}
/>
))} ))}
</FilePickerItem> </FilePickerItem>
</> </>
); );
}; };
export const FilePicker: React.FC<FilePickerProps> = ({ files }) => { export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
const terminalOpen = useTerminalStore((s) => s.isOpen);
const jobs = useTerminalStore((s) => s.jobs);
return ( return (
<div <div
style={{ style={{
@@ -60,8 +68,15 @@ export const FilePicker: React.FC<FilePickerProps> = ({ files }) => {
backgroundColor: "var(--bg-primary)", backgroundColor: "var(--bg-primary)",
}} }}
> >
{/* Terminal — сверху, над списком файлов */}
{terminalOpen && jobs.length > 0 && (
<div style={{ height: 250 }}>
<TerminalOutput />
</div>
)}
{(files.children || []).map((child, idx) => ( {(files.children || []).map((child, idx) => (
<FilePickerTree key={idx} node={child} level={0} /> <FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
))} ))}
</div> </div>
); );
@@ -4,30 +4,31 @@ import {
FiChevronDown, FiChevronDown,
FiFile, FiFile,
FiFolder, FiFolder,
FiPlay,
} from "react-icons/fi"; } from "react-icons/fi";
interface FilePickerItemProps { interface FilePickerItemProps {
name: string; name: string;
type: "file" | "folder"; type: "file" | "folder";
path: string; path: string;
isSelected?: boolean;
isExpanded?: boolean; isExpanded?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
level: number; level: number;
onToggleSelect?: (path: string) => void; onToggleSelect?: (path: string) => void;
onToggleFolder?: (path: string) => void; onToggleFolder?: (path: string) => void;
onRun?: (path: string) => void;
} }
export const FilePickerItem: React.FC<FilePickerItemProps> = ({ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
name, name,
type, type,
path, path,
isSelected,
isExpanded, isExpanded,
children, children,
level, level,
onToggleSelect, onToggleSelect,
onToggleFolder, onToggleFolder,
onRun,
}) => { }) => {
const isFolder = type === "folder"; const isFolder = type === "folder";
const extension = name.includes(".") const extension = name.includes(".")
@@ -120,40 +121,40 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
</span> </span>
)} )}
{/* Checkbox — только у файлов */} {/* Run button — только у файлов */}
{!isFolder && onToggleSelect && ( {!isFolder && onRun && (
<div <button
style={{ style={{
width: "18px",
height: "18px",
border: isSelected
? "2px solid #0e639c"
: "2px solid var(--border)",
borderRadius: "3px",
backgroundColor: isSelected ? "#0e639c" : "transparent",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: "4px",
backgroundColor: "transparent",
border: "1px solid transparent",
borderRadius: "3px",
color: "var(--text-secondary)",
cursor: "pointer",
flexShrink: 0, flexShrink: 0,
transition: "all 0.15s", transition: "all 0.15s",
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleSelect(path); onRun(path);
}} }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#238636";
e.currentTarget.style.color = "#ffffff";
e.currentTarget.style.borderColor = "#2ea043";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
e.currentTarget.style.borderColor = "transparent";
}}
title="Run script"
> >
{isSelected && ( <FiPlay size={12} />
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"> </button>
<path
d="M2 6L5 9L10 3"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
)} )}
</div> </div>
@@ -1,10 +1,12 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import type { Interpreter } from "../types";
interface InputDialogProps { interface InputDialogProps {
title: string; title: string;
initialValue?: string; initialValue?: string;
onConfirm: (value: string) => void; onConfirm: (value: string, interpreterId?: number) => void;
onCancel: () => void; onCancel: () => void;
interpreters?: Interpreter[];
} }
export const InputDialog: React.FC<InputDialogProps> = ({ export const InputDialog: React.FC<InputDialogProps> = ({
@@ -12,8 +14,12 @@ export const InputDialog: React.FC<InputDialogProps> = ({
initialValue = "", initialValue = "",
onConfirm, onConfirm,
onCancel, onCancel,
interpreters,
}) => { }) => {
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const [interpreterId, setInterpreterId] = useState<number | undefined>(
interpreters?.[0]?.id,
);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -21,6 +27,8 @@ export const InputDialog: React.FC<InputDialogProps> = ({
inputRef.current?.select(); inputRef.current?.select();
}, []); }, []);
const showInterpreterDropdown = interpreters && interpreters.length > 0;
return ( return (
<div <div
style={{ style={{
@@ -59,7 +67,7 @@ export const InputDialog: React.FC<InputDialogProps> = ({
{title} {title}
</h3> </h3>
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}> <p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
Enter a new name Enter a name
</p> </p>
<input <input
ref={inputRef} ref={inputRef}
@@ -67,7 +75,9 @@ export const InputDialog: React.FC<InputDialogProps> = ({
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => onKeyDown={(e) =>
e.key === "Enter" && value.trim() && onConfirm(value.trim()) e.key === "Enter" &&
value.trim() &&
onConfirm(value.trim(), interpreterId)
} }
style={{ style={{
width: "100%", width: "100%",
@@ -77,10 +87,48 @@ export const InputDialog: React.FC<InputDialogProps> = ({
borderRadius: "6px", borderRadius: "6px",
color: "#ccc", color: "#ccc",
fontSize: "14px", fontSize: "14px",
marginBottom: "20px", marginBottom: showInterpreterDropdown ? "12px" : "20px",
outline: "none", outline: "none",
}} }}
/> />
{/* Interpreter dropdown */}
{showInterpreterDropdown && (
<div style={{ marginBottom: "20px" }}>
<label
style={{
display: "block",
fontSize: "12px",
color: "#858585",
marginBottom: "6px",
}}
>
Interpreter
</label>
<select
value={interpreterId}
onChange={(e) => setInterpreterId(Number(e.target.value))}
style={{
width: "100%",
padding: "10px",
backgroundColor: "#3c3c3c",
border: "1px solid #3e3e42",
borderRadius: "6px",
color: "#ccc",
fontSize: "14px",
outline: "none",
cursor: "pointer",
}}
>
{interpreters.map((interp) => (
<option key={interp.id} value={interp.id}>
{interp.label}
</option>
))}
</select>
</div>
)}
<div <div
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }} style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
> >
@@ -99,7 +147,9 @@ export const InputDialog: React.FC<InputDialogProps> = ({
Cancel Cancel
</button> </button>
<button <button
onClick={() => value.trim() && onConfirm(value.trim())} onClick={() =>
value.trim() && onConfirm(value.trim(), interpreterId)
}
style={{ style={{
padding: "6px 16px", padding: "6px 16px",
backgroundColor: "#0e639c", backgroundColor: "#0e639c",
@@ -0,0 +1,302 @@
import React, { useState, useRef, useEffect } from "react";
import { MdClose } from "react-icons/md";
import { scriptsApi } from "../api/scripts.api";
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
interface RunScriptModalProps {
scriptPath: string;
scriptId: number;
onClose: () => void;
}
export const RunScriptModal: React.FC<RunScriptModalProps> = ({
scriptPath,
scriptId,
onClose,
}) => {
const [selectedAgentIdx, setSelectedAgentIdx] = useState(0);
const [stdinValue, setStdinValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLSelectElement>(null);
const agents = useAgentStore((s) => s.agents);
const addJob = useTerminalStore((s) => s.addJob);
const openTerminal = useTerminalStore((s) => s.openTerminal);
const selectedAgent = agents[selectedAgentIdx];
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleRun = async () => {
if (!selectedAgent) {
setError("No agents available");
return;
}
setLoading(true);
setError(null);
try {
// 1. Запускаем скрипт
const runResult = await scriptsApi.runScript(scriptId, {
stdin: stdinValue,
token: selectedAgent.token,
});
// 2. Добавляем джоб в терминал
addJob({
id: runResult.id,
scriptPath,
command: runResult.command,
});
// 3. Открываем терминал
openTerminal();
// 4. Ждём завершения по id
const jobResult = await scriptsApi.waitJob(runResult.id);
// 5. Обновляем существующий джоб (не создаём новый!)
const terminalStore = useTerminalStore.getState();
terminalStore.updateJob(runResult.id, {
command: jobResult.command,
stdin: jobResult.stdin,
status: jobResult.status,
stdout: jobResult.stdout,
stderr: jobResult.stderr,
isRunning: false,
});
onClose();
} catch (e: any) {
console.error("Failed to run script:", e);
setError(e?.response?.data?.detail || "Failed to run script");
} 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,
}}
>
Run Script
</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>
{/* Content */}
<div style={{ padding: "20px" }}>
{/* Script path */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Script
</label>
<div
style={{
padding: "8px 12px",
backgroundColor: "var(--bg-secondary)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
fontFamily: "monospace",
border: "1px solid var(--border)",
}}
>
{scriptPath}
</div>
</div>
{/* Agent selector */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Agent <span style={{ color: "#f44747" }}>*</span>
</label>
<select
ref={inputRef}
value={selectedAgentIdx}
onChange={(e) => setSelectedAgentIdx(Number(e.target.value))}
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",
}}
>
{agents.length === 0 && (
<option value="">No agents available</option>
)}
{agents.map((agent, idx) => (
<option key={agent.label} value={idx}>
{agent.label}
</option>
))}
</select>
</div>
{/* Stdin (optional) */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Stdin <span style={{ color: "#858585" }}>(optional)</span>
</label>
<textarea
value={stdinValue}
onChange={(e) => setStdinValue(e.target.value)}
placeholder="Enter input data..."
rows={4}
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
fontFamily: "monospace",
resize: "vertical",
outline: "none",
}}
/>
</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>
)}
{/* Run button */}
<button
onClick={handleRun}
disabled={loading || !selectedAgent}
style={{
width: "100%",
padding: "10px",
backgroundColor: loading || !selectedAgent ? "#555" : "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
fontSize: "13px",
fontWeight: 500,
cursor: loading || !selectedAgent ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
}}
>
{loading ? (
<>
<span
style={{
display: "inline-block",
animation: "spin 1s linear infinite",
}}
>
</span>
Running...
</>
) : (
<> Run</>
)}
</button>
</div>
</div>
</div>
);
};
+139 -39
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { FileNode } from "../types"; import type { FileNode, Interpreter, DialogState } from "../types";
import { import {
addPaths, addPaths,
getAllFolderPaths, getAllFolderPaths,
@@ -52,13 +52,11 @@ interface IDEState {
searchQuery: string; searchQuery: string;
showSearch: boolean; showSearch: boolean;
isInitialized: boolean; isInitialized: boolean;
interpreters: Interpreter[];
// Диалоги и контекстные меню // Диалоги и контекстные меню
contextMenu: { x: number; y: number; node: FileNode | null } | null; contextMenu: { x: number; y: number; node: FileNode | null } | null;
dialog: { dialog: DialogState | null;
type: "newFile" | "newFolder" | "rename";
node: FileNode | null;
} | null;
tabContextMenu: { x: number; y: number; file: FileNode } | null; tabContextMenu: { x: number; y: number; file: FileNode } | null;
// Действия с файлами // Действия с файлами
@@ -78,6 +76,9 @@ interface IDEState {
deleteRoot: () => void; deleteRoot: () => void;
createNewProject: () => void; createNewProject: () => void;
// Интерпретаторы
fetchInterpreters: () => Promise<void>;
// API методы // API методы
fetchTree: () => Promise<void>; fetchTree: () => Promise<void>;
createScript: (payload: { createScript: (payload: {
@@ -85,11 +86,13 @@ interface IDEState {
interpreter_id: number; interpreter_id: number;
path: string; path: string;
}) => Promise<void>; }) => Promise<void>;
createFolder: (path: string) => Promise<void>;
updateScript: ( updateScript: (
id: number, id: number,
payload: { content: string; interpreter_id: number; path: string }, payload: { content: string; interpreter_id: number; path: string },
) => Promise<void>; ) => Promise<void>;
deleteScript: (id: number) => Promise<void>; deleteScript: (id: number) => Promise<void>;
deleteFolder: (payload: { path: string }) => Promise<void>;
saveActiveFile: () => Promise<void>; saveActiveFile: () => Promise<void>;
// Поиск // Поиск
@@ -114,7 +117,7 @@ interface IDEState {
initialize: (initialFiles: FileNode) => void; initialize: (initialFiles: FileNode) => void;
// Диалог подтверждения // Диалог подтверждения
handleDialogConfirm: (value: string) => Promise<void>; handleDialogConfirm: (value: string, interpreterId?: number) => Promise<void>;
handleDeleteNode: (node: FileNode) => Promise<void>; handleDeleteNode: (node: FileNode) => Promise<void>;
} }
@@ -131,6 +134,7 @@ export const useIDEStore = create<IDEState>((set, get) => ({
contextMenu: null, contextMenu: null,
dialog: null, dialog: null,
tabContextMenu: null, tabContextMenu: null,
interpreters: [],
// Инициализация // Инициализация
initialize: (initialFiles: FileNode) => { initialize: (initialFiles: FileNode) => {
@@ -295,10 +299,21 @@ export const useIDEStore = create<IDEState>((set, get) => ({
}); });
}, },
// Интерпретаторы
fetchInterpreters: async () => {
try {
const interpreters = await scriptsApi.getInterpreters();
set({ interpreters });
} catch (e) {
console.error("Failed to fetch interpreters:", e);
}
},
// API: загрузка дерева с сервера // API: загрузка дерева с сервера
fetchTree: async () => { fetchTree: async () => {
try { try {
const data = await scriptsApi.getTree(); const data = await scriptsApi.getTree();
const { expandedFolders } = get();
const convertItem = (item: any): FileNode => { const convertItem = (item: any): FileNode => {
const node: FileNode = { const node: FileNode = {
@@ -332,7 +347,7 @@ export const useIDEStore = create<IDEState>((set, get) => ({
type: "folder", type: "folder",
children: roots, children: roots,
}, },
expandedFolders: new Set(), expandedFolders,
isInitialized: true, isInitialized: true,
}); });
} catch (e) { } catch (e) {
@@ -352,6 +367,37 @@ export const useIDEStore = create<IDEState>((set, get) => ({
} }
}, },
// API: создание папки
createFolder: async (path: string) => {
try {
await scriptsApi.createFolder(path);
await get().fetchTree();
} catch (e) {
console.error("Failed to create folder:", e);
throw e;
}
},
// API: удаление папки
deleteFolder: async ({ path }: { path: string }) => {
try {
const { openFiles } = get();
// Закрываем все файлы, которые находятся в удаляемой папке
const folderPathPrefix = path.endsWith("/") ? path : `${path}/`;
const filesToClose = openFiles.filter(
(f) => f.path === path || f.path?.startsWith(folderPathPrefix),
);
filesToClose.forEach((f) => get().closeFile(f));
await scriptsApi.deleteFolder(path);
await get().fetchTree();
} catch (e) {
console.error("Failed to delete folder:", e);
throw e;
}
},
// API: обновление скрипта // API: обновление скрипта
updateScript: async (id, payload) => { updateScript: async (id, payload) => {
try { try {
@@ -412,7 +458,7 @@ export const useIDEStore = create<IDEState>((set, get) => ({
setTabContextMenu: (menu) => set({ tabContextMenu: menu }), setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
// Подтверждение диалога // Подтверждение диалога
handleDialogConfirm: async (value: string) => { handleDialogConfirm: async (value: string, interpreterId?: number) => {
const { dialog, files, toggleFolder, autoExpandPaths } = get(); const { dialog, files, toggleFolder, autoExpandPaths } = get();
if (!dialog) return; if (!dialog) return;
@@ -430,14 +476,45 @@ export const useIDEStore = create<IDEState>((set, get) => ({
alert(`"${value}" already exists.`); alert(`"${value}" already exists.`);
return; return;
} }
const newFiles = renameNode(
files!, const oldPath = dialog.node.path || dialog.node.name;
dialog.node.path || dialog.node.name, const newPath = parentPath ? `${parentPath}/${value}` : value;
value,
); // Сохраняем раскрытые папки
if (newFiles) { const savedExpandedFolders = new Set(get().expandedFolders);
set({ files: newFiles });
try {
await scriptsApi.rename({ old_path: oldPath, new_path: newPath });
await get().fetchTree();
// Восстанавливаем раскрытые папки
set({ expandedFolders: savedExpandedFolders });
// Раскрываем родительскую цепочку
const allParentPaths: string[] = [];
let current = parentPath;
while (current) {
allParentPaths.push(current);
const parts = current.split("/");
parts.pop();
current = parts.join("/");
}
autoExpandPaths(new Set(allParentPaths));
// Если переименованный файл был открыт — обновим его в openFiles
const { openFiles, activeFile } = get();
const updatedOpenFiles = openFiles.map((f) =>
f.path === oldPath ? { ...f, name: value, path: newPath } : f,
);
set({ openFiles: updatedOpenFiles });
if (activeFile?.path === oldPath) {
set({ activeFile: { ...activeFile, name: value, path: newPath } });
}
} catch (e) {
console.error("Failed to rename:", e);
} }
set({ dialog: null }); set({ dialog: null });
return; return;
} }
@@ -480,31 +557,52 @@ export const useIDEStore = create<IDEState>((set, get) => ({
const savedExpandedFolders = new Set(get().expandedFolders); const savedExpandedFolders = new Set(get().expandedFolders);
try { try {
const result = await scriptsApi.createScript({ // Создание папки
content: "", if (dialog.type === "newFolder" && !isFile) {
interpreter_id: 1, await scriptsApi.createFolder(fullPath);
path: fullPath, await get().fetchTree();
});
await get().fetchTree(); // Восстанавливаем раскрытые папки
set({ expandedFolders: savedExpandedFolders });
// Восстанавливаем раскрытые папки // Собираем все пути от корня до родительской папки
set({ expandedFolders: savedExpandedFolders }); const allParentPaths: string[] = [];
let current = parentPath;
while (current) {
allParentPaths.push(current);
const parts = current.split("/");
parts.pop();
current = parts.join("/");
}
// Собираем все пути от корня до родительской папки // Раскрываем родительскую цепочку
const allParentPaths: string[] = []; autoExpandPaths(new Set(allParentPaths));
let current = parentPath; } else {
while (current) { // Создание файла
allParentPaths.push(current); const result = await scriptsApi.createScript({
const parts = current.split("/"); content: "",
parts.pop(); interpreter_id: interpreterId || 0,
current = parts.join("/"); path: fullPath,
} });
// Раскрываем родительскую цепочку await get().fetchTree();
autoExpandPaths(new Set(allParentPaths));
// Восстанавливаем раскрытые папки
set({ expandedFolders: savedExpandedFolders });
// Собираем все пути от корня до родительской папки
const allParentPaths: string[] = [];
let current = parentPath;
while (current) {
allParentPaths.push(current);
const parts = current.split("/");
parts.pop();
current = parts.join("/");
}
// Раскрываем родительскую цепочку
autoExpandPaths(new Set(allParentPaths));
if (isFile) {
const createdNode: FileNode = { const createdNode: FileNode = {
id: result.id, id: result.id,
name: finalName, name: finalName,
@@ -529,12 +627,14 @@ export const useIDEStore = create<IDEState>((set, get) => ({
if (isRootNode) { if (isRootNode) {
get().deleteRoot(); get().deleteRoot();
} else if (window.confirm(`Delete "${node.name}"?`)) { } else if (window.confirm(`Delete "${node.name}"?`)) {
if (node.id) { try {
try { if (node.type === "folder") {
await get().deleteFolder({ path: node.path || node.name });
} else if (node.id) {
await get().deleteScript(node.id); await get().deleteScript(node.id);
} catch (e) {
console.error("Failed to delete:", e);
} }
} catch (e) {
console.error("Failed to delete:", e);
} }
} }
}, },
+10
View File
@@ -6,6 +6,15 @@ export interface FileNode {
path?: string; path?: string;
} }
export interface Interpreter {
id: number;
name: string;
label: string;
argv: string[];
created_at: string;
updated_at: string;
}
export interface ContextMenuState { export interface ContextMenuState {
x: number; x: number;
y: number; y: number;
@@ -15,6 +24,7 @@ export interface ContextMenuState {
export interface DialogState { export interface DialogState {
type: "newFile" | "newFolder" | "rename"; type: "newFile" | "newFolder" | "rename";
node: FileNode | null; node: FileNode | null;
interpreterId?: number;
} }
export interface TabContextMenuState { export interface TabContextMenuState {
@@ -0,0 +1,262 @@
import React from "react";
import { useTerminalStore } from "../store/useTerminalStore";
import { MdClose, MdClearAll } from "react-icons/md";
import { FiTerminal } from "react-icons/fi";
export const TerminalOutput: React.FC = () => {
const {
jobs,
isOpen,
activeJobId,
closeTerminal,
setActiveJob,
clearJobs,
removeJob,
} = useTerminalStore();
if (!isOpen) return null;
const activeJob = jobs.find((j) => j.id === activeJobId) || jobs[jobs.length - 1];
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#1e1e1e",
borderTop: "1px solid #3e3e42",
}}
>
{/* Terminal header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
height: "35px",
borderBottom: "1px solid #3e3e42",
backgroundColor: "#252526",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FiTerminal size={14} color="#bbbbbb" />
<span
style={{
color: "#bbbbbb",
fontWeight: 500,
fontSize: "11px",
letterSpacing: "0.8px",
}}
>
TERMINAL
</span>
{jobs.length > 0 && (
<span
style={{
color: "#858585",
fontSize: "11px",
backgroundColor: "#3c3c3c",
padding: "2px 8px",
borderRadius: "10px",
}}
>
{jobs.length}
</span>
)}
</div>
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
{jobs.length > 0 && (
<button
onClick={clearJobs}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
}}
title="Clear all"
>
<MdClearAll size={14} />
</button>
)}
<button
onClick={closeTerminal}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
}}
title="Close"
>
<MdClose size={14} />
</button>
</div>
</div>
{/* Job tabs */}
{jobs.length > 1 && (
<div
style={{
display: "flex",
backgroundColor: "#2d2d2d",
borderBottom: "1px solid #3e3e42",
overflowX: "auto",
}}
>
{jobs.map((job) => (
<button
key={job.id}
onClick={() => setActiveJob(job.id)}
style={{
padding: "6px 16px",
backgroundColor:
job.id === activeJobId ? "#1e1e1e" : "transparent",
border: "none",
borderBottom:
job.id === activeJobId
? "2px solid #0e639c"
: "2px solid transparent",
color: job.isRunning ? "#cccccc" : "#858585",
fontSize: "12px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
whiteSpace: "nowrap",
}}
>
<span
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: job.isRunning ? "#4ec9b0" : "#858585",
display: "inline-block",
}}
/>
{job.scriptPath.split("/").pop()}
</button>
))}
</div>
)}
{/* Terminal output */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "12px",
fontFamily: "'Consolas', 'Courier New', monospace",
fontSize: "13px",
lineHeight: "1.5",
}}
>
{activeJob ? (
<>
{/* Command header */}
<div style={{ marginBottom: "8px" }}>
<span style={{ color: "#6a9955" }}>$ </span>
<span style={{ color: "#cccccc" }}>
{activeJob.command.join(" ")}
</span>
</div>
{/* Stdin if provided */}
{activeJob.stdin && (
<div
style={{
marginBottom: "8px",
padding: "8px",
backgroundColor: "#2d2d2d",
borderRadius: "4px",
borderLeft: "3px solid #0e639c",
}}
>
<span style={{ color: "#858585" }}>stdin: </span>
<pre
style={{
margin: 0,
color: "#cccccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{activeJob.stdin}
</pre>
</div>
)}
{/* Stdout */}
{activeJob.stdout && (
<pre
style={{
margin: "0 0 8px 0",
color: "#cccccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{activeJob.stdout}
</pre>
)}
{/* Stderr */}
{activeJob.stderr && (
<pre
style={{
margin: "0 0 8px 0",
color: "#f44747",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{activeJob.stderr}
</pre>
)}
{/* Status */}
{activeJob.isRunning ? (
<div style={{ color: "#4ec9b0" }}> Running...</div>
) : activeJob.status !== null ? (
<div
style={{
color: activeJob.status === 0 ? "#4ec9b0" : "#f44747",
}}
>
{activeJob.status === 0
? "✓ Process exited with code 0"
: `✗ Process exited with code ${activeJob.status}`}
</div>
) : null}
</>
) : (
<div
style={{
color: "#858585",
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
flexDirection: "column",
gap: "8px",
}}
>
<FiTerminal size={32} />
<span>No active jobs</span>
</div>
)}
</div>
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export { TerminalOutput } from "./components/TerminalOutput";
@@ -0,0 +1,75 @@
import { create } from "zustand";
export interface TerminalJob {
id: number;
scriptPath: string;
command: string[];
status: number | null;
stdout: string;
stderr: string;
stdin: string;
isRunning: boolean;
}
interface TerminalState {
jobs: TerminalJob[];
isOpen: boolean;
activeJobId: number | null;
openTerminal: () => void;
closeTerminal: () => void;
addJob: (job: Omit<TerminalJob, "status" | "stdout" | "stderr" | "isRunning">) => void;
updateJob: (id: number, updates: Partial<TerminalJob>) => void;
setActiveJob: (id: number | null) => void;
clearJobs: () => void;
removeJob: (id: number) => void;
}
export const useTerminalStore = create<TerminalState>((set) => ({
jobs: [],
isOpen: false,
activeJobId: null,
openTerminal: () => set({ isOpen: true }),
closeTerminal: () => set({ isOpen: false }),
addJob: (job) =>
set((state) => ({
jobs: [
...state.jobs,
{
...job,
status: null,
stdout: "",
stderr: "",
stdin: "",
isRunning: true,
},
],
activeJobId: job.id,
})),
updateJob: (id, updates) =>
set((state) => ({
jobs: state.jobs.map((j) => (j.id === id ? { ...j, ...updates } : j)),
})),
setActiveJob: (id) => set({ activeJobId: id }),
clearJobs: () => set({ jobs: [], activeJobId: null }),
removeJob: (id) =>
set((state) => {
const newJobs = state.jobs.filter((j) => j.id !== id);
return {
jobs: newJobs,
activeJobId:
state.activeJobId === id
? newJobs.length > 0
? newJobs[newJobs.length - 1].id
: null
: state.activeJobId,
};
}),
}));
+398
View File
@@ -0,0 +1,398 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import {
startMetricsPolling,
stopMetricsPolling,
} from "@/app/providers/layout/store/metrics.store";
import { useMetricsStore } from "@/app/providers/layout/store/metrics.store";
import { FiArrowLeft, FiCpu, FiHardDrive } from "react-icons/fi";
import { FaMemory, FaNetworkWired } from "react-icons/fa";
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
interface MetricsSnapshot {
timestamp: string;
metrics: Record<string, SystemMetrics>;
}
interface SystemMetrics {
connected_at: string;
cpu_percent: number;
disk_percent: number;
id: string;
label: string;
memory_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
}
export const AgentDashboardPage = () => {
const { agentLabel } = useParams<{ agentLabel: string }>();
const navigate = useNavigate();
const { metrics, lastUpdated } = useMetricsStore();
const [history, setHistory] = useState<MetricsSnapshot[]>([]);
useEffect(() => {
startMetricsPolling();
return () => stopMetricsPolling();
}, []);
const agentMetric = useMemo(
() => metrics.find((m) => m.label === agentLabel),
[metrics, agentLabel],
);
useEffect(() => {
if (metrics.length > 0) {
const now = new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
const map: Record<string, SystemMetrics> = {};
metrics.forEach((m) => {
map[m.label] = m;
});
setHistory((prev) =>
[...prev, { timestamp: now, metrics: map }].slice(-60),
);
}
}, [metrics]);
const historyData = useMemo(() => {
return history
.map((s) => {
const m = s.metrics[agentLabel || ""];
return m
? [
{ timestamp: s.timestamp, value: m.cpu_percent, metric: "CPU" },
{
timestamp: s.timestamp,
value: m.memory_percent,
metric: "RAM",
},
{ timestamp: s.timestamp, value: m.disk_percent, metric: "Disk" },
]
: [];
})
.flat();
}, [history, agentLabel]);
const cpuHistory = useMemo(
() => historyData.filter((d) => d.metric === "CPU"),
[historyData],
);
const ramHistory = useMemo(
() => historyData.filter((d) => d.metric === "RAM"),
[historyData],
);
const diskHistory = useMemo(
() => historyData.filter((d) => d.metric === "Disk"),
[historyData],
);
const displayMetric = agentMetric;
if (!displayMetric) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p style={{ color: "var(--text-muted)" }}>
Метрики для агента "{agentLabel}" не найдены
</p>
</div>
</div>
);
}
return (
<div style={{ padding: "16px 20px" }}>
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => navigate("/dashboard")}
style={{
background: "transparent",
border: "1px solid var(--border)",
color: "var(--text-primary)",
padding: "6px 10px",
borderRadius: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<FiArrowLeft size={14} />
Назад
</button>
<div>
<h1
style={{
fontSize: "16px",
fontWeight: 600,
color: "var(--text-primary)",
margin: 0,
}}
>
{displayMetric.label}
</h1>
<p
style={{
fontSize: "12px",
color: "var(--text-secondary)",
margin: "2px 0 0 0",
}}
>
{lastUpdated && (
<span>
Обновлено {new Date(lastUpdated).toLocaleTimeString()}
</span>
)}
</p>
</div>
</div>
{/* Metric cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "12px",
marginBottom: "20px",
}}
>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FiCpu size={16} style={{ color: "#3b82f6" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
CPU
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.cpu_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FaMemory size={16} style={{ color: "#10b981" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
RAM
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.memory_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FiHardDrive size={16} style={{ color: "#f59e0b" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Disk
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.disk_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FaNetworkWired size={16} style={{ color: "#8b5cf6" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Network
</span>
</div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatBytes(displayMetric.network_rx_bytes)}
</div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatBytes(displayMetric.network_tx_bytes)}
</div>
</div>
</div>
{/* Charts */}
<div
style={{
maxWidth: "1100px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{/* CPU History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
CPU Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cpuHistory}>
<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="#3b82f6"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* RAM History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
Memory Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={ramHistory}>
<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="#10b981"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
+121 -30
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 || [];
nodes.push({ services.forEach((svc: string) => {
id: agent.label, // Parse "serviceName:up" or "serviceName:down"
name: agent.label, const parts = svc.split(":");
type: "agent", 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) => {
const serviceId = `${agent.label}-${service}`;
nodes.push({
id: serviceId,
name: service,
type: "service",
val: 12,
description: `Сервис: ${service}`,
});
links.push({
source: agent.label,
target: serviceId,
type: "hosts",
});
}); });
}); });
if (!apiData?.nodes) return { nodes, links };
Object.entries(apiData.nodes).forEach(
([agentLabel, agentNode]: [string, any]) => {
// Агент как узел
nodes.push({
id: agentLabel,
name: agentLabel,
type: "agent",
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({
id: serviceId,
name: serviceName,
type: "service",
val: 12,
description: `Сервис: ${serviceName}`,
status,
});
// Связь агент → сервис
links.push({
source: agentLabel,
target: serviceId,
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} />
+119 -42
View File
@@ -1,10 +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, FiPlay } 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, useFilePickerStore } from "../modules/ide"; import { FilePicker } from "../modules/ide";
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 => {
@@ -40,11 +44,18 @@ const convertTreeToFileNode = (data: any[]): FileNode => {
export const TemplatesPage = () => { export const TemplatesPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const selectedPaths = useFilePickerStore((s) => s.selectedPaths); 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<{
scriptPath: string;
scriptId: number;
} | null>(null);
const [showAddInterpreter, setShowAddInterpreter] = useState(false);
useEffect(() => { const reloadTree = () => {
setLoading(true);
scriptsApi scriptsApi
.getTree() .getTree()
.then((data) => { .then((data) => {
@@ -55,56 +66,51 @@ 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) => {
if (!id) {
console.warn("Script ID not found for:", path);
return;
}
setRunModal({ scriptPath: path, scriptId: id });
};
return ( return (
<div <div
style={{ style={{
height: "100vh", height: "100vh",
position: "relative",
backgroundColor: "var(--bg-primary)", backgroundColor: "var(--bg-primary)",
display: "flex",
flexDirection: "column",
}} }}
> >
{/* Floating header */} {/* Header bar */}
<div <div
style={{ style={{
position: "absolute",
top: "16px",
right: "16px",
zIndex: 10,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "16px", justifyContent: "flex-end",
padding: "12px 16px",
gap: "12px",
borderBottom: "1px solid var(--border)",
backgroundColor: "var(--card-bg)",
flexShrink: 0,
}} }}
> >
{/* Running scripts counter */} {/* Add Interpreter button */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 12px",
backgroundColor: "var(--card-bg)",
borderRadius: "4px",
border: "1px solid var(--border)",
}}
>
<FiPlay size={13} color="#61c454" />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
{selectedPaths.size} script{selectedPaths.size !== 1 ? "s" : ""}{" "}
running
</span>
</div>
{/* Open in Editor button */}
<button <button
onClick={() => navigate("/ide")} onClick={() => setShowAddInterpreter(true)}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "8px", gap: "6px",
padding: "6px 16px", padding: "6px 14px",
backgroundColor: "#0e639c", backgroundColor: "#238636",
border: "none", border: "none",
borderRadius: "4px", borderRadius: "4px",
color: "#ffffff", color: "#ffffff",
@@ -114,19 +120,49 @@ export const TemplatesPage = () => {
transition: "all 0.15s", transition: "all 0.15s",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#1177bb"; e.currentTarget.style.backgroundColor = "#2ea043";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#0e639c"; e.currentTarget.style.backgroundColor = "#238636";
}} }}
> >
<FiEdit3 size={14} /> <MdAdd size={14} />
Open Editor Add Interpreter
</button> </button>
{/* Open in Editor button — только с правом manage_agent */}
{canManageAgent && (
<button
onClick={() => navigate("/ide")}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 16px",
backgroundColor: "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#1177bb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#0e639c";
}}
>
<FiEdit3 size={14} />
Open Editor
</button>
)}
</div> </div>
{/* File Picker */} {/* File Picker (terminal встроен внутрь) */}
<div style={{ height: "100%", overflow: "hidden" }}> <div style={{ flex: 1, overflow: "hidden" }}>
{loading ? ( {loading ? (
<div <div
style={{ style={{
@@ -145,9 +181,50 @@ export const TemplatesPage = () => {
/> />
</div> </div>
) : files ? ( ) : files ? (
<FilePicker files={files} /> <FilePicker
files={files}
onRun={(path) => {
// Находим ID скрипта по пути
const findNodeById = (
node: FileNode,
p: string,
): FileNode | null => {
if (node.path === p) return node;
if (node.children) {
for (const child of node.children) {
const found = findNodeById(child, p);
if (found) return found;
}
}
return null;
};
const node = findNodeById(files, path);
if (node?.id) {
handleRun(path, node.id);
} else {
console.warn("Script ID not found for path:", path);
}
}}
/>
) : null} ) : null}
</div> </div>
{/* Run Script Modal */}
{runModal && (
<RunScriptModal
scriptPath={runModal.scriptPath}
scriptId={runModal.scriptId}
onClose={() => setRunModal(null)}
/>
)}
{/* Add Interpreter Modal */}
{showAddInterpreter && (
<AddInterpreterModal
onClose={() => setShowAddInterpreter(false)}
onSuccess={reloadTree}
/>
)}
</div> </div>
); );
}; };