Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26323dfd15 | |||
| 7d2f3d0f3a | |||
| 255fe2eaf3 | |||
| 915aa7018a | |||
| c175461634 | |||
| 5b90447984 | |||
| 9f6defd25c | |||
| 5f6c4303db | |||
| 17d4770de6 | |||
| 337e5891f3 | |||
| 2bc3da21fd | |||
| d6512d6c97 | |||
| f14490c076 | |||
| 178c3b53f7 | |||
| 5073cfd357 | |||
| f71a3b1a03 | |||
| e024f91111 | |||
| 8f5558fdb7 | |||
| 07066ec8c0 | |||
| 31eecf4ba5 | |||
| cf6065b55a | |||
| 43ea41f633 | |||
| 6b82c99d50 | |||
| c73035019f | |||
| e3fae7a02c | |||
| d46d0f8253 |
@@ -8,7 +8,8 @@
|
||||
"Bash(dir)",
|
||||
"Bash(move *)",
|
||||
"Bash(findstr *)",
|
||||
"Bash(del *)"
|
||||
"Bash(del *)",
|
||||
"Bash(mkdir *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import "@/shared/styles/index.css";
|
||||
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
||||
import "primereact/resources/primereact.min.css";
|
||||
import "primeicons/primeicons.css";
|
||||
import { PrimeReactProvider } from "primereact/api";
|
||||
import { Routing } from "./providers/routing/routing";
|
||||
import { AppLoader } from "./components/AppLoader";
|
||||
|
||||
function App() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 1800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <AppLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimeReactProvider>
|
||||
<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,12 +1,44 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
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";
|
||||
|
||||
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 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(() => {
|
||||
fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
@@ -20,11 +52,23 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
}, [fetchAgents]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--bg-primary)" }}>
|
||||
<Sidebar isOpen={isOpen} onToggle={() => setOpen(!isOpen)} />
|
||||
<div
|
||||
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">
|
||||
<Navigation />
|
||||
<Navigation
|
||||
onToggleSidebar={toggleSidebar}
|
||||
isMobile={isMobile}
|
||||
isVerySmall={isVerySmall}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||
{isVerySmall && <BottomNav />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { FaCode } from "react-icons/fa";
|
||||
import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
|
||||
import {
|
||||
FaHome,
|
||||
FaServer,
|
||||
@@ -8,161 +9,436 @@ import {
|
||||
FaRocket,
|
||||
FaKey,
|
||||
FaFileAlt,
|
||||
FaSun,
|
||||
FaMoon,
|
||||
FaPalette,
|
||||
FaSignOutAlt,
|
||||
FaShieldAlt,
|
||||
} from "react-icons/fa";
|
||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
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 location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { toggleTheme, theme } = useThemeStore();
|
||||
|
||||
const isDark = theme === "dark";
|
||||
const { setTheme } = useThemeStore();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const currentTheme = getCurrentTheme();
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "Главная", icon: FaHome },
|
||||
{ path: "/add-agents", label: "Агенты", icon: FaServer },
|
||||
{ path: "/templates", label: "Шаблоны", icon: FaCode },
|
||||
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
|
||||
{ path: "/registration", label: "Регистрация", icon: FaKey },
|
||||
{ path: "/logs", label: "Логи", icon: FaFileAlt },
|
||||
{ path: "/admin", label: "Админка", icon: FaUsers, adminOnly: true },
|
||||
{ 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;
|
||||
});
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex-shrink-0 border-b"
|
||||
className="flex-shrink-0 border-t"
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
{/* Навигация с горизонтальным скроллом */}
|
||||
<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.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="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 className="flex items-center justify-around px-2 py-2">
|
||||
{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 justify-center p-3 rounded-lg transition-all"
|
||||
style={{
|
||||
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
||||
}}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
FaBars,
|
||||
FaMicrochip,
|
||||
@@ -12,19 +12,25 @@ import {
|
||||
FaTrash,
|
||||
FaArrowLeft,
|
||||
} from "react-icons/fa";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
import { Graph, type GraphData } from "@/modules/graph";
|
||||
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||
import { adminApi } from "@/modules/admin/api/admin.api";
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
isOpen = true,
|
||||
onToggle,
|
||||
isMobile = false,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { agents, isLoading, error, fetchAgents, removeAgent } =
|
||||
useAgentStore();
|
||||
const { token } = useAuthStore();
|
||||
@@ -32,10 +38,32 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const [showGraphs, setShowGraphs] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(288);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
|
||||
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
|
||||
const [tokenLabel, setTokenLabel] = useState("");
|
||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||
const [tokenGenerating, setTokenGenerating] = useState(false);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
const toggleAgent = (label: string) => {
|
||||
setExpandedAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -55,78 +83,139 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
);
|
||||
}, [agents, searchQuery]);
|
||||
|
||||
const graphData: GraphData = useMemo(() => {
|
||||
const nodes: any[] = [];
|
||||
const links: any[] = [];
|
||||
const [graphData, setGraphData] = useState<GraphData>({
|
||||
nodes: [],
|
||||
links: [],
|
||||
});
|
||||
|
||||
agents.forEach((agent) => {
|
||||
nodes.push({
|
||||
id: agent.label,
|
||||
name: agent.label,
|
||||
type: "agent" as const,
|
||||
val: 8,
|
||||
description: `Агент: ${agent.label}`,
|
||||
});
|
||||
useEffect(() => {
|
||||
const fetchGraph = () => {
|
||||
agentApiService
|
||||
.getGraph()
|
||||
.then((apiData) => {
|
||||
const nodes: any[] = [];
|
||||
const links: any[] = [];
|
||||
|
||||
agent.services.forEach((service) => {
|
||||
const serviceId = `${agent.label}-${service}`;
|
||||
nodes.push({
|
||||
id: serviceId,
|
||||
name: service,
|
||||
type: "service" as const,
|
||||
val: 12,
|
||||
description: `Сервис: ${service}`,
|
||||
// Build a map of service statuses from agents
|
||||
const serviceStatusMap = new Map<string, "up" | "down">();
|
||||
agents.forEach((agent) => {
|
||||
const services = agent.services || [];
|
||||
services.forEach((svc: string) => {
|
||||
const parts = svc.split(":");
|
||||
const svcName = parts[0];
|
||||
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({
|
||||
source: agent.label,
|
||||
target: serviceId,
|
||||
type: "hosts",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, links };
|
||||
fetchGraph();
|
||||
const interval = setInterval(fetchGraph, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [agents]);
|
||||
|
||||
const handleCopyToken = () => {
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token);
|
||||
const tokenToCopy = generatedToken || token;
|
||||
if (tokenToCopy) {
|
||||
navigator.clipboard.writeText(tokenToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateToken = async () => {
|
||||
if (!tokenLabel.trim()) return;
|
||||
setTokenGenerating(true);
|
||||
setTokenError(null);
|
||||
try {
|
||||
const newToken = await adminApi.generateToken(tokenLabel.trim());
|
||||
setGeneratedToken(newToken);
|
||||
} catch (e) {
|
||||
setTokenError(
|
||||
e instanceof Error ? e.message : "Failed to generate token",
|
||||
);
|
||||
} finally {
|
||||
setTokenGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTokenModal = () => {
|
||||
setShowTokenModal(false);
|
||||
setTokenLabel("");
|
||||
setGeneratedToken(null);
|
||||
setTokenError(null);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<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 null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay для мобильных */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
{/* Overlay — только на мобильных (< 856px) */}
|
||||
{isMobile && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed md:relative z-50 transition-all duration-300 ease-in-out flex flex-col ${
|
||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
}`}
|
||||
ref={sidebarRef}
|
||||
className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
|
||||
style={{
|
||||
width: showGraphs ? "500px" : "288px",
|
||||
width: `${sidebarWidth}px`,
|
||||
height: "100vh",
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
@@ -157,8 +246,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded transition-colors md:hidden"
|
||||
className={`p-1 rounded transition-colors ${isMobile ? "" : "hidden"}`}
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
aria-label="Закрыть sidebar"
|
||||
>
|
||||
<FaTimes size={14} />
|
||||
</button>
|
||||
@@ -266,8 +356,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
style={{ color: "var(--accent)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-medium flex-1 truncate"
|
||||
className="text-sm font-medium flex-1 truncate cursor-pointer"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/${agent.label}`);
|
||||
}}
|
||||
title="Открыть дашборд агента"
|
||||
>
|
||||
{agent.label}
|
||||
</span>
|
||||
@@ -329,6 +424,11 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
{agent.services.map((service) => {
|
||||
// Parse "serviceName:up" or "serviceName:down"
|
||||
const parts = service.split(":");
|
||||
const serviceName = parts[0];
|
||||
const isDown = parts[1] === "down";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service}
|
||||
@@ -336,25 +436,31 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
style={{
|
||||
color: isDown
|
||||
? "#ef4444"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{service}
|
||||
{serviceName}
|
||||
</span>
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: "#4ade80",
|
||||
backgroundColor: isDown
|
||||
? "#ef4444"
|
||||
: "#4ade80",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{
|
||||
color: "#4ade80",
|
||||
color: isDown ? "#ef4444" : "#4ade80",
|
||||
}}
|
||||
>
|
||||
run
|
||||
{isDown ? "down" : "run"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,7 +545,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
onClick={handleCloseTokenModal}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl shadow-2xl border"
|
||||
@@ -459,11 +565,11 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Ваш токен доступа
|
||||
Генерация токена
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
onClick={handleCloseTokenModal}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
@@ -472,27 +578,72 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Токен
|
||||
</label>
|
||||
{/* Error */}
|
||||
{tokenError && (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg p-3 border"
|
||||
className="text-xs p-2 rounded"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
backgroundColor: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
color: "var(--error-text, #ef4444)",
|
||||
}}
|
||||
>
|
||||
<code
|
||||
className="flex-1 text-xs font-mono break-all"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
{tokenError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label input */}
|
||||
{!generatedToken && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{token || "Токен не найден"}
|
||||
</code>
|
||||
{token && (
|
||||
Имя токена
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tokenLabel}
|
||||
onChange={(e) => setTokenLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && tokenLabel.trim()) {
|
||||
handleGenerateToken();
|
||||
}
|
||||
}}
|
||||
placeholder="Введите имя..."
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated token */}
|
||||
{generatedToken && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Токен
|
||||
</label>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg p-3 border"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<code
|
||||
className="flex-1 text-xs font-mono break-all"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{generatedToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyToken}
|
||||
className="p-1.5 rounded transition-colors"
|
||||
@@ -507,20 +658,56 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<FaCopy size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
className="w-full py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--accent-text)",
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{generatedToken && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setGeneratedToken(null);
|
||||
setTokenLabel("");
|
||||
}}
|
||||
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
Новый токен
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={
|
||||
generatedToken ? handleCloseTokenModal : handleGenerateToken
|
||||
}
|
||||
disabled={tokenGenerating || !tokenLabel.trim()}
|
||||
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||
? "var(--bg-secondary)"
|
||||
: "var(--accent)",
|
||||
color:
|
||||
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||
? "var(--text-muted)"
|
||||
: "var(--accent-text)",
|
||||
cursor:
|
||||
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||
? "default"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{tokenGenerating
|
||||
? "Генерация..."
|
||||
: generatedToken
|
||||
? "Готово"
|
||||
: "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,29 +10,8 @@ interface AgentState {
|
||||
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) => ({
|
||||
agents: mockAgents,
|
||||
agents: [],
|
||||
isLoading: false,
|
||||
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 { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireView?: boolean;
|
||||
requireManageAgent?: boolean;
|
||||
requireAdmin?: boolean;
|
||||
fallbackPath?: string;
|
||||
}
|
||||
|
||||
// if (!isAuthenticated) {
|
||||
// return <Navigate to="/auth" replace />;
|
||||
// }
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
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}</>;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import { RegistrationTokenPage } from "@/pages/registration.page";
|
||||
import { LogsPage } from "@/pages/logs.page";
|
||||
import { GraphsPage } from "@/pages/graphs.page";
|
||||
import { DashboardPage } from "@/pages/dashboard.page";
|
||||
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
|
||||
import { ProtectedRoute } from "./helper/protected.route";
|
||||
|
||||
export const mockGraphData: GraphData = {
|
||||
nodes: [
|
||||
@@ -121,15 +123,83 @@ export const Routing = () => {
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
<Route element={<DefaultLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
||||
<Route path="/registration" element={<RegistrationTokenPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/IDE" element={<IDEPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/graphs" element={<GraphsPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
{/* Routes requiring 'view' permission */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute requireView>
|
||||
<TemplatesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<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 path="/test" element={<TestPage />} />
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import React from "react";
|
||||
import { FaUsers, FaShieldAlt } from "react-icons/fa";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
FaUsers,
|
||||
FaShieldAlt,
|
||||
FaSpinner,
|
||||
FaExclamationCircle,
|
||||
FaPlus,
|
||||
} from "react-icons/fa";
|
||||
import { useAdminStore } from "./store/useAdminStore";
|
||||
import { UserCard } from "./components/UserCard";
|
||||
import { CreateUserModal } from "./components/CreateUserModal";
|
||||
|
||||
export const AdminPanel: React.FC = () => {
|
||||
const users = useAdminStore((s) => s.users);
|
||||
const loading = useAdminStore((s) => s.loading);
|
||||
const error = useAdminStore((s) => s.error);
|
||||
const fetchUsers = useAdminStore((s) => s.fetchUsers);
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const activeCount = users.filter((u) => u.is_active).length;
|
||||
|
||||
@@ -45,24 +61,106 @@ export const AdminPanel: React.FC = () => {
|
||||
Управление пользователями
|
||||
</h1>
|
||||
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||
{activeCount} / {users.length} активных
|
||||
{loading
|
||||
? "Загрузка..."
|
||||
: `${activeCount} / ${users.length} активных`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--accent-text)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<FaPlus size={12} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "12px",
|
||||
backgroundColor: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--error-text, #ef4444)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<FaExclamationCircle />
|
||||
<span style={{ fontSize: "13px" }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && users.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "60px 0",
|
||||
}}
|
||||
>
|
||||
<FaSpinner
|
||||
className="animate-spin"
|
||||
size={24}
|
||||
style={{ color: "var(--accent)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users list */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
{!loading && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && users.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px 0",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: "14px" }}>
|
||||
Нет зарегистрированных пользователей
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create user modal */}
|
||||
<CreateUserModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { apiClient } from "@/shared/api/axios.instance";
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const raw = localStorage.getItem("auth-storage");
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.state?.token) return `bearer ${parsed.state.token}`;
|
||||
} catch {}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export interface AdminUserDto {
|
||||
id: number;
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
password: string;
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionsPayload {
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getUsers: async (): Promise<AdminUserDto[]> => {
|
||||
const res = await apiClient.get<AdminUserDto[]>("/auth/tokens", {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createUser: async (payload: CreateUserPayload): Promise<void> => {
|
||||
await apiClient.post("/auth/token", payload, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
},
|
||||
|
||||
deleteUser: async (login: string): Promise<void> => {
|
||||
await apiClient.delete(`/auth/tokens/${login}`, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
},
|
||||
|
||||
activateUser: async (login: string): Promise<void> => {
|
||||
await apiClient.post(
|
||||
`/auth/users/${login}/activate`,
|
||||
{},
|
||||
{ headers: { Authorization: getAuthHeader() } },
|
||||
);
|
||||
},
|
||||
|
||||
deactivateUser: async (login: string): Promise<void> => {
|
||||
await apiClient.post(
|
||||
`/auth/users/${login}/deactivate`,
|
||||
{},
|
||||
{ headers: { Authorization: getAuthHeader() } },
|
||||
);
|
||||
},
|
||||
|
||||
updatePermissions: async (
|
||||
login: string,
|
||||
payload: PermissionsPayload,
|
||||
): Promise<void> => {
|
||||
await apiClient.put(`/auth/users/${login}/permissions`, payload, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
},
|
||||
|
||||
generateToken: async (label: string): Promise<string> => {
|
||||
const res = await apiClient.post<{ token: string }>(
|
||||
"/agents/register-token",
|
||||
{ label },
|
||||
{ headers: { Authorization: getAuthHeader() } },
|
||||
);
|
||||
return res.data.token;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useState } from "react";
|
||||
import { FaTimes, FaPlus } from "react-icons/fa";
|
||||
import { useAdminStore } from "../store/useAdminStore";
|
||||
|
||||
interface CreateUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const createUser = useAdminStore((s) => s.createUser);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
login: "",
|
||||
name: "",
|
||||
last_name: "",
|
||||
password: "",
|
||||
is_active: true,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: false,
|
||||
permission_view: true,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.login || !form.password) return;
|
||||
setLoading(true);
|
||||
await createUser(form);
|
||||
setLoading(false);
|
||||
setForm({
|
||||
login: "",
|
||||
name: "",
|
||||
last_name: "",
|
||||
password: "",
|
||||
is_active: true,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: false,
|
||||
permission_view: true,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
minWidth: "380px",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "16px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Создать пользователя
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
<FaTimes size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{/* Login */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name + Last name */}
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.last_name}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, last_name: e.target.value })
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div style={{ paddingTop: "8px" }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "8px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Разрешения
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
|
||||
{[
|
||||
{ key: "is_active", label: "Active" },
|
||||
{ key: "permission_view", label: "View" },
|
||||
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||
{ key: "permission_admin", label: "Admin" },
|
||||
].map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
form[key as keyof typeof form] as boolean
|
||||
}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, [key]: e.target.checked })
|
||||
}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !form.login || !form.password}
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
padding: "10px",
|
||||
backgroundColor:
|
||||
loading || !form.login || !form.password
|
||||
? "var(--bg-secondary)"
|
||||
: "var(--accent)",
|
||||
color:
|
||||
loading || !form.login || !form.password
|
||||
? "var(--text-muted)"
|
||||
: "var(--accent-text)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor:
|
||||
loading || !form.login || !form.password
|
||||
? "default"
|
||||
: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<FaPlus size={12} />
|
||||
{loading ? "Создание..." : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { FaUser, FaCheck, FaTimes } from "react-icons/fa";
|
||||
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
|
||||
import type { AdminUser, PermissionKey } from "../types";
|
||||
import { useAdminStore } from "../store/useAdminStore";
|
||||
|
||||
@@ -14,8 +14,10 @@ const permissions: { key: PermissionKey; label: string }[] = [
|
||||
];
|
||||
|
||||
export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||
const users = useAdminStore((s) => s.users);
|
||||
const toggleActive = useAdminStore((s) => s.toggleActive);
|
||||
const togglePermission = useAdminStore((s) => s.togglePermission);
|
||||
const deleteUser = useAdminStore((s) => s.deleteUser);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -28,7 +30,7 @@ export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||
opacity: user.is_active ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
{/* Header: User info + Active toggle */}
|
||||
{/* Header: User info + Active toggle + Delete */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -74,49 +76,79 @@ export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: user.is_active
|
||||
? "var(--success-text, #22c55e)"
|
||||
: "var(--error-text, #ef4444)",
|
||||
}}
|
||||
>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleActive(user.id)}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "22px",
|
||||
borderRadius: "11px",
|
||||
border: "none",
|
||||
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
{/* Active toggle */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fff",
|
||||
position: "absolute",
|
||||
top: "3px",
|
||||
left: user.is_active ? "21px" : "3px",
|
||||
transition: "left 0.2s",
|
||||
fontSize: "11px",
|
||||
color: user.is_active
|
||||
? "var(--success-text, #22c55e)"
|
||||
: "var(--error-text, #ef4444)",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleActive(user.id, user.login, user.is_active)}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "22px",
|
||||
borderRadius: "11px",
|
||||
border: "none",
|
||||
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fff",
|
||||
position: "absolute",
|
||||
top: "3px",
|
||||
left: user.is_active ? "21px" : "3px",
|
||||
transition: "left 0.2s",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Удалить пользователя "${user.login}"?`)) {
|
||||
deleteUser(user.id, user.login);
|
||||
}
|
||||
}}
|
||||
title="Удалить"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "1px solid transparent",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
padding: "6px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--error-text, #ef4444)";
|
||||
e.currentTarget.style.backgroundColor = "rgba(239,68,68,0.1)";
|
||||
e.currentTarget.style.borderColor = "rgba(239,68,68,0.3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<FaTrash size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +176,7 @@ export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => togglePermission(user.id, key)}
|
||||
onClick={() => togglePermission(user.id, user.login, key, users)}
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
@@ -160,7 +192,10 @@ export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||
}}
|
||||
>
|
||||
{user[key] && (
|
||||
<FaCheck size={10} style={{ color: "var(--accent-text, #fff)" }} />
|
||||
<FaCheck
|
||||
size={10}
|
||||
style={{ color: "var(--accent-text, #fff)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{label}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AdminPanel } from "./AdminPanel";
|
||||
export { useAdminStore } from "./store/useAdminStore";
|
||||
export { adminApi } from "./api/admin.api";
|
||||
export type { AdminUser } from "./types";
|
||||
|
||||
@@ -1,69 +1,129 @@
|
||||
import { create } from "zustand";
|
||||
import type { AdminUser, PermissionKey } from "../types";
|
||||
|
||||
const mockUsers: AdminUser[] = [
|
||||
{
|
||||
id: "1",
|
||||
login: "admin",
|
||||
name: "Иван",
|
||||
last_name: "Петров",
|
||||
is_active: true,
|
||||
permission_admin: true,
|
||||
permission_manage_agent: true,
|
||||
permission_view: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
login: "operator",
|
||||
name: "Анна",
|
||||
last_name: "Сидорова",
|
||||
is_active: true,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: true,
|
||||
permission_view: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
login: "viewer",
|
||||
name: "Сергей",
|
||||
last_name: "Козлов",
|
||||
is_active: true,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: false,
|
||||
permission_view: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
login: "dev_user",
|
||||
name: "Мария",
|
||||
last_name: "Новикова",
|
||||
is_active: false,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: true,
|
||||
permission_view: true,
|
||||
},
|
||||
];
|
||||
import { adminApi } from "../api/admin.api";
|
||||
import type { CreateUserPayload } from "../api/admin.api";
|
||||
|
||||
interface AdminState {
|
||||
users: AdminUser[];
|
||||
toggleActive: (id: string) => void;
|
||||
togglePermission: (id: string, permission: PermissionKey) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchUsers: () => Promise<void>;
|
||||
createUser: (payload: CreateUserPayload) => Promise<void>;
|
||||
deleteUser: (id: string, login: string) => Promise<void>;
|
||||
toggleActive: (id: string, login: string, current: boolean) => Promise<void>;
|
||||
togglePermission: (
|
||||
id: string,
|
||||
login: string,
|
||||
permission: PermissionKey,
|
||||
users: AdminUser[],
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAdminStore = create<AdminState>((set) => ({
|
||||
users: mockUsers,
|
||||
export const useAdminStore = create<AdminState>((set, get) => ({
|
||||
users: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
toggleActive: (id: string) =>
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.id === id ? { ...u, is_active: !u.is_active } : u,
|
||||
),
|
||||
})),
|
||||
fetchUsers: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await adminApi.getUsers();
|
||||
set({
|
||||
users: data.map((u) => ({
|
||||
id: String(u.id),
|
||||
login: u.login,
|
||||
name: u.name,
|
||||
last_name: u.last_name,
|
||||
is_active: u.is_active,
|
||||
permission_admin: u.permission_admin,
|
||||
permission_manage_agent: u.permission_manage_agent,
|
||||
permission_view: u.permission_view,
|
||||
})),
|
||||
loading: false,
|
||||
});
|
||||
} catch (e) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : "Failed to fetch users",
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
togglePermission: (id: string, permission: PermissionKey) =>
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.id === id ? { ...u, [permission]: !u[permission] } : u,
|
||||
),
|
||||
})),
|
||||
createUser: async (payload) => {
|
||||
try {
|
||||
await adminApi.createUser(payload);
|
||||
await get().fetchUsers();
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "Failed to create user" });
|
||||
}
|
||||
},
|
||||
|
||||
deleteUser: async (id, login) => {
|
||||
try {
|
||||
await adminApi.deleteUser(login);
|
||||
set((state) => ({
|
||||
users: state.users.filter((u) => u.id !== id),
|
||||
}));
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "Failed to delete user" });
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive: async (id, login, current) => {
|
||||
try {
|
||||
if (current) {
|
||||
await adminApi.deactivateUser(login);
|
||||
} else {
|
||||
await adminApi.activateUser(login);
|
||||
}
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.id === id ? { ...u, is_active: !current } : u,
|
||||
),
|
||||
}));
|
||||
} catch (e) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : "Failed to toggle active",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
togglePermission: async (id, login, permission, users) => {
|
||||
const user = users.find((u) => u.id === id);
|
||||
if (!user) return;
|
||||
|
||||
const newPermissions = {
|
||||
is_active: user.is_active,
|
||||
permission_admin:
|
||||
permission === "permission_admin"
|
||||
? !user.permission_admin
|
||||
: user.permission_admin,
|
||||
permission_manage_agent:
|
||||
permission === "permission_manage_agent"
|
||||
? !user.permission_manage_agent
|
||||
: user.permission_manage_agent,
|
||||
permission_view:
|
||||
permission === "permission_view"
|
||||
? !user.permission_view
|
||||
: user.permission_view,
|
||||
};
|
||||
|
||||
try {
|
||||
await adminApi.updatePermissions(login, newPermissions);
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.id === id
|
||||
? {
|
||||
...u,
|
||||
[permission]: !u[permission],
|
||||
}
|
||||
: u,
|
||||
),
|
||||
}));
|
||||
} catch (e) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : "Failed to update permissions",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,9 @@ import type {
|
||||
RegistrationRequest,
|
||||
DeployAgentsRequest,
|
||||
DeployResponse,
|
||||
SystemMetrics,
|
||||
} from "../types/agent.types";
|
||||
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||
|
||||
class AgentApiService {
|
||||
private readonly basePath = "/agents";
|
||||
@@ -162,6 +164,18 @@ class AgentApiService {
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type LogLevel = "INFO" | "WARNING" | "ERROR" | "FATAL";
|
||||
export type LogLevel = "info" | "warning" | "error" | "fatal";
|
||||
|
||||
interface LogFilterState {
|
||||
searchQuery: string;
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
selectedLogLevels: LogLevel[];
|
||||
selectedLogLevel: LogLevel | null;
|
||||
selectedService: string;
|
||||
selectedAgent: string;
|
||||
limit: number;
|
||||
@@ -15,7 +15,7 @@ interface LogFilterState {
|
||||
setSearchQuery: (query: string) => void;
|
||||
setStartDate: (date: Date | null) => void;
|
||||
setEndDate: (date: Date | null) => void;
|
||||
toggleLogLevel: (level: LogLevel) => void;
|
||||
setSelectedLogLevel: (level: LogLevel | null) => void;
|
||||
setSelectedService: (service: string) => void;
|
||||
setSelectedAgent: (agent: string) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
@@ -36,7 +36,7 @@ export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||
searchQuery: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
|
||||
selectedLogLevel: null,
|
||||
selectedService: "",
|
||||
selectedAgent: "",
|
||||
limit: 100,
|
||||
@@ -45,14 +45,7 @@ export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setStartDate: (date) => set({ startDate: date }),
|
||||
setEndDate: (date) => set({ endDate: date }),
|
||||
toggleLogLevel: (level) => {
|
||||
const { selectedLogLevels } = get();
|
||||
if (selectedLogLevels.includes(level)) {
|
||||
set({ selectedLogLevels: selectedLogLevels.filter((l) => l !== level) });
|
||||
} else {
|
||||
set({ selectedLogLevels: [...selectedLogLevels, level] });
|
||||
}
|
||||
},
|
||||
setSelectedLogLevel: (level) => set({ selectedLogLevel: level }),
|
||||
setSelectedService: (service) => set({ selectedService: service }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
setLimit: (limit) => set({ limit }),
|
||||
@@ -63,7 +56,7 @@ export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||
searchQuery: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
|
||||
selectedLogLevel: null,
|
||||
selectedService: "",
|
||||
selectedAgent: "",
|
||||
limit: 100,
|
||||
@@ -72,9 +65,17 @@ export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||
},
|
||||
|
||||
getFilters: () => {
|
||||
const { selectedLogLevels, selectedService, selectedAgent, startDate, endDate, limit, offset } = get();
|
||||
const {
|
||||
selectedLogLevel,
|
||||
selectedService,
|
||||
selectedAgent,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
} = get();
|
||||
return {
|
||||
level: selectedLogLevels.length > 0 ? selectedLogLevels.join(",") : undefined,
|
||||
level: selectedLogLevel || undefined,
|
||||
service: selectedService || undefined,
|
||||
agent: selectedAgent || undefined,
|
||||
date_from: startDate ? startDate.toISOString() : undefined,
|
||||
|
||||
@@ -42,11 +42,11 @@ export interface TokenUser {
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
agent: string;
|
||||
level: string;
|
||||
message: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
Agent: string;
|
||||
Level: string;
|
||||
Message: string;
|
||||
Service: string;
|
||||
Timestamp: string;
|
||||
}
|
||||
|
||||
export interface InsertLogRequest {
|
||||
@@ -62,7 +62,7 @@ export interface InsertLogsRequest {
|
||||
}
|
||||
|
||||
export interface LogFilters {
|
||||
level?: string;
|
||||
level?: string | string[];
|
||||
service?: string;
|
||||
agent?: string;
|
||||
date_from?: string;
|
||||
@@ -118,3 +118,14 @@ export interface DeployResponse {
|
||||
message?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,25 +13,25 @@ const logLevelColors: Record<
|
||||
LogLevel,
|
||||
{ bg: string; text: string; border: string }
|
||||
> = {
|
||||
INFO: {
|
||||
bg: "var(--info-bg)",
|
||||
text: "var(--info-text)",
|
||||
border: "var(--info-border)",
|
||||
info: {
|
||||
bg: "rgba(59, 130, 246, 0.1)",
|
||||
text: "#3b82f6",
|
||||
border: "rgba(59, 130, 246, 0.3)",
|
||||
},
|
||||
WARNING: {
|
||||
bg: "var(--warning-bg)",
|
||||
text: "var(--warning-text)",
|
||||
border: "var(--warning-border)",
|
||||
warning: {
|
||||
bg: "rgba(245, 158, 11, 0.1)",
|
||||
text: "#f59e0b",
|
||||
border: "rgba(245, 158, 11, 0.3)",
|
||||
},
|
||||
ERROR: {
|
||||
error: {
|
||||
bg: "var(--error-bg)",
|
||||
text: "var(--error-text)",
|
||||
border: "var(--error-border)",
|
||||
},
|
||||
FATAL: {
|
||||
bg: "var(--fatal-bg)",
|
||||
text: "var(--fatal-text)",
|
||||
border: "var(--fatal-border)",
|
||||
fatal: {
|
||||
bg: "rgba(168, 85, 247, 0.1)",
|
||||
text: "#a855f7",
|
||||
border: "rgba(168, 85, 247, 0.3)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,13 +50,13 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
searchQuery,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedLogLevels,
|
||||
selectedLogLevel,
|
||||
selectedService,
|
||||
selectedAgent,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
toggleLogLevel,
|
||||
setSelectedLogLevel,
|
||||
setSelectedService,
|
||||
setSelectedAgent,
|
||||
resetFilters,
|
||||
@@ -67,6 +67,9 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
|
||||
const [localService, setLocalService] = useState(selectedService);
|
||||
const [localAgent, setLocalAgent] = useState(selectedAgent);
|
||||
const [localLevel, setLocalLevel] = useState<LogLevel | null>(
|
||||
selectedLogLevel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearchQuery(searchQuery);
|
||||
@@ -88,10 +91,15 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
setLocalAgent(selectedAgent);
|
||||
}, [selectedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalLevel(selectedLogLevel);
|
||||
}, [selectedLogLevel]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
setSearchQuery(localSearchQuery);
|
||||
setStartDate(localStartDate);
|
||||
setEndDate(localEndDate);
|
||||
setSelectedLogLevel(localLevel);
|
||||
setSelectedService(localService);
|
||||
setSelectedAgent(localAgent);
|
||||
onApply();
|
||||
@@ -99,6 +107,7 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
localSearchQuery,
|
||||
localStartDate,
|
||||
localEndDate,
|
||||
localLevel,
|
||||
localService,
|
||||
localAgent,
|
||||
onApply,
|
||||
@@ -108,6 +117,7 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
setLocalSearchQuery("");
|
||||
setLocalStartDate(null);
|
||||
setLocalEndDate(null);
|
||||
setLocalLevel(null);
|
||||
setLocalService("");
|
||||
setLocalAgent("");
|
||||
resetFilters();
|
||||
@@ -121,7 +131,7 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
if (endDate) count++;
|
||||
if (selectedService) count++;
|
||||
if (selectedAgent) count++;
|
||||
if (selectedLogLevels.length < 4) count++;
|
||||
if (selectedLogLevel) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
@@ -265,21 +275,18 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Уровни логов
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
({selectedLogLevels.length}/4)
|
||||
Уровень логов
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["INFO", "WARNING", "ERROR", "FATAL"] as LogLevel[]).map(
|
||||
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
|
||||
(level) => {
|
||||
const isSelected = selectedLogLevels.includes(level);
|
||||
const isSelected = localLevel === level;
|
||||
const colors = logLevelColors[level];
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => toggleLogLevel(level)}
|
||||
onClick={() => setLocalLevel(isSelected ? null : level)}
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium transition-all border flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isSelected ? colors.bg : "transparent",
|
||||
@@ -287,11 +294,29 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
borderColor: isSelected ? colors.border : "var(--border)",
|
||||
minHeight: "36px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected) {
|
||||
e.currentTarget.style.backgroundColor = colors.text;
|
||||
e.currentTarget.style.color = "#fff";
|
||||
} else {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"rgba(128, 128, 128, 0.08)";
|
||||
e.currentTarget.style.color = "var(--text-primary)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isSelected
|
||||
? colors.bg
|
||||
: "transparent";
|
||||
e.currentTarget.style.color = isSelected
|
||||
? colors.text
|
||||
: "var(--text-secondary)";
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<FiCheck size={10} className="inline mr-1" />
|
||||
)}
|
||||
{level}
|
||||
{level.toUpperCase()}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@@ -402,6 +427,39 @@ export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLogLevel &&
|
||||
(() => {
|
||||
const colors = logLevelColors[selectedLogLevel];
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<FiTag size={10} style={{ color: colors.text }} />
|
||||
<span style={{ color: colors.text }}>
|
||||
Уровень: {selectedLogLevel.toUpperCase()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalLevel(null);
|
||||
setSelectedLogLevel(null);
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{selectedAgent && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
|
||||
@@ -51,12 +51,6 @@ export const Graph: React.FC<GraphProps> = ({
|
||||
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) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
||||
@@ -86,7 +80,6 @@ export const Graph: React.FC<GraphProps> = ({
|
||||
ref={fgRef}
|
||||
data={data}
|
||||
onNodeRightClick={handleNodeRightClick}
|
||||
onLinkRightClick={handleLinkRightClick}
|
||||
/>
|
||||
|
||||
<GraphContextMenu
|
||||
|
||||
@@ -13,11 +13,10 @@ import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||
interface ForceGraphProps {
|
||||
data: GraphData;
|
||||
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
|
||||
onLinkRightClick: (link: GraphLink, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
||||
({ data, onNodeRightClick, onLinkRightClick }, ref) => {
|
||||
({ data, onNodeRightClick }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
|
||||
|
||||
@@ -87,9 +86,47 @@ export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
||||
};
|
||||
|
||||
const getNodeColor = (node: GraphNode) => {
|
||||
if (highlightNodes.has(node.id)) return "#fbbf24";
|
||||
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) {
|
||||
case "service":
|
||||
return "#3b82f6";
|
||||
@@ -176,7 +213,6 @@ export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
||||
linkDirectionalParticles={0}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeRightClick={onNodeRightClick}
|
||||
onLinkRightClick={onLinkRightClick}
|
||||
onNodeHover={handleNodeHover}
|
||||
cooldownTicks={50}
|
||||
cooldownTime={2000}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import React from "react";
|
||||
import { FiLink, FiTrash2, FiMinusCircle } from "react-icons/fi";
|
||||
import type {
|
||||
ContextMenuState,
|
||||
GraphNode,
|
||||
GraphLink,
|
||||
GraphData,
|
||||
} from "../types";
|
||||
import { FiLink, FiTrash2 } from "react-icons/fi";
|
||||
import type { ContextMenuState, GraphNode, GraphData } from "../types";
|
||||
import { useGraphStore } from "../store/useGraphStore";
|
||||
|
||||
interface GraphContextMenuProps {
|
||||
@@ -20,7 +15,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const removeNode = useGraphStore((s) => s.removeNode);
|
||||
const removeLink = useGraphStore((s) => s.removeLink);
|
||||
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
||||
const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
|
||||
|
||||
@@ -31,11 +25,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteLink = (link: GraphLink) => {
|
||||
removeLink(link);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateLink = (node: GraphNode) => {
|
||||
toggleLinkMode();
|
||||
setSelectedNode(node);
|
||||
@@ -92,40 +81,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
{/* Режим создания связи */}
|
||||
<button
|
||||
{/* <button
|
||||
onClick={toggleLinkMode}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||
style={{
|
||||
@@ -62,7 +62,7 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
|
||||
>
|
||||
<FiLink />
|
||||
<span>{isLinkMode ? "Создание связи..." : "Добавить связь"}</span>
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
{/* Зум + */}
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface GraphNode {
|
||||
description?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
status?: "up" | "down";
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
@@ -25,3 +26,25 @@ export interface ContextMenuState {
|
||||
node: GraphNode | 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>;
|
||||
}
|
||||
|
||||
@@ -58,19 +58,45 @@ export const IDE: React.FC<IDEProps> = ({
|
||||
const createNewProject = useIDEStore((state) => state.createNewProject);
|
||||
const selectFile = useIDEStore((state) => state.selectFile);
|
||||
const updateFileContent = useIDEStore((state) => state.updateFileContent);
|
||||
const saveActiveFile = useIDEStore((state) => state.saveActiveFile);
|
||||
const closeFile = useIDEStore((state) => state.closeFile);
|
||||
const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
|
||||
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
|
||||
const initialize = useIDEStore((state) => state.initialize);
|
||||
const isInitialized = useIDEStore((state) => state.isInitialized);
|
||||
const fetchTree = useIDEStore((state) => state.fetchTree);
|
||||
const fetchInterpreters = useIDEStore((state) => state.fetchInterpreters);
|
||||
|
||||
// Инициализация файлов
|
||||
// Загружаем интерпретаторы при инициализации
|
||||
useEffect(() => {
|
||||
fetchInterpreters();
|
||||
}, []);
|
||||
|
||||
// Обработка Ctrl+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
saveActiveFile();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [saveActiveFile]);
|
||||
|
||||
// При загрузке пробуем загрузить дерево с сервера
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
const filesToInit = externalFiles || defaultInitialFiles;
|
||||
initialize(filesToInit);
|
||||
fetchTree().catch(() => {
|
||||
// Только при ошибке — используем моковые данные
|
||||
const state = useIDEStore.getState();
|
||||
if (!state.files) {
|
||||
const filesToInit = externalFiles || defaultInitialFiles;
|
||||
initialize(filesToInit);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isInitialized, externalFiles, initialize]);
|
||||
}, [isInitialized]);
|
||||
|
||||
// Если проект не открыт
|
||||
if (!files) {
|
||||
@@ -249,10 +275,30 @@ export const IDE: React.FC<IDEProps> = ({
|
||||
)}
|
||||
{!onBack && <div />}
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{activeFile ? `${activeFile.name} - ` : ""}
|
||||
{activeFile
|
||||
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
|
||||
: ""}
|
||||
{files.name}
|
||||
</span>
|
||||
<div style={{ width: 60 }} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{activeFile?.dirty && (
|
||||
<button
|
||||
onClick={saveActiveFile}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: c.textPrimary,
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
title="Сохранить (Ctrl+S)"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
<div style={{ width: "260px", flexShrink: 0 }}>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { apiClient } from "@/shared/api/axios.instance";
|
||||
import type { Interpreter } from "../types";
|
||||
|
||||
export interface ScriptNodeDto {
|
||||
id: number;
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
content?: string;
|
||||
children?: string[];
|
||||
interpreter_id?: number;
|
||||
}
|
||||
|
||||
export interface ScriptResponse {
|
||||
id: number;
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScriptPayload {
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UpdateScriptPayload {
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
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
|
||||
export const scriptsApi = {
|
||||
getInterpreters: async (): Promise<Interpreter[]> => {
|
||||
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getTree: async (): Promise<ScriptNodeDto[]> => {
|
||||
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createScript: async (
|
||||
payload: CreateScriptPayload,
|
||||
): Promise<ScriptResponse> => {
|
||||
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
updateScript: async (
|
||||
id: number,
|
||||
payload: UpdateScriptPayload,
|
||||
): Promise<ScriptResponse> => {
|
||||
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
deleteScript: async (id: number): Promise<void> => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Загружаем интерпретаторы перед открытием меню
|
||||
if (store.interpreters.length === 0) {
|
||||
store.fetchInterpreters();
|
||||
}
|
||||
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
|
||||
};
|
||||
|
||||
@@ -55,9 +59,18 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||
};
|
||||
|
||||
// Загружаем интерпретаторы при монтировании компонента
|
||||
useEffect(() => {
|
||||
if (store.interpreters.length === 0) {
|
||||
store.fetchInterpreters();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredFiles = store.searchQuery
|
||||
? filterTree(files, store.searchQuery)
|
||||
: files;
|
||||
? (files.children || [])
|
||||
.map((child) => filterTree(child, store.searchQuery))
|
||||
.filter((child): child is FileNode => child !== null)
|
||||
: files.children || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (store.searchQuery && files) {
|
||||
@@ -185,29 +198,6 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
borderBottom: "1px solid #3e3e42",
|
||||
}}
|
||||
>
|
||||
<FiFolder size={14} color="#858585" />
|
||||
<span
|
||||
style={{
|
||||
color: "#cccccc",
|
||||
fontWeight: 600,
|
||||
fontSize: "11px",
|
||||
letterSpacing: "0.3px",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{files.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
||||
<div
|
||||
@@ -262,19 +252,21 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{filteredFiles ? (
|
||||
<FileTreeItem
|
||||
node={filteredFiles}
|
||||
level={0}
|
||||
onFileSelect={store.selectFile}
|
||||
selectedFile={store.activeFile?.path || null}
|
||||
onContextMenu={handleNodeContextMenu}
|
||||
expandedFolders={store.expandedFolders}
|
||||
onToggleFolder={store.toggleFolder}
|
||||
onDelete={store.handleDeleteNode}
|
||||
isRoot
|
||||
searchQuery={store.searchQuery}
|
||||
/>
|
||||
{filteredFiles.length > 0 ? (
|
||||
filteredFiles.map((child, idx) => (
|
||||
<FileTreeItem
|
||||
key={idx}
|
||||
node={child}
|
||||
level={0}
|
||||
onFileSelect={store.selectFile}
|
||||
selectedFile={store.activeFile?.path || null}
|
||||
onContextMenu={handleNodeContextMenu}
|
||||
expandedFolders={store.expandedFolders}
|
||||
onToggleFolder={store.toggleFolder}
|
||||
onDelete={store.handleDeleteNode}
|
||||
searchQuery={store.searchQuery}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
@@ -339,8 +331,13 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||
? store.dialog.node.name
|
||||
: ""
|
||||
}
|
||||
onConfirm={store.handleDialogConfirm}
|
||||
onConfirm={(value, interpreterId) => {
|
||||
store.handleDialogConfirm(value, interpreterId);
|
||||
}}
|
||||
onCancel={() => store.setDialog(null)}
|
||||
interpreters={
|
||||
store.dialog.type === "newFile" ? store.interpreters : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,23 +2,24 @@ import React from "react";
|
||||
import type { FileNode } from "../types";
|
||||
import { FilePickerItem } from "./FilePickerItem";
|
||||
import { useFilePickerStore } from "../store/useFilePickerStore";
|
||||
import { TerminalOutput } from "@/modules/terminal";
|
||||
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
||||
|
||||
interface FilePickerProps {
|
||||
files: FileNode;
|
||||
onRun?: (path: string) => void;
|
||||
}
|
||||
|
||||
const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({
|
||||
node,
|
||||
level,
|
||||
}) => {
|
||||
const FilePickerTree: React.FC<{
|
||||
node: FileNode;
|
||||
level: number;
|
||||
onRun?: (path: string) => void;
|
||||
}> = ({ node, level, onRun }) => {
|
||||
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 nodePath = node.path || node.name;
|
||||
const isExpanded = expandedFolders.has(nodePath);
|
||||
const isSelected = node.type === "file" && selectedPaths.has(nodePath);
|
||||
|
||||
if (node.type === "file") {
|
||||
return (
|
||||
@@ -26,9 +27,8 @@ const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({
|
||||
name={node.name}
|
||||
type="file"
|
||||
path={nodePath}
|
||||
isSelected={isSelected}
|
||||
level={level}
|
||||
onToggleSelect={toggleSelection}
|
||||
onRun={onRun}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,14 +44,22 @@ const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({
|
||||
onToggleFolder={toggleFolder}
|
||||
>
|
||||
{node.children?.map((child, idx) => (
|
||||
<FilePickerTree key={idx} node={child} level={level + 1} />
|
||||
<FilePickerTree
|
||||
key={idx}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onRun={onRun}
|
||||
/>
|
||||
))}
|
||||
</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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -60,7 +68,16 @@ export const FilePicker: React.FC<FilePickerProps> = ({ files }) => {
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
}}
|
||||
>
|
||||
<FilePickerTree node={files} level={0} />
|
||||
{/* Terminal — сверху, над списком файлов */}
|
||||
{terminalOpen && jobs.length > 0 && (
|
||||
<div style={{ height: 250 }}>
|
||||
<TerminalOutput />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(files.children || []).map((child, idx) => (
|
||||
<FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,30 +4,31 @@ import {
|
||||
FiChevronDown,
|
||||
FiFile,
|
||||
FiFolder,
|
||||
FiPlay,
|
||||
} from "react-icons/fi";
|
||||
|
||||
interface FilePickerItemProps {
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
path: string;
|
||||
isSelected?: boolean;
|
||||
isExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
onToggleSelect?: (path: string) => void;
|
||||
onToggleFolder?: (path: string) => void;
|
||||
onRun?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||
name,
|
||||
type,
|
||||
path,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
children,
|
||||
level,
|
||||
onToggleSelect,
|
||||
onToggleFolder,
|
||||
onRun,
|
||||
}) => {
|
||||
const isFolder = type === "folder";
|
||||
const extension = name.includes(".")
|
||||
@@ -120,40 +121,40 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox — только у файлов */}
|
||||
{!isFolder && onToggleSelect && (
|
||||
<div
|
||||
{/* Run button — только у файлов */}
|
||||
{!isFolder && onRun && (
|
||||
<button
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
border: isSelected
|
||||
? "2px solid #0e639c"
|
||||
: "2px solid var(--border)",
|
||||
borderRadius: "3px",
|
||||
backgroundColor: isSelected ? "#0e639c" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4px",
|
||||
backgroundColor: "transparent",
|
||||
border: "1px solid transparent",
|
||||
borderRadius: "3px",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
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 && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2 6L5 9L10 3"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<FiPlay size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import type { Interpreter } from "../types";
|
||||
|
||||
interface InputDialogProps {
|
||||
title: string;
|
||||
initialValue?: string;
|
||||
onConfirm: (value: string) => void;
|
||||
onConfirm: (value: string, interpreterId?: number) => void;
|
||||
onCancel: () => void;
|
||||
interpreters?: Interpreter[];
|
||||
}
|
||||
|
||||
export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
@@ -12,8 +14,12 @@ export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
initialValue = "",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
interpreters,
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [interpreterId, setInterpreterId] = useState<number | undefined>(
|
||||
interpreters?.[0]?.id,
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,6 +27,8 @@ export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const showInterpreterDropdown = interpreters && interpreters.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -59,7 +67,7 @@ export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
||||
Enter a new name
|
||||
Enter a name
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -67,7 +75,9 @@ export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && value.trim() && onConfirm(value.trim())
|
||||
e.key === "Enter" &&
|
||||
value.trim() &&
|
||||
onConfirm(value.trim(), interpreterId)
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -77,10 +87,48 @@ export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
borderRadius: "6px",
|
||||
color: "#ccc",
|
||||
fontSize: "14px",
|
||||
marginBottom: "20px",
|
||||
marginBottom: showInterpreterDropdown ? "12px" : "20px",
|
||||
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
|
||||
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
|
||||
>
|
||||
@@ -99,7 +147,9 @@ export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => value.trim() && onConfirm(value.trim())}
|
||||
onClick={() =>
|
||||
value.trim() && onConfirm(value.trim(), interpreterId)
|
||||
}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -106,6 +106,17 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
>
|
||||
<GoFile />
|
||||
<span>{file.name}</span>
|
||||
{file.dirty && (
|
||||
<span
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fbbf24",
|
||||
marginLeft: "-4px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { FileNode } from "../types";
|
||||
import type { FileNode, Interpreter, DialogState } from "../types";
|
||||
import {
|
||||
addPaths,
|
||||
getAllFolderPaths,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
addNode,
|
||||
renameNode,
|
||||
} from "../helpers/fileTree";
|
||||
import { scriptsApi } from "../api/scripts.api";
|
||||
|
||||
export const initialFiles: FileNode = {
|
||||
name: "my-project",
|
||||
@@ -38,27 +39,30 @@ export const initialFiles: FileNode = {
|
||||
],
|
||||
};
|
||||
|
||||
interface IDEFileNode extends FileNode {
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
interface IDEState {
|
||||
// Файловая система
|
||||
files: FileNode | null;
|
||||
openFiles: FileNode[];
|
||||
activeFile: FileNode | null;
|
||||
openFiles: IDEFileNode[];
|
||||
activeFile: IDEFileNode | null;
|
||||
expandedFolders: Set<string>;
|
||||
searchQuery: string;
|
||||
showSearch: boolean;
|
||||
isInitialized: boolean;
|
||||
interpreters: Interpreter[];
|
||||
|
||||
// Диалоги и контекстные меню
|
||||
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
||||
dialog: {
|
||||
type: "newFile" | "newFolder" | "rename";
|
||||
node: FileNode | null;
|
||||
} | null;
|
||||
dialog: DialogState | null;
|
||||
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
||||
|
||||
// Действия с файлами
|
||||
selectFile: (node: FileNode) => void;
|
||||
updateFileContent: (content: string) => void;
|
||||
saveActiveFile: () => Promise<void>;
|
||||
closeFile: (file: FileNode) => void;
|
||||
closeAllFiles: () => void;
|
||||
closeOtherFiles: (file: FileNode) => void;
|
||||
@@ -72,6 +76,25 @@ interface IDEState {
|
||||
deleteRoot: () => void;
|
||||
createNewProject: () => void;
|
||||
|
||||
// Интерпретаторы
|
||||
fetchInterpreters: () => Promise<void>;
|
||||
|
||||
// API методы
|
||||
fetchTree: () => Promise<void>;
|
||||
createScript: (payload: {
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
createFolder: (path: string) => Promise<void>;
|
||||
updateScript: (
|
||||
id: number,
|
||||
payload: { content: string; interpreter_id: number; path: string },
|
||||
) => Promise<void>;
|
||||
deleteScript: (id: number) => Promise<void>;
|
||||
deleteFolder: (payload: { path: string }) => Promise<void>;
|
||||
saveActiveFile: () => Promise<void>;
|
||||
|
||||
// Поиск
|
||||
setSearchQuery: (query: string) => void;
|
||||
toggleSearch: () => void;
|
||||
@@ -94,8 +117,8 @@ interface IDEState {
|
||||
initialize: (initialFiles: FileNode) => void;
|
||||
|
||||
// Диалог подтверждения
|
||||
handleDialogConfirm: (value: string) => void;
|
||||
handleDeleteNode: (node: FileNode) => void;
|
||||
handleDialogConfirm: (value: string, interpreterId?: number) => Promise<void>;
|
||||
handleDeleteNode: (node: FileNode) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useIDEStore = create<IDEState>((set, get) => ({
|
||||
@@ -111,6 +134,7 @@ export const useIDEStore = create<IDEState>((set, get) => ({
|
||||
contextMenu: null,
|
||||
dialog: null,
|
||||
tabContextMenu: null,
|
||||
interpreters: [],
|
||||
|
||||
// Инициализация
|
||||
initialize: (initialFiles: FileNode) => {
|
||||
@@ -142,7 +166,7 @@ export const useIDEStore = create<IDEState>((set, get) => ({
|
||||
updateFileContent: (content: string) => {
|
||||
const { activeFile, files } = get();
|
||||
if (activeFile && files) {
|
||||
const updatedFile = { ...activeFile, content };
|
||||
const updatedFile = { ...activeFile, content, dirty: true };
|
||||
set({ activeFile: updatedFile });
|
||||
set((state) => ({
|
||||
openFiles: state.openFiles.map((f) =>
|
||||
@@ -275,6 +299,150 @@ 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: загрузка дерева с сервера
|
||||
fetchTree: async () => {
|
||||
try {
|
||||
const data = await scriptsApi.getTree();
|
||||
const { expandedFolders } = get();
|
||||
|
||||
const convertItem = (item: any): FileNode => {
|
||||
const node: FileNode = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type === "folder" ? "folder" : "file",
|
||||
content: item.content || "",
|
||||
path: item.name,
|
||||
interpreter_id: item.interpreter_id,
|
||||
};
|
||||
|
||||
if (item.type === "folder") {
|
||||
node.children = [];
|
||||
if (item.children && Array.isArray(item.children)) {
|
||||
node.children = item.children.map((child: any) => {
|
||||
const childNode = convertItem(child);
|
||||
childNode.path = `${item.name}/${child.name}`;
|
||||
return childNode;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const roots = data.map((item) => convertItem(item));
|
||||
|
||||
set({
|
||||
files: {
|
||||
name: "scripts",
|
||||
type: "folder",
|
||||
children: roots,
|
||||
},
|
||||
expandedFolders,
|
||||
isInitialized: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch tree:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: создание скрипта
|
||||
createScript: async (payload) => {
|
||||
try {
|
||||
await scriptsApi.createScript(payload);
|
||||
await get().fetchTree();
|
||||
} catch (e) {
|
||||
console.error("Failed to create script:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// 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: обновление скрипта
|
||||
updateScript: async (id, payload) => {
|
||||
try {
|
||||
await scriptsApi.updateScript(id, payload);
|
||||
} catch (e) {
|
||||
console.error("Failed to update script:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: удаление скрипта
|
||||
deleteScript: async (id) => {
|
||||
try {
|
||||
await scriptsApi.deleteScript(id);
|
||||
await get().fetchTree();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete script:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: сохранение активного файла
|
||||
saveActiveFile: async () => {
|
||||
const { activeFile } = get();
|
||||
if (!activeFile || !activeFile.id) return;
|
||||
|
||||
try {
|
||||
await scriptsApi.updateScript(activeFile.id, {
|
||||
content: activeFile.content || "",
|
||||
interpreter_id: activeFile.interpreter_id || 0,
|
||||
path: activeFile.path || "",
|
||||
});
|
||||
set((state) => ({
|
||||
activeFile: state.activeFile
|
||||
? { ...state.activeFile, dirty: false }
|
||||
: null,
|
||||
openFiles: state.openFiles.map((f) =>
|
||||
f.path === state.activeFile?.path ? { ...f, dirty: false } : f,
|
||||
),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to save file:", e);
|
||||
}
|
||||
},
|
||||
|
||||
// Поиск
|
||||
setSearchQuery: (query: string) => {
|
||||
set({ searchQuery: query });
|
||||
@@ -290,9 +458,8 @@ export const useIDEStore = create<IDEState>((set, get) => ({
|
||||
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
|
||||
|
||||
// Подтверждение диалога
|
||||
handleDialogConfirm: (value: string) => {
|
||||
const { dialog, files, refreshFiles, toggleFolder, autoExpandPaths } =
|
||||
get();
|
||||
handleDialogConfirm: async (value: string, interpreterId?: number) => {
|
||||
const { dialog, files, toggleFolder, autoExpandPaths } = get();
|
||||
if (!dialog) return;
|
||||
|
||||
if (dialog.type === "rename" && dialog.node) {
|
||||
@@ -309,97 +476,166 @@ export const useIDEStore = create<IDEState>((set, get) => ({
|
||||
alert(`"${value}" already exists.`);
|
||||
return;
|
||||
}
|
||||
const newFiles = renameNode(
|
||||
files!,
|
||||
dialog.node.path || dialog.node.name,
|
||||
value,
|
||||
);
|
||||
if (newFiles) {
|
||||
refreshFiles(newFiles);
|
||||
|
||||
const oldPath = dialog.node.path || dialog.node.name;
|
||||
const newPath = parentPath ? `${parentPath}/${value}` : value;
|
||||
|
||||
// Сохраняем раскрытые папки
|
||||
const savedExpandedFolders = new Set(get().expandedFolders);
|
||||
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем родительский путь
|
||||
let parentPath: string;
|
||||
|
||||
if (!dialog.node) {
|
||||
parentPath = files!.path || files!.name;
|
||||
parentPath = "";
|
||||
} else if (dialog.node.type === "folder") {
|
||||
parentPath = dialog.node.path || dialog.node.name;
|
||||
} else {
|
||||
const pathParts = (dialog.node.path || dialog.node.name).split("/");
|
||||
pathParts.pop();
|
||||
parentPath = pathParts.join("/") || files!.path || files!.name;
|
||||
parentPath = pathParts.join("/");
|
||||
}
|
||||
|
||||
const parentNode = findNode(files!, parentPath);
|
||||
if (
|
||||
parentNode?.children?.some(
|
||||
(c) => c.name.toLowerCase() === value.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
alert(`"${value}" already exists in this folder.`);
|
||||
set({ dialog: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let newFiles: FileNode | null = null;
|
||||
let createdNode: FileNode | null = null;
|
||||
// Проверяем наличие расширения
|
||||
const hasExtension =
|
||||
value.includes(".") && value.split(".").pop() !== value;
|
||||
let finalName = value;
|
||||
let isFile = false;
|
||||
|
||||
// Если диалог создания файла
|
||||
if (dialog.type === "newFile") {
|
||||
createdNode = { name: value, type: "file", content: "" };
|
||||
newFiles = addNode(files!, parentPath, createdNode);
|
||||
isFile = true;
|
||||
// Если нет расширения — добавляем .txt
|
||||
if (!hasExtension) {
|
||||
finalName = `${value}.txt`;
|
||||
}
|
||||
} else if (dialog.type === "newFolder") {
|
||||
createdNode = { name: value, type: "folder", children: [] };
|
||||
newFiles = addNode(files!, parentPath, createdNode);
|
||||
// Если диалог создания папки — но имя с расширением, считаем файлом
|
||||
if (hasExtension) {
|
||||
isFile = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (newFiles) {
|
||||
const allParentPaths: string[] = [];
|
||||
let current = parentPath;
|
||||
while (current) {
|
||||
allParentPaths.push(current);
|
||||
const parts = current.split("/");
|
||||
parts.pop();
|
||||
current = parts.join("/");
|
||||
}
|
||||
allParentPaths.forEach((p) => {
|
||||
if (!get().expandedFolders.has(p)) {
|
||||
toggleFolder(p);
|
||||
const fullPath = parentPath ? `${parentPath}/${finalName}` : finalName;
|
||||
|
||||
// Сохраняем раскрытые папки ДО перезагрузки дерева
|
||||
const savedExpandedFolders = new Set(get().expandedFolders);
|
||||
|
||||
try {
|
||||
// Создание папки
|
||||
if (dialog.type === "newFolder" && !isFile) {
|
||||
await scriptsApi.createFolder(fullPath);
|
||||
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));
|
||||
|
||||
if (createdNode && createdNode.type === "file") {
|
||||
const findAndOpen = (node: FileNode, name: string): FileNode | null => {
|
||||
if (node.name === name && node.type === "file") return node;
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = findAndOpen(child, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const openedFile = findAndOpen(newFiles, value);
|
||||
refreshFiles(newFiles, openedFile || undefined);
|
||||
// Раскрываем родительскую цепочку
|
||||
autoExpandPaths(new Set(allParentPaths));
|
||||
} else {
|
||||
refreshFiles(newFiles);
|
||||
// Создание файла
|
||||
const result = await scriptsApi.createScript({
|
||||
content: "",
|
||||
interpreter_id: interpreterId || 0,
|
||||
path: fullPath,
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const createdNode: FileNode = {
|
||||
id: result.id,
|
||||
name: finalName,
|
||||
type: "file",
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
interpreter_id: result.interpreter_id,
|
||||
};
|
||||
get().selectFile(createdNode);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create:", e);
|
||||
}
|
||||
|
||||
set({ dialog: null });
|
||||
},
|
||||
|
||||
// Удаление узла
|
||||
handleDeleteNode: (node: FileNode) => {
|
||||
const { files, refreshFiles } = get();
|
||||
handleDeleteNode: async (node: FileNode) => {
|
||||
const { files } = get();
|
||||
const isRootNode = node.path === files?.path;
|
||||
if (isRootNode) {
|
||||
get().deleteRoot();
|
||||
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
||||
const newFiles = deleteNode(files!, node.path || node.name);
|
||||
if (newFiles) refreshFiles(newFiles);
|
||||
try {
|
||||
if (node.type === "folder") {
|
||||
await get().deleteFolder({ path: node.path || node.name });
|
||||
} else if (node.id) {
|
||||
await get().deleteScript(node.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete:", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,15 @@ export interface FileNode {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface Interpreter {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
argv: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -15,6 +24,7 @@ export interface ContextMenuState {
|
||||
export interface DialogState {
|
||||
type: "newFile" | "newFolder" | "rename";
|
||||
node: FileNode | null;
|
||||
interpreterId?: number;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +1,147 @@
|
||||
import { useMemo } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Graph,
|
||||
type GraphData,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} 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";
|
||||
|
||||
const buildGraphFromAgents = (): GraphData => {
|
||||
const agents = useAgentStore.getState().agents;
|
||||
const buildGraphFromApi = (apiData: any, agents: any[]): GraphData => {
|
||||
const nodes: GraphNode[] = [];
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
// Build a map of service statuses from agents
|
||||
const serviceStatusMap = new Map<string, "up" | "down">();
|
||||
agents.forEach((agent) => {
|
||||
// Агент как узел
|
||||
nodes.push({
|
||||
id: agent.label,
|
||||
name: agent.label,
|
||||
type: "agent",
|
||||
val: 8,
|
||||
description: `Агент: ${agent.label}`,
|
||||
});
|
||||
|
||||
// Сервисы агента как узлы + связи
|
||||
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",
|
||||
});
|
||||
const services = agent.services || [];
|
||||
services.forEach((svc: string) => {
|
||||
// Parse "serviceName:up" or "serviceName:down"
|
||||
const parts = svc.split(":");
|
||||
const svcName = parts[0];
|
||||
const status = parts[1] === "down" ? "down" : "up";
|
||||
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
|
||||
});
|
||||
});
|
||||
|
||||
if (!apiData?.nodes) return { nodes, links };
|
||||
|
||||
Object.entries(apiData.nodes).forEach(
|
||||
([agentLabel, agentNode]: [string, any]) => {
|
||||
// Агент как узел
|
||||
nodes.push({
|
||||
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 };
|
||||
};
|
||||
|
||||
export const GraphsPage = () => {
|
||||
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(() => {
|
||||
return buildGraphFromAgents();
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
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 (
|
||||
<div className="h-full">
|
||||
<Graph initialData={graphData} />
|
||||
|
||||
@@ -15,35 +15,35 @@ import {
|
||||
} from "react-icons/fi";
|
||||
|
||||
const logLevelIcons: Record<string, React.ReactNode> = {
|
||||
INFO: <FiInfo size={14} />,
|
||||
WARNING: <FiAlertTriangle size={14} />,
|
||||
ERROR: <FiAlertCircle size={14} />,
|
||||
FATAL: <FiXOctagon size={14} />,
|
||||
info: <FiInfo size={14} />,
|
||||
warning: <FiAlertTriangle size={14} />,
|
||||
error: <FiAlertCircle size={14} />,
|
||||
fatal: <FiXOctagon size={14} />,
|
||||
};
|
||||
|
||||
const logLevelColors: Record<
|
||||
string,
|
||||
{ bg: string; text: string; border: string }
|
||||
> = {
|
||||
INFO: {
|
||||
bg: "var(--info-bg)",
|
||||
text: "var(--info-text)",
|
||||
border: "var(--info-border)",
|
||||
info: {
|
||||
bg: "rgba(59, 130, 246, 0.1)",
|
||||
text: "#3b82f6",
|
||||
border: "rgba(59, 130, 246, 0.3)",
|
||||
},
|
||||
WARNING: {
|
||||
bg: "var(--warning-bg)",
|
||||
text: "var(--warning-text)",
|
||||
border: "var(--warning-border)",
|
||||
warning: {
|
||||
bg: "rgba(245, 158, 11, 0.1)",
|
||||
text: "#f59e0b",
|
||||
border: "rgba(245, 158, 11, 0.3)",
|
||||
},
|
||||
ERROR: {
|
||||
error: {
|
||||
bg: "var(--error-bg)",
|
||||
text: "var(--error-text)",
|
||||
border: "var(--error-border)",
|
||||
},
|
||||
FATAL: {
|
||||
bg: "var(--fatal-bg)",
|
||||
text: "var(--fatal-text)",
|
||||
border: "var(--fatal-border)",
|
||||
fatal: {
|
||||
bg: "rgba(168, 85, 247, 0.1)",
|
||||
text: "#a855f7",
|
||||
border: "rgba(168, 85, 247, 0.3)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -306,13 +306,13 @@ export const LogsPage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => {
|
||||
const level = log.level || "INFO";
|
||||
const level = log.Level?.toLowerCase() || "info";
|
||||
const colors =
|
||||
logLevelColors[level] || logLevelColors.INFO;
|
||||
logLevelColors[level] || logLevelColors.info;
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-t"
|
||||
className="border-t transition-colors"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
backgroundColor:
|
||||
@@ -320,12 +320,22 @@ export const LogsPage: React.FC = () => {
|
||||
? "var(--card-bg)"
|
||||
: "var(--bg-secondary)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--border)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
index % 2 === 0
|
||||
? "var(--card-bg)"
|
||||
: "var(--bg-secondary)";
|
||||
}}
|
||||
>
|
||||
<td
|
||||
className="px-4 py-3 text-sm font-mono whitespace-nowrap"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{formatTimestamp(log.timestamp)}
|
||||
{formatTimestamp(log.Timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
@@ -344,19 +354,19 @@ export const LogsPage: React.FC = () => {
|
||||
className="px-4 py-3 text-sm"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{log.service || "—"}
|
||||
{log.Service || "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 text-sm font-mono"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{log.agent || "—"}
|
||||
{log.Agent || "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 text-sm"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{log.message || "—"}
|
||||
{log.Message || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -1,166 +1,116 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FiEdit3, FiPlay } from "react-icons/fi";
|
||||
import { FilePicker, useFilePickerStore } from "../modules/ide";
|
||||
import { FiEdit3 } from "react-icons/fi";
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import { FaSpinner } from "react-icons/fa";
|
||||
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 { scriptsApi } from "../modules/ide/api/scripts.api";
|
||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
|
||||
const mockFiles: FileNode = {
|
||||
name: "templates",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "python-basic",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "src",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "main.py",
|
||||
type: "file",
|
||||
content:
|
||||
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()',
|
||||
},
|
||||
{
|
||||
name: "utils.py",
|
||||
type: "file",
|
||||
content: "def helper():\n return 42",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "file",
|
||||
content: "# Python Project\n\nA basic Python project.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "react-starter",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "src",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "App.tsx",
|
||||
type: "file",
|
||||
content:
|
||||
'import React from "react";\n\nexport const App: React.FC = () => {\n return <div>Hello React!</div>;\n};',
|
||||
},
|
||||
{
|
||||
name: "index.tsx",
|
||||
type: "file",
|
||||
content:
|
||||
'import React from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<App />);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "file",
|
||||
content: '{\n "name": "react-project",\n "version": "1.0.0"\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "node-api",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "src",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "index.js",
|
||||
type: "file",
|
||||
content:
|
||||
'const express = require("express");\nconst app = express();\nconst PORT = 3000;\n\napp.get("/", (req, res) => {\n res.json({ message: "Hello!" });\n});\n\napp.listen(PORT, () => {\n console.log(`Server running on port ${PORT}`);\n});',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "file",
|
||||
content:
|
||||
'{\n "name": "api-project",\n "dependencies": {\n "express": "^4.18.0"\n }\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "html-css",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "index.html",
|
||||
type: "file",
|
||||
content:
|
||||
'<!DOCTYPE html>\n<html>\n<head>\n <title>My Landing</title>\n <link rel="stylesheet" href="styles.css">\n</head>\n<body>\n <h1>Welcome!</h1>\n</body>\n</html>',
|
||||
},
|
||||
{
|
||||
name: "styles.css",
|
||||
type: "file",
|
||||
content:
|
||||
"body {\n font-family: sans-serif;\n margin: 0;\n padding: 2rem;\n background: #f5f5f5;\n}\n\nh1 {\n color: #333;\n}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
const convertTreeToFileNode = (data: any[]): FileNode => {
|
||||
const convertItem = (item: any): FileNode => {
|
||||
const node: FileNode = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type === "folder" ? "folder" : "file",
|
||||
content: item.content || "",
|
||||
path: item.name,
|
||||
interpreter_id: item.interpreter_id,
|
||||
};
|
||||
|
||||
if (item.type === "folder") {
|
||||
node.children = [];
|
||||
if (item.children && Array.isArray(item.children)) {
|
||||
node.children = item.children.map((child: any) => {
|
||||
const childNode = convertItem(child);
|
||||
childNode.path = `${item.name}/${child.name}`;
|
||||
return childNode;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return {
|
||||
name: "templates",
|
||||
type: "folder",
|
||||
children: data.map((item) => convertItem(item)),
|
||||
};
|
||||
};
|
||||
|
||||
export const TemplatesPage = () => {
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [runModal, setRunModal] = useState<{
|
||||
scriptPath: string;
|
||||
scriptId: number;
|
||||
} | null>(null);
|
||||
const [showAddInterpreter, setShowAddInterpreter] = useState(false);
|
||||
|
||||
const reloadTree = () => {
|
||||
setLoading(true);
|
||||
scriptsApi
|
||||
.getTree()
|
||||
.then((data) => {
|
||||
setFiles(convertTreeToFileNode(data));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to load tree:", e);
|
||||
setFiles({ name: "templates", type: "folder", children: [] });
|
||||
})
|
||||
.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 (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
position: "relative",
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Floating header */}
|
||||
{/* Header bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "16px",
|
||||
right: "16px",
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
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 */}
|
||||
<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 */}
|
||||
{/* Add Interpreter button */}
|
||||
<button
|
||||
onClick={() => navigate("/ide")}
|
||||
onClick={() => setShowAddInterpreter(true)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "6px 16px",
|
||||
backgroundColor: "#0e639c",
|
||||
gap: "6px",
|
||||
padding: "6px 14px",
|
||||
backgroundColor: "#238636",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
color: "#ffffff",
|
||||
@@ -170,21 +120,111 @@ export const TemplatesPage = () => {
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#1177bb";
|
||||
e.currentTarget.style.backgroundColor = "#2ea043";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#0e639c";
|
||||
e.currentTarget.style.backgroundColor = "#238636";
|
||||
}}
|
||||
>
|
||||
<FiEdit3 size={14} />
|
||||
Open Editor
|
||||
<MdAdd size={14} />
|
||||
Add Interpreter
|
||||
</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>
|
||||
|
||||
{/* File Picker */}
|
||||
<div style={{ height: "100%", overflow: "hidden" }}>
|
||||
<FilePicker files={mockFiles} />
|
||||
{/* File Picker (terminal встроен внутрь) */}
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<FaSpinner
|
||||
size={24}
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : 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}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ class ApiClient {
|
||||
|
||||
constructor() {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: "http://10.97.147.99:8080/api/v1",
|
||||
baseURL: "http://213.165.213.170:8080/api/v1",
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -24,6 +24,33 @@ class ApiClient {
|
||||
validateStatus: (status) => {
|
||||
return status >= 200 && status < 400;
|
||||
},
|
||||
// Добавляем кастомный сериализатор параметров
|
||||
paramsSerializer: {
|
||||
serialize: (params) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) return;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Преобразуем массив в множественные параметры: level=info&level=warning
|
||||
value.forEach((item) => {
|
||||
if (item !== undefined && item !== null) {
|
||||
parts.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(item)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
parts.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join("&");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
|
||||
Reference in New Issue
Block a user