Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26323dfd15 | |||
| 7d2f3d0f3a | |||
| 255fe2eaf3 | |||
| 915aa7018a | |||
| c175461634 | |||
| 5b90447984 |
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
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 }) => {
|
||||||
@@ -8,6 +11,9 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
const [isMobile, setIsMobile] = useState(() =>
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
typeof window !== "undefined" ? window.innerWidth < 856 : false,
|
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;
|
const sidebarOpen = isMobile ? mobileOpen : true;
|
||||||
@@ -19,6 +25,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
if (!mobile) {
|
if (!mobile) {
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
}
|
}
|
||||||
|
setIsVerySmall(window.innerWidth < 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
@@ -55,8 +62,13 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
isMobile={isMobile}
|
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 onToggleSidebar={toggleSidebar} isMobile={isMobile} />
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import {
|
|||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
isVerySmall?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Navigation: React.FC<NavigationProps> = ({
|
export const Navigation: React.FC<NavigationProps> = ({
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isVerySmall = false,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -40,15 +42,31 @@ export const Navigation: React.FC<NavigationProps> = ({
|
|||||||
const currentTheme = getCurrentTheme();
|
const currentTheme = getCurrentTheme();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: "/templates", label: "Шаблоны", icon: FaCode },
|
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
||||||
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
|
{
|
||||||
{ path: "/registration", label: "Регистрация", icon: FaKey },
|
path: "/add-agents",
|
||||||
{ path: "/logs", label: "Логи", icon: FaFileAlt },
|
label: "Деплой",
|
||||||
|
icon: FaRocket,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/registration",
|
||||||
|
label: "Регистрация",
|
||||||
|
icon: FaKey,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
// Закрытие дропдауна при клике вне
|
// Filter nav items based on user permissions
|
||||||
|
const filteredNavItems = navItems.filter((item) => {
|
||||||
|
if (item.requireView && !user?.permission_view) return false;
|
||||||
|
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -74,281 +92,353 @@ export const Navigation: React.FC<NavigationProps> = ({
|
|||||||
setThemePickerOpen(false);
|
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) => {
|
||||||
{isMobile && (
|
const Icon = item.icon;
|
||||||
<button
|
const active = isActive(item.path);
|
||||||
onClick={onToggleSidebar}
|
return (
|
||||||
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
|
<button
|
||||||
style={{
|
key={item.path}
|
||||||
backgroundColor: "transparent",
|
onClick={() => navigate(item.path)}
|
||||||
color: "var(--text-secondary)",
|
className="flex items-center justify-center p-3 rounded-lg transition-all"
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
aria-label="Открыть sidebar"
|
|
||||||
>
|
|
||||||
<FaBars size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Навигация */}
|
|
||||||
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
|
|
||||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
|
||||||
{navItems
|
|
||||||
.filter((item) => {
|
|
||||||
if ((item as any).adminOnly && !user?.permission_admin)
|
|
||||||
return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.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-1.5 rounded-lg text-xs 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)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={12} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</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={{
|
style={{
|
||||||
color: "var(--text-secondary)",
|
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||||
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
|
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
||||||
transition: "transform 0.2s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
|
||||||
{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",
|
|
||||||
}}
|
}}
|
||||||
|
title={item.label}
|
||||||
>
|
>
|
||||||
{/* User info */}
|
<Icon size={20} />
|
||||||
<div
|
</button>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Theme selector */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Theme sub-dropdown */}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Admin (if admin) */}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div
|
|
||||||
className="my-1 border-b"
|
|
||||||
style={{ borderColor: "var(--border)" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { Graph, type GraphData } from "@/modules/graph";
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
import { adminApi } from "@/modules/admin/api/admin.api";
|
import { adminApi } from "@/modules/admin/api/admin.api";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -82,38 +83,88 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
);
|
);
|
||||||
}, [agents, searchQuery]);
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
const graphData: GraphData = useMemo(() => {
|
const [graphData, setGraphData] = useState<GraphData>({
|
||||||
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 = () => {
|
||||||
@@ -373,6 +424,11 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
style={{ borderColor: "var(--border)" }}
|
style={{ borderColor: "var(--border)" }}
|
||||||
>
|
>
|
||||||
{agent.services.map((service) => {
|
{agent.services.map((service) => {
|
||||||
|
// Parse "serviceName:up" or "serviceName:down"
|
||||||
|
const parts = service.split(":");
|
||||||
|
const serviceName = parts[0];
|
||||||
|
const isDown = parts[1] === "down";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={service}
|
key={service}
|
||||||
@@ -380,25 +436,31 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{
|
||||||
|
color: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{service}
|
{serviceName}
|
||||||
</span>
|
</span>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#4ade80",
|
backgroundColor: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "#4ade80",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-medium"
|
className="text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
color: "#4ade80",
|
color: isDown ? "#ef4444" : "#4ade80",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
run
|
{isDown ? "down" : "run"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,42 @@
|
|||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
interface ProtectedRouteProps {
|
||||||
const { isAuthenticated } = useAuthStore();
|
children: React.ReactNode;
|
||||||
|
requireView?: boolean;
|
||||||
|
requireManageAgent?: boolean;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
fallbackPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// if (!isAuthenticated) {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
// return <Navigate to="/auth" replace />;
|
children,
|
||||||
// }
|
requireView = false,
|
||||||
|
requireManageAgent = false,
|
||||||
|
requireAdmin = false,
|
||||||
|
fallbackPath = "/",
|
||||||
|
}) => {
|
||||||
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (!isAuthenticated && user?.token) {
|
||||||
|
// User is authenticated based on token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/auth" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireView && !user.permission_view) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireManageAgent && !user.permission_manage_agent) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && !user.permission_admin) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { LogsPage } from "@/pages/logs.page";
|
|||||||
import { GraphsPage } from "@/pages/graphs.page";
|
import { GraphsPage } from "@/pages/graphs.page";
|
||||||
import { DashboardPage } from "@/pages/dashboard.page";
|
import { DashboardPage } from "@/pages/dashboard.page";
|
||||||
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
|
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
|
||||||
|
import { ProtectedRoute } from "./helper/protected.route";
|
||||||
|
|
||||||
export const mockGraphData: GraphData = {
|
export const mockGraphData: GraphData = {
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -122,18 +123,82 @@ export const Routing = () => {
|
|||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
<Route element={<DefaultLayout />}>
|
<Route element={<DefaultLayout />}>
|
||||||
<Route path="/" element={<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
|
<Route
|
||||||
path="/dashboard/:agentLabel"
|
path="/dashboard/:agentLabel"
|
||||||
element={<AgentDashboardPage />}
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<AgentDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Routes requiring 'manage_agent' permission */}
|
||||||
|
<Route
|
||||||
|
path="/add-agents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireManageAgent>
|
||||||
|
<AddAgentsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/registration"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireManageAgent>
|
||||||
|
<RegistrationTokenPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<TemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/IDE"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<IDEPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin route requiring 'admin' permission */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
DeployResponse,
|
DeployResponse,
|
||||||
SystemMetrics,
|
SystemMetrics,
|
||||||
} from "../types/agent.types";
|
} from "../types/agent.types";
|
||||||
|
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||||
|
|
||||||
class AgentApiService {
|
class AgentApiService {
|
||||||
private readonly basePath = "/agents";
|
private readonly basePath = "/agents";
|
||||||
@@ -170,6 +171,11 @@ class AgentApiService {
|
|||||||
);
|
);
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGraph(): Promise<GraphApiResponse> {
|
||||||
|
const response = await apiClient.get<GraphApiResponse>("/graph");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentApiService = new AgentApiService();
|
export const agentApiService = new AgentApiService();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface GraphNode {
|
|||||||
description?: string;
|
description?: string;
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
|
status?: "up" | "down";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphLink {
|
export interface GraphLink {
|
||||||
@@ -25,3 +26,25 @@ export interface ContextMenuState {
|
|||||||
node: GraphNode | null;
|
node: GraphNode | null;
|
||||||
link: GraphLink | null;
|
link: GraphLink | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API response types for GET /graph
|
||||||
|
export interface GraphDependencyTarget {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphDependency {
|
||||||
|
condition: string;
|
||||||
|
target: GraphDependencyTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphServiceNode {
|
||||||
|
dependencies: GraphDependency[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphAgentNode {
|
||||||
|
services: Record<string, GraphServiceNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphApiResponse {
|
||||||
|
nodes: Record<string, GraphAgentNode>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface RunScriptResponse {
|
|||||||
wait_url: string;
|
wait_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateInterpreterPayload {
|
||||||
|
argv: string[];
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobWaitResponse {
|
export interface JobWaitResponse {
|
||||||
command: string[];
|
command: string[];
|
||||||
id: number;
|
id: number;
|
||||||
@@ -119,4 +125,14 @@ export const scriptsApi = {
|
|||||||
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
|
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createInterpreter: async (
|
||||||
|
payload: CreateInterpreterPayload,
|
||||||
|
): Promise<Interpreter> => {
|
||||||
|
const res = await apiClient.post<Interpreter>(
|
||||||
|
"/scripts/interpreters",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { MdClose, MdAdd } from "react-icons/md";
|
||||||
|
import { scriptsApi } from "../api/scripts.api";
|
||||||
|
import type { CreateInterpreterPayload } from "../api/scripts.api";
|
||||||
|
|
||||||
|
interface AddInterpreterModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [argv, setArgv] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
nameRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !label.trim()) {
|
||||||
|
setError("Name and Label are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: CreateInterpreterPayload = {
|
||||||
|
name: name.trim(),
|
||||||
|
label: label.trim(),
|
||||||
|
argv: argv
|
||||||
|
.split(" ")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
await scriptsApi.createInterpreter(payload);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Failed to create interpreter:", e);
|
||||||
|
setError(e?.response?.data?.detail || "Failed to create interpreter");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
width: "420px",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Interpreter
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name <span style={{ color: "#f44747" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={nameRef}
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Python, Node.js, etc."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Label <span style={{ color: "#f44747" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="python3, node, etc."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Args */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Arguments <span style={{ color: "#858585" }}>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={argv}
|
||||||
|
onChange={(e) => setArgv(e.target.value)}
|
||||||
|
placeholder="-u -O (space separated)"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
||||||
|
border: "1px solid #f44747",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#f44747",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: loading ? "#555" : "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏳
|
||||||
|
</span>
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MdAdd size={16} />
|
||||||
|
Add Interpreter
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -392,55 +392,6 @@ export const AgentDashboardPage = () => {
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disk History */}
|
|
||||||
<div style={{ height: 280 }}>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
marginBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Disk Usage History (%)
|
|
||||||
</h3>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={diskHistory}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
stroke="var(--text-secondary)"
|
|
||||||
fontSize={11}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--text-secondary)"
|
|
||||||
fontSize={12}
|
|
||||||
domain={[0, 100]}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="value"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
fill="#f59e0b"
|
|
||||||
label={{
|
|
||||||
position: "top",
|
|
||||||
fill: "var(--text-primary)",
|
|
||||||
fontSize: 10,
|
|
||||||
formatter: (v: number) => `${v.toFixed(0)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { FiEdit3 } from "react-icons/fi";
|
import { FiEdit3 } from "react-icons/fi";
|
||||||
|
import { MdAdd } from "react-icons/md";
|
||||||
import { FaSpinner } from "react-icons/fa";
|
import { FaSpinner } from "react-icons/fa";
|
||||||
import { FilePicker } from "../modules/ide";
|
import { FilePicker } from "../modules/ide";
|
||||||
import { RunScriptModal } from "../modules/ide/components/RunScriptModal";
|
import { RunScriptModal } from "../modules/ide/components/RunScriptModal";
|
||||||
|
import { AddInterpreterModal } from "../modules/ide/components/AddInterpreterModal";
|
||||||
import type { FileNode } from "../modules/ide";
|
import type { FileNode } from "../modules/ide";
|
||||||
import { scriptsApi } from "../modules/ide/api/scripts.api";
|
import { scriptsApi } from "../modules/ide/api/scripts.api";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
const convertTreeToFileNode = (data: any[]): FileNode => {
|
const convertTreeToFileNode = (data: any[]): FileNode => {
|
||||||
const convertItem = (item: any): FileNode => {
|
const convertItem = (item: any): FileNode => {
|
||||||
@@ -41,14 +44,18 @@ const convertTreeToFileNode = (data: any[]): FileNode => {
|
|||||||
|
|
||||||
export const TemplatesPage = () => {
|
export const TemplatesPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const canManageAgent = user?.permission_manage_agent;
|
||||||
const [files, setFiles] = useState<FileNode | null>(null);
|
const [files, setFiles] = useState<FileNode | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [runModal, setRunModal] = useState<{
|
const [runModal, setRunModal] = useState<{
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
scriptId: number;
|
scriptId: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showAddInterpreter, setShowAddInterpreter] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const reloadTree = () => {
|
||||||
|
setLoading(true);
|
||||||
scriptsApi
|
scriptsApi
|
||||||
.getTree()
|
.getTree()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -59,6 +66,10 @@ export const TemplatesPage = () => {
|
|||||||
setFiles({ name: "templates", type: "folder", children: [] });
|
setFiles({ name: "templates", type: "folder", children: [] });
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadTree();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRun = (path: string, id?: number) => {
|
const handleRun = (path: string, id?: number) => {
|
||||||
@@ -85,20 +96,21 @@ export const TemplatesPage = () => {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
padding: "12px 16px",
|
padding: "12px 16px",
|
||||||
|
gap: "12px",
|
||||||
borderBottom: "1px solid var(--border)",
|
borderBottom: "1px solid var(--border)",
|
||||||
backgroundColor: "var(--card-bg)",
|
backgroundColor: "var(--card-bg)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Open in Editor button */}
|
{/* Add Interpreter button */}
|
||||||
<button
|
<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",
|
||||||
@@ -108,15 +120,45 @@ 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 (terminal встроен внутрь) */}
|
{/* File Picker (terminal встроен внутрь) */}
|
||||||
@@ -175,6 +217,14 @@ export const TemplatesPage = () => {
|
|||||||
onClose={() => setRunModal(null)}
|
onClose={() => setRunModal(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Interpreter Modal */}
|
||||||
|
{showAddInterpreter && (
|
||||||
|
<AddInterpreterModal
|
||||||
|
onClose={() => setShowAddInterpreter(false)}
|
||||||
|
onSuccess={reloadTree}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user