Compare commits
53 Commits
2714bd1178
...
frontend
| 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 | |||
| bcca8fa298 | |||
| 400ceab47c | |||
| c6a9907822 | |||
| 69ff617c30 | |||
| 3430070df8 | |||
| 78f35f6811 | |||
| 55cb214458 | |||
| 8175d7b3a5 | |||
| 822f953698 | |||
| e7f1ea2386 | |||
| aac3fa3758 | |||
| 26ca7c0d51 | |||
| dd921e5892 | |||
| eedc9c9b62 | |||
| 4f69e002c6 | |||
| 5209e8b2e9 | |||
| 95a6902dae | |||
| adbb0ee368 | |||
| 96f82b4162 | |||
| ed439656f8 | |||
| d62205b329 | |||
| 11cef95929 | |||
| 43e16b1360 | |||
| f537f1eab9 | |||
| 9d1096a9b4 | |||
| 57b43da2e3 | |||
| 691e1fced5 |
@@ -7,7 +7,9 @@
|
|||||||
"Bash(type *)",
|
"Bash(type *)",
|
||||||
"Bash(dir)",
|
"Bash(dir)",
|
||||||
"Bash(move *)",
|
"Bash(move *)",
|
||||||
"Bash(findstr *)"
|
"Bash(findstr *)",
|
||||||
|
"Bash(del *)",
|
||||||
|
"Bash(mkdir *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
Generated
+7152
File diff suppressed because it is too large
Load Diff
@@ -11,19 +11,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@uiw/react-codemirror": "^4.25.8",
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"file-surf": "^1.0.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"monaco-languageclient": "^10.7.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.7",
|
"primereact": "^10.9.7",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vscode-ws-jsonrpc": "^3.5.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import "@/shared/styles/index.css";
|
import "@/shared/styles/index.css";
|
||||||
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
||||||
import "primereact/resources/primereact.min.css";
|
import "primereact/resources/primereact.min.css";
|
||||||
import "primeicons/primeicons.css";
|
import "primeicons/primeicons.css";
|
||||||
import { PrimeReactProvider } from "primereact/api";
|
import { PrimeReactProvider } from "primereact/api";
|
||||||
import { Routing } from "./providers/routing/routing";
|
import { Routing } from "./providers/routing/routing";
|
||||||
|
import { AppLoader } from "./components/AppLoader";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <AppLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimeReactProvider>
|
<PrimeReactProvider>
|
||||||
<Routing />
|
<Routing />
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FaMicrochip, FaCode, FaNetworkWired, FaAtom } from "react-icons/fa";
|
||||||
|
|
||||||
|
export const AppLoader = () => {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [phase, setPhase] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const phases = [
|
||||||
|
{ progress: 25, delay: 400 },
|
||||||
|
{ progress: 50, delay: 300 },
|
||||||
|
{ progress: 75, delay: 400 },
|
||||||
|
{ progress: 100, delay: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let timeouts: NodeJS.Timeout[] = [];
|
||||||
|
let currentDelay = 0;
|
||||||
|
|
||||||
|
phases.forEach((p, i) => {
|
||||||
|
currentDelay += p.delay;
|
||||||
|
timeouts.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
setProgress(p.progress);
|
||||||
|
setPhase(i);
|
||||||
|
}, currentDelay),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => timeouts.forEach(clearTimeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "#0a0a0f",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 9999,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background grid effect */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
animation: "gridMove 20s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glowing orbs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "300px",
|
||||||
|
height: "300px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)",
|
||||||
|
filter: "blur(40px)",
|
||||||
|
animation: "orbFloat 6s ease-in-out infinite",
|
||||||
|
top: "20%",
|
||||||
|
left: "30%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "250px",
|
||||||
|
height: "250px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)",
|
||||||
|
filter: "blur(40px)",
|
||||||
|
animation: "orbFloat 8s ease-in-out infinite reverse",
|
||||||
|
bottom: "20%",
|
||||||
|
right: "30%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
|
||||||
|
{/* Logo with animation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "16px",
|
||||||
|
marginBottom: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
animation: "logoSpin 3s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaAtom size={48} style={{ color: "#3b82f6" }} />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "42px",
|
||||||
|
fontWeight: 800,
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
letterSpacing: "4px",
|
||||||
|
animation: "titleGlow 2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading icons animation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "24px",
|
||||||
|
marginBottom: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ icon: FaMicrochip, delay: "0s" },
|
||||||
|
{ icon: FaNetworkWired, delay: "0.2s" },
|
||||||
|
{ icon: FaCode, delay: "0.4s" },
|
||||||
|
].map(({ icon: Icon, delay }, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `2px solid ${
|
||||||
|
phase >= i
|
||||||
|
? "rgba(59, 130, 246, 0.6)"
|
||||||
|
: "rgba(255,255,255,0.1)"
|
||||||
|
}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor:
|
||||||
|
phase >= i ? "rgba(59, 130, 246, 0.1)" : "transparent",
|
||||||
|
animation: `iconPop 0.5s ease-out ${delay} both`,
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={22}
|
||||||
|
style={{
|
||||||
|
color: phase >= i ? "#3b82f6" : "#555",
|
||||||
|
transition: "color 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "320px",
|
||||||
|
height: "4px",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${progress}%`,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
boxShadow: "0 0 20px rgba(59, 130, 246, 0.5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
letterSpacing: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phase === 0 && "INITIALIZING CORE..."}
|
||||||
|
{phase === 1 && "LOADING AGENTS..."}
|
||||||
|
{phase === 2 && "ESTABLISHING CONNECTIONS..."}
|
||||||
|
{phase === 3 && "READY"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS Animations */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes gridMove {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
100% { transform: translate(40px, 40px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbFloat {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoSpin {
|
||||||
|
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||||
|
25% { transform: rotate(-10deg) scale(1.05); }
|
||||||
|
75% { transform: rotate(10deg) scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes titleGlow {
|
||||||
|
0%, 100% { filter: brightness(1); }
|
||||||
|
50% { filter: brightness(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iconPop {
|
||||||
|
0% { transform: scale(0.5) translateY(10px); opacity: 0; }
|
||||||
|
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
|
||||||
|
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 [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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
isMobile={isMobile}
|
||||||
|
isVerySmall={isVerySmall}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||||
|
{isVerySmall && <BottomNav />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
|
||||||
|
import {
|
||||||
|
FaHome,
|
||||||
|
FaServer,
|
||||||
|
FaUser,
|
||||||
|
FaUsers,
|
||||||
|
FaRocket,
|
||||||
|
FaKey,
|
||||||
|
FaFileAlt,
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 { setTheme } = useThemeStore();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentTheme = getCurrentTheme();
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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-t"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
FaBars,
|
||||||
|
FaMicrochip,
|
||||||
|
FaTimes,
|
||||||
|
FaSpinner,
|
||||||
|
FaCopy,
|
||||||
|
FaCheck,
|
||||||
|
FaChevronRight,
|
||||||
|
FaChevronDown,
|
||||||
|
FaProjectDiagram,
|
||||||
|
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();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
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);
|
||||||
|
if (next.has(label)) next.delete(label);
|
||||||
|
else next.add(label);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
if (!searchQuery) return agents;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.label.toLowerCase().includes(query) ||
|
||||||
|
agent.services.some((s) => s.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
|
const [graphData, setGraphData] = useState<GraphData>({
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGraph = () => {
|
||||||
|
agentApiService
|
||||||
|
.getGraph()
|
||||||
|
.then((apiData) => {
|
||||||
|
const nodes: any[] = [];
|
||||||
|
const links: any[] = [];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGraph();
|
||||||
|
const interval = setInterval(fetchGraph, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const handleCopyToken = () => {
|
||||||
|
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 null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay — только на мобильных (< 856px) */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
|
||||||
|
style={{
|
||||||
|
width: `${sidebarWidth}px`,
|
||||||
|
height: "100vh",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Агенты
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agents.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`p-1 rounded transition-colors ${isMobile ? "" : "hidden"}`}
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
aria-label="Закрыть sidebar"
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент — либо список агентов, либо графы */}
|
||||||
|
{showGraphs ? (
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
<Graph initialData={graphData} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск агентов..."
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список агентов */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{isLoading && agents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FaSpinner
|
||||||
|
className="animate-spin mb-3"
|
||||||
|
style={{ color: "var(--accent)", fontSize: "20px" }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Загрузка агентов...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div
|
||||||
|
className="text-xs mb-2"
|
||||||
|
style={{ color: "var(--error-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchAgents}
|
||||||
|
className="text-xs hover:underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredAgents.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="text-center py-8"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
|
||||||
|
<p className="text-xs">
|
||||||
|
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredAgents.map((agent) => {
|
||||||
|
const isExpanded = expandedAgents.has(agent.label);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.label}
|
||||||
|
className="rounded-lg border overflow-hidden transition-all group"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Agent header — кликабельный для сворачивания */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => toggleAgent(agent.label)}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronDown size={10} />
|
||||||
|
) : (
|
||||||
|
<FaChevronRight size={10} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<FaMicrochip
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
{/* Статус-индикатор агента (количество сервисов) */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{agent.services.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: "#4ade80" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{agent.services.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Кнопка удаления — появляется при наведении */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Удалить агента "${agent.label}"?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
removeAgent(agent.label);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#f87171";
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(248, 113, 113, 0.15)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--text-muted)";
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"transparent";
|
||||||
|
}}
|
||||||
|
title="Удалить агента"
|
||||||
|
>
|
||||||
|
<FaTrash size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services list — сворачивается */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
className="px-3 pb-2"
|
||||||
|
style={{ paddingLeft: "24px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="border-l-2 pl-3 space-y-1"
|
||||||
|
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}
|
||||||
|
className="flex items-center justify-between py-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
color: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "#4ade80",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
color: isDown ? "#ef4444" : "#4ade80",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDown ? "down" : "run"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer с кнопками */}
|
||||||
|
<div
|
||||||
|
className="p-2 border-t flex gap-2"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showGraphs ? (
|
||||||
|
/* Кнопка назад к агентам */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(false)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={10} />К агентам
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
/* Кнопка Графы */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(true)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaProjectDiagram size={10} />
|
||||||
|
Графы
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTokenModal(true)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaCopy size={10} />
|
||||||
|
Токен
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Modal токена */}
|
||||||
|
{showTokenModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
onClick={handleCloseTokenModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl shadow-2xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaCopy style={{ color: "var(--accent)" }} size={14} />
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Генерация токена
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseTokenModal}
|
||||||
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Error */}
|
||||||
|
{tokenError && (
|
||||||
|
<div
|
||||||
|
className="text-xs p-2 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(239,68,68,0.1)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
color: "var(--error-text, #ef4444)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tokenError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label input */}
|
||||||
|
{!generatedToken && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium mb-2"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Имя токена
|
||||||
|
</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"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<FaCheck
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--success-text)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaCopy size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import type { AgentInfo } from "@/modules/agent/types/agent.types";
|
||||||
|
|
||||||
|
interface AgentState {
|
||||||
|
agents: AgentInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchAgents: () => Promise<void>;
|
||||||
|
removeAgent: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAgentStore = create<AgentState>()((set, get) => ({
|
||||||
|
agents: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchAgents: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const agents = await agentApiService.getAgents();
|
||||||
|
set({ agents, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to fetch agents",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAgent: (name: string) => {
|
||||||
|
set({ agents: get().agents.filter((a) => a.label !== name) });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 "@/store/auth/auth.store";
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
interface ProtectedRouteProps {
|
||||||
const { isAuthenticated } = useAuthStore();
|
children: React.ReactNode;
|
||||||
|
requireView?: boolean;
|
||||||
|
requireManageAgent?: boolean;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
fallbackPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
|
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 />;
|
return <Navigate to="/auth" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requireView && !user.permission_view) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireManageAgent && !user.permission_manage_agent) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && !user.permission_admin) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,113 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
||||||
import { HomePage } from "@/pages/home.page";
|
import { HomePage } from "@/pages/home.page";
|
||||||
import { ThemesPage } from "@/pages/themes.page";
|
import { TestPage } from "@/pages/test.page";
|
||||||
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
import { AuthPage } from "@/pages/auth.page";
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
import { RegisterPage } from "@/pages/register.page";
|
||||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||||
|
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||||
|
import { IDEPage } from "@/pages/ide.page";
|
||||||
|
import { TemplatesPage } from "@/pages/templates.page";
|
||||||
|
import { AdminPage } from "@/pages/admin.page";
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
id: "api-gateway",
|
||||||
|
name: "API Gateway",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Входная точка API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auth-service",
|
||||||
|
name: "Auth Service",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Аутентификация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "db-service",
|
||||||
|
name: "Database",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Хранилище данных",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redis-service",
|
||||||
|
name: "Redis",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Кэширование",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queue-service",
|
||||||
|
name: "Message Queue",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Очередь сообщений",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-agent",
|
||||||
|
name: "User Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Обработка пользователей",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payment-agent",
|
||||||
|
name: "Payment Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Платежи",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-agent",
|
||||||
|
name: "Notification Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Уведомления",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analytics-agent",
|
||||||
|
name: "Analytics Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Аналитика",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "report-agent",
|
||||||
|
name: "Report Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Отчеты",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: "user-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "analytics-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
|
||||||
|
{ source: "auth-service", target: "db-service", type: "depends_on" },
|
||||||
|
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
|
||||||
|
{ source: "queue-service", target: "redis-service", type: "depends_on" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const Routing = () => {
|
export const Routing = () => {
|
||||||
return (
|
return (
|
||||||
@@ -17,15 +119,94 @@ export const Routing = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
<Route element={<DefaultLayout />}>
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
|
||||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route element={<DefaultLayout />}>
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<Route path="/test" element={<TestPage />} />
|
||||||
|
|
||||||
|
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px", maxWidth: "900px", margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaShieldAlt size={18} style={{ color: "var(--accent-text)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Управление пользователями
|
||||||
|
</h1>
|
||||||
|
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{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 */}
|
||||||
|
{!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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
|
||||||
|
import type { AdminUser, PermissionKey } from "../types";
|
||||||
|
import { useAdminStore } from "../store/useAdminStore";
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: AdminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: { key: PermissionKey; label: string }[] = [
|
||||||
|
{ key: "permission_view", label: "View" },
|
||||||
|
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||||
|
{ key: "permission_admin", label: "Admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
opacity: user.is_active ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header: User info + Active toggle + Delete */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: user.is_active
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaUser size={16} style={{ color: "var(--card-bg)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name} {user.last_name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.login}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
{/* 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, 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>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "16px",
|
||||||
|
paddingTop: "12px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permissions.map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => togglePermission(user.id, user.login, key, users)}
|
||||||
|
style={{
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: user[key] ? "var(--accent)" : "var(--border)",
|
||||||
|
backgroundColor: user[key] ? "var(--accent)" : "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user[key] && (
|
||||||
|
<FaCheck
|
||||||
|
size={10}
|
||||||
|
style={{ color: "var(--accent-text, #fff)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { AdminPanel } from "./AdminPanel";
|
||||||
|
export { useAdminStore } from "./store/useAdminStore";
|
||||||
|
export { adminApi } from "./api/admin.api";
|
||||||
|
export type { AdminUser } from "./types";
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { AdminUser, PermissionKey } from "../types";
|
||||||
|
import { adminApi } from "../api/admin.api";
|
||||||
|
import type { CreateUserPayload } from "../api/admin.api";
|
||||||
|
|
||||||
|
interface AdminState {
|
||||||
|
users: AdminUser[];
|
||||||
|
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, get) => ({
|
||||||
|
users: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionKey =
|
||||||
|
| "permission_admin"
|
||||||
|
| "permission_manage_agent"
|
||||||
|
| "permission_view";
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
import type {
|
||||||
|
AgentInfo,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
LogFilters,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
DeployResponse,
|
||||||
|
SystemMetrics,
|
||||||
|
} from "../types/agent.types";
|
||||||
|
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||||
|
|
||||||
|
class AgentApiService {
|
||||||
|
private readonly basePath = "/agents";
|
||||||
|
private readonly authBasePath = "/auth";
|
||||||
|
private readonly logsBasePath = "/logs";
|
||||||
|
|
||||||
|
async getAgents(): Promise<AgentInfo[]> {
|
||||||
|
const response = await apiClient.get<AgentInfo[]>(this.basePath);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/tokens`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: TokenCreate): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/token`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(login: string): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyAccount(): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.authBasePath}/token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
|
||||||
|
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
|
||||||
|
params: {
|
||||||
|
level: filters?.level || undefined,
|
||||||
|
service: filters?.service || undefined,
|
||||||
|
agent: filters?.agent || undefined,
|
||||||
|
date_from: filters?.date_from || undefined,
|
||||||
|
date_to: filters?.date_to || undefined,
|
||||||
|
limit: filters?.limit ?? 100,
|
||||||
|
offset: filters?.offset ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(response.data)) {
|
||||||
|
console.error(
|
||||||
|
"[Logs] Unexpected response format:",
|
||||||
|
typeof response.data,
|
||||||
|
response.data,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertLog(entry: InsertLogRequest): Promise<void> {
|
||||||
|
await apiClient.post(this.logsBasePath, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
|
||||||
|
await apiClient.post(`${this.logsBasePath}/batch`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctAgents(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/agents`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctLevels(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/levels`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctServices(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/services`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// User management methods
|
||||||
|
async getUserByLogin(login: string): Promise<TokenUser> {
|
||||||
|
const response = await apiClient.get<TokenUser>(
|
||||||
|
`${this.authBasePath}/users/${login}`,
|
||||||
|
);
|
||||||
|
if (!response.data || typeof response.data !== "object") {
|
||||||
|
throw new Error(`User not found: ${login}`);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInactiveUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/users/inactive`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(login: string, data: TokenUpdate): Promise<void> {
|
||||||
|
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserPermissions(
|
||||||
|
login: string,
|
||||||
|
data: TokenUpdatePermissions,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.put(
|
||||||
|
`${this.authBasePath}/users/${login}/permissions`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUserPassword(
|
||||||
|
login: string,
|
||||||
|
data: TokenPasswordReset,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateUser(login: string): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateUser(login: string): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegistrationToken(
|
||||||
|
data: RegistrationRequest,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const response = await apiClient.post<Record<string, string>>(
|
||||||
|
`${this.basePath}/register-token`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
|
||||||
|
const response = await apiClient.post<DeployResponse>(
|
||||||
|
`${this.basePath}/deploy`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemMetrics(): Promise<SystemMetrics[]> {
|
||||||
|
const response = await apiClient.get<SystemMetrics[]>(
|
||||||
|
`${this.basePath}/system-metrics`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGraph(): Promise<GraphApiResponse> {
|
||||||
|
const response = await apiClient.get<GraphApiResponse>("/graph");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentApiService = new AgentApiService();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { agentApiService } from "../api/agent.api.service";
|
||||||
|
import type { AgentInfo } from "../types/agent.types";
|
||||||
|
|
||||||
|
interface UseAgentsResult {
|
||||||
|
agents: AgentInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgents(): UseAgentsResult {
|
||||||
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAgents = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await agentApiService.getAgents();
|
||||||
|
setAgents(data);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch agents";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
return { agents, isLoading, error, refetch: fetchAgents };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export { SSHAgentForm } from "./ui/SSHAgentForm";
|
||||||
|
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
|
||||||
|
|
||||||
|
export { useAgents } from "./hooks/useAgents.hook";
|
||||||
|
|
||||||
|
export { agentApiService } from "./api/agent.api.service";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AgentInfo,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
LogFilters,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployResult,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
AgentDeployConfig,
|
||||||
|
DeployResponse,
|
||||||
|
} from "./types/agent.types";
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type LogLevel = "info" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
|
interface LogFilterState {
|
||||||
|
searchQuery: string;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
selectedLogLevel: LogLevel | null;
|
||||||
|
selectedService: string;
|
||||||
|
selectedAgent: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setStartDate: (date: Date | null) => void;
|
||||||
|
setEndDate: (date: Date | null) => void;
|
||||||
|
setSelectedLogLevel: (level: LogLevel | null) => void;
|
||||||
|
setSelectedService: (service: string) => void;
|
||||||
|
setSelectedAgent: (agent: string) => void;
|
||||||
|
setLimit: (limit: number) => void;
|
||||||
|
setOffset: (offset: number) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
getFilters: () => {
|
||||||
|
level?: string;
|
||||||
|
service?: string;
|
||||||
|
agent?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||||
|
searchQuery: "",
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
selectedLogLevel: null,
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
setStartDate: (date) => set({ startDate: date }),
|
||||||
|
setEndDate: (date) => set({ endDate: date }),
|
||||||
|
setSelectedLogLevel: (level) => set({ selectedLogLevel: level }),
|
||||||
|
setSelectedService: (service) => set({ selectedService: service }),
|
||||||
|
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||||
|
setLimit: (limit) => set({ limit }),
|
||||||
|
setOffset: (offset) => set({ offset }),
|
||||||
|
|
||||||
|
resetFilters: () => {
|
||||||
|
set({
|
||||||
|
searchQuery: "",
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
selectedLogLevel: null,
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilters: () => {
|
||||||
|
const {
|
||||||
|
selectedLogLevel,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = get();
|
||||||
|
return {
|
||||||
|
level: selectedLogLevel || undefined,
|
||||||
|
service: selectedService || undefined,
|
||||||
|
agent: selectedAgent || undefined,
|
||||||
|
date_from: startDate ? startDate.toISOString() : undefined,
|
||||||
|
date_to: endDate ? endDate.toISOString() : undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
export interface AgentInfo {
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
services: string[];
|
||||||
|
connected_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
last_name: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenCreate {
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
password: string;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
Agent: string;
|
||||||
|
Level: string;
|
||||||
|
Message: string;
|
||||||
|
Service: string;
|
||||||
|
Timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertLogRequest {
|
||||||
|
agent: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
service: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertLogsRequest {
|
||||||
|
logs: InsertLogRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilters {
|
||||||
|
level?: string | string[];
|
||||||
|
service?: string;
|
||||||
|
agent?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUpdate {
|
||||||
|
name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUpdatePermissions {
|
||||||
|
is_active?: boolean;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPasswordReset {
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationRequest {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployResult {
|
||||||
|
agent_label: string;
|
||||||
|
error?: string;
|
||||||
|
ip: string;
|
||||||
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployAgentsRequest {
|
||||||
|
servers: AgentDeployConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDeployConfig {
|
||||||
|
agentLabel: string;
|
||||||
|
authMethod: "key" | "password";
|
||||||
|
deployType: "docker" | "binary";
|
||||||
|
ip: string;
|
||||||
|
password?: string;
|
||||||
|
port?: number;
|
||||||
|
sshKey?: string;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
FiSearch,
|
||||||
|
FiX,
|
||||||
|
FiFilter,
|
||||||
|
FiCalendar,
|
||||||
|
FiTag,
|
||||||
|
FiCheck,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
|
||||||
|
|
||||||
|
const logLevelColors: Record<
|
||||||
|
LogLevel,
|
||||||
|
{ bg: string; text: string; border: string }
|
||||||
|
> = {
|
||||||
|
info: {
|
||||||
|
bg: "rgba(59, 130, 246, 0.1)",
|
||||||
|
text: "#3b82f6",
|
||||||
|
border: "rgba(59, 130, 246, 0.3)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "rgba(245, 158, 11, 0.1)",
|
||||||
|
text: "#f59e0b",
|
||||||
|
border: "rgba(245, 158, 11, 0.3)",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "var(--error-bg)",
|
||||||
|
text: "var(--error-text)",
|
||||||
|
border: "var(--error-border)",
|
||||||
|
},
|
||||||
|
fatal: {
|
||||||
|
bg: "rgba(168, 85, 247, 0.1)",
|
||||||
|
text: "#a855f7",
|
||||||
|
border: "rgba(168, 85, 247, 0.3)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogFiltersProps {
|
||||||
|
onApply: () => void;
|
||||||
|
availableServices: string[];
|
||||||
|
availableAgents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||||
|
onApply,
|
||||||
|
availableServices,
|
||||||
|
availableAgents,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedLogLevel,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
setSearchQuery,
|
||||||
|
setStartDate,
|
||||||
|
setEndDate,
|
||||||
|
setSelectedLogLevel,
|
||||||
|
setSelectedService,
|
||||||
|
setSelectedAgent,
|
||||||
|
resetFilters,
|
||||||
|
} = useLogFilterStore();
|
||||||
|
|
||||||
|
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||||
|
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
|
||||||
|
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);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStartDate(startDate);
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalEndDate(endDate);
|
||||||
|
}, [endDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalService(selectedService);
|
||||||
|
}, [selectedService]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalAgent(selectedAgent);
|
||||||
|
}, [selectedAgent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalLevel(selectedLogLevel);
|
||||||
|
}, [selectedLogLevel]);
|
||||||
|
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
setSearchQuery(localSearchQuery);
|
||||||
|
setStartDate(localStartDate);
|
||||||
|
setEndDate(localEndDate);
|
||||||
|
setSelectedLogLevel(localLevel);
|
||||||
|
setSelectedService(localService);
|
||||||
|
setSelectedAgent(localAgent);
|
||||||
|
onApply();
|
||||||
|
}, [
|
||||||
|
localSearchQuery,
|
||||||
|
localStartDate,
|
||||||
|
localEndDate,
|
||||||
|
localLevel,
|
||||||
|
localService,
|
||||||
|
localAgent,
|
||||||
|
onApply,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setLocalEndDate(null);
|
||||||
|
setLocalLevel(null);
|
||||||
|
setLocalService("");
|
||||||
|
setLocalAgent("");
|
||||||
|
resetFilters();
|
||||||
|
onApply();
|
||||||
|
}, [resetFilters, onApply]);
|
||||||
|
|
||||||
|
const getActiveFiltersCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
if (searchQuery) count++;
|
||||||
|
if (startDate) count++;
|
||||||
|
if (endDate) count++;
|
||||||
|
if (selectedService) count++;
|
||||||
|
if (selectedAgent) count++;
|
||||||
|
if (selectedLogLevel) count++;
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return null;
|
||||||
|
return date.toLocaleDateString("ru-RU");
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFiltersCount = getActiveFiltersCount();
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiFilter size={14} style={{ color: "var(--accent)" }} />
|
||||||
|
<h3
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Фильтры логов
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Активно: {activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<FiSearch
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "10px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSearchQuery}
|
||||||
|
onChange={(e) => setLocalSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск по сообщению..."
|
||||||
|
style={{ ...inputStyle, paddingLeft: "32px" }}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Select */}
|
||||||
|
<select
|
||||||
|
value={localService}
|
||||||
|
onChange={(e) => setLocalService(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">Все сервисы</option>
|
||||||
|
{availableServices.map((service) => (
|
||||||
|
<option key={service} value={service}>
|
||||||
|
{service}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Agent Select */}
|
||||||
|
<select
|
||||||
|
value={localAgent}
|
||||||
|
onChange={(e) => setLocalAgent(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">Все агенты</option>
|
||||||
|
{availableAgents.map((agent) => (
|
||||||
|
<option key={agent} value={agent}>
|
||||||
|
{agent}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date Range */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={
|
||||||
|
localStartDate ? localStartDate.toISOString().split("T")[0] : ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalStartDate(
|
||||||
|
e.target.value ? new Date(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, minWidth: 0 }}
|
||||||
|
placeholder="Дата от"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={
|
||||||
|
localEndDate ? localEndDate.toISOString().split("T")[0] : ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalEndDate(
|
||||||
|
e.target.value ? new Date(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, minWidth: 0 }}
|
||||||
|
placeholder="Дата до"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Levels */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Уровень логов
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
|
||||||
|
(level) => {
|
||||||
|
const isSelected = localLevel === level;
|
||||||
|
const colors = logLevelColors[level];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={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",
|
||||||
|
color: isSelected ? colors.text : "var(--text-secondary)",
|
||||||
|
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.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
minHeight: "44px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCheck size={14} />
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minHeight: "44px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={14} />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-4 pt-4 border-t"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiFilter size={10} style={{ color: "var(--accent)" }} />
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Активные фильтры:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{searchQuery && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSearch size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Поиск: {searchQuery}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setSearchQuery("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedService && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Сервис: {selectedService}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalService("");
|
||||||
|
setSelectedService("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</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"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Агент: {selectedAgent}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalAgent("");
|
||||||
|
setSelectedAgent("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{startDate && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCalendar size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
С: {formatDate(startDate)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setStartDate(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endDate && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCalendar size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
По: {formatDate(endDate)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalEndDate(null);
|
||||||
|
setEndDate(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FiPlus,
|
FiPlus,
|
||||||
FiTrash2,
|
FiTrash2,
|
||||||
FiSettings,
|
FiSettings,
|
||||||
|
FiLink,
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { SiDocker } from "react-icons/si";
|
import { SiDocker } from "react-icons/si";
|
||||||
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
||||||
@@ -20,8 +21,10 @@ interface ExtraField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHAgentConfig {
|
export interface SSHAgentConfig {
|
||||||
|
agentLabel: string;
|
||||||
user: string;
|
user: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
port: number;
|
||||||
authMethod: AuthMethod;
|
authMethod: AuthMethod;
|
||||||
sshKey?: string;
|
sshKey?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -189,11 +192,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "20px" }}>
|
<div style={{ display: "grid", gap: "20px" }}>
|
||||||
{/* User и IP */}
|
{/* Agent Label */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<FiServer size={14} />
|
||||||
|
Метка агента *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.agentLabel}
|
||||||
|
onChange={(e) => handleChange("agentLabel", e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="production-server-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User, IP и Port */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -238,6 +261,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
placeholder="192.168.1.1"
|
placeholder="192.168.1.1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiLink size={14} />
|
||||||
|
Порт *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("port", parseInt(e.target.value) || 22)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Метод аутентификации */}
|
{/* Метод аутентификации */}
|
||||||
@@ -457,7 +505,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (data: RegisterData): Promise<LoginResponse> => {
|
const register = async (
|
||||||
const response = await apiClient.post<LoginResponse>("/auth/register", {
|
data: RegisterData,
|
||||||
login: data.login,
|
): Promise<Record<string, string>> => {
|
||||||
password: data.password,
|
const response = await apiClient.post<Record<string, string>>(
|
||||||
name: data.firstName,
|
"/auth/register",
|
||||||
last_name: data.lastName,
|
{
|
||||||
});
|
login: data.login,
|
||||||
|
password: data.password,
|
||||||
|
name: data.firstName,
|
||||||
|
last_name: data.lastName,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,9 +67,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
register: async (data: RegisterData) => {
|
register: async (data: RegisterData) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await register(data);
|
await register(data);
|
||||||
const user = mapResponseToUser(response);
|
// После регистрации пользователь не авторизуется автоматически
|
||||||
set({ user, token: response.token, isLoading: false });
|
// Нужно войти через /auth/login
|
||||||
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaPlus } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface AddWidgetButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddWidgetButton: React.FC<AddWidgetButtonProps> = ({
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full py-1.5 bg-tertiary hover:bg-tertiary/70 rounded-lg border border-primary transition-colors flex items-center justify-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<FaPlus size={10} className="text-tertiary" />
|
||||||
|
<span className="text-[10px] text-secondary">Добавить график</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import type { ChartType } from "../types";
|
||||||
|
|
||||||
|
interface AddWidgetModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onAdd: (data: { type: ChartType; title: string; dataKey: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddWidgetModal: React.FC<AddWidgetModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [type, setType] = useState<ChartType>("line");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [dataKey, setDataKey] = useState("requests");
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
onAdd({ type, title: title.trim(), dataKey });
|
||||||
|
setTitle("");
|
||||||
|
setType("line");
|
||||||
|
setDataKey("requests");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-primary mb-3">
|
||||||
|
Добавить график
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Тип
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
||||||
|
type === t
|
||||||
|
? "bg-accent-primary text-white"
|
||||||
|
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "line" && "📈"}
|
||||||
|
{t === "bar" && "📊"}
|
||||||
|
{t === "area" && "📉"}
|
||||||
|
{t === "pie" && "🥧"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Название"
|
||||||
|
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// modules/dashboard/components/ChartWidget.tsx
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
FaChartLine,
|
||||||
|
FaChartBar,
|
||||||
|
FaChartArea,
|
||||||
|
FaChartPie,
|
||||||
|
FaCog,
|
||||||
|
FaEye,
|
||||||
|
FaEyeSlash,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartWidget as ChartWidgetType, MetricData } from "../types";
|
||||||
|
|
||||||
|
interface ChartWidgetProps {
|
||||||
|
widget: ChartWidgetType;
|
||||||
|
data: MetricData[];
|
||||||
|
onEdit: () => void;
|
||||||
|
onToggleVisibility: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все возможные уровни логов (метрики)
|
||||||
|
const METRICS = ["INFO", "WARN", "ERROR", "DEBUG"];
|
||||||
|
|
||||||
|
// Цвета для каждой метрики
|
||||||
|
const METRIC_COLORS: Record<string, string> = {
|
||||||
|
INFO: "#10b981", // зеленый
|
||||||
|
WARN: "#f59e0b", // оранжевый
|
||||||
|
ERROR: "#ef4444", // красный
|
||||||
|
DEBUG: "#3b82f6", // синий
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||||
|
widget,
|
||||||
|
data,
|
||||||
|
onEdit,
|
||||||
|
onToggleVisibility,
|
||||||
|
}) => {
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-[10px] text-tertiary">Нет данных</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedData = data.map((point) => {
|
||||||
|
const normalized: MetricData = { timestamp: point.timestamp };
|
||||||
|
METRICS.forEach((metric) => {
|
||||||
|
normalized[metric] = point[metric] || 0;
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
data: normalizedData,
|
||||||
|
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (widget.type) {
|
||||||
|
case "line":
|
||||||
|
return (
|
||||||
|
<LineChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Line
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={METRIC_COLORS[metric]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "bar":
|
||||||
|
return (
|
||||||
|
<BarChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Bar
|
||||||
|
key={metric}
|
||||||
|
dataKey={metric}
|
||||||
|
fill={METRIC_COLORS[metric]}
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "area":
|
||||||
|
return (
|
||||||
|
<AreaChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Area
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={METRIC_COLORS[metric]}
|
||||||
|
fill={METRIC_COLORS[metric]}
|
||||||
|
fillOpacity={0.2}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pie":
|
||||||
|
// Для круговой диаграммы берем последнюю точку
|
||||||
|
const lastPoint = normalizedData[normalizedData.length - 1];
|
||||||
|
const pieData = METRICS.map((metric) => ({
|
||||||
|
name: metric,
|
||||||
|
value: lastPoint[metric] || 0,
|
||||||
|
})).filter((item) => Number(item.value) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={55}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={METRIC_COLORS[entry.name]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (widget.type) {
|
||||||
|
case "line":
|
||||||
|
return <FaChartLine size={10} />;
|
||||||
|
case "bar":
|
||||||
|
return <FaChartBar size={10} />;
|
||||||
|
case "area":
|
||||||
|
return <FaChartArea size={10} />;
|
||||||
|
case "pie":
|
||||||
|
return <FaChartPie size={10} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className={`bg-secondary rounded-lg border border-primary p-2 transition-all ${!widget.visible ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1 px-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-tertiary">{getIcon()}</span>
|
||||||
|
<h3 className="text-[11px] font-medium text-primary">
|
||||||
|
{widget.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={onToggleVisibility}
|
||||||
|
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
||||||
|
title={widget.visible ? "Скрыть" : "Показать"}
|
||||||
|
>
|
||||||
|
{widget.visible ? (
|
||||||
|
<FaEye size={9} className="text-tertiary" />
|
||||||
|
) : (
|
||||||
|
<FaEyeSlash size={9} className="text-tertiary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
<FaCog size={9} className="text-tertiary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-40">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// modules/dashboard/components/WidgetSettings.tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartType, ChartWidget } from "../types";
|
||||||
|
|
||||||
|
interface WidgetSettingsProps {
|
||||||
|
widget: ChartWidget;
|
||||||
|
onUpdate: (widget: ChartWidget) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WidgetSettings: React.FC<WidgetSettingsProps> = ({
|
||||||
|
widget,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [type, setType] = useState<ChartType>(widget.type);
|
||||||
|
const [title, setTitle] = useState(widget.title);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate({ ...widget, type, title });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-primary mb-3">
|
||||||
|
Настройки графика
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">Тип</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
||||||
|
type === t
|
||||||
|
? "bg-accent-primary text-white"
|
||||||
|
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "line" && "📈"}
|
||||||
|
{t === "bar" && "📊"}
|
||||||
|
{t === "area" && "📉"}
|
||||||
|
{t === "pie" && "🥧"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="px-2 py-1 bg-red-500/10 text-red-500 rounded text-[10px] hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartType, MetricData } from "../types";
|
||||||
|
|
||||||
|
interface DashboardChartProps {
|
||||||
|
title: string;
|
||||||
|
type: ChartType;
|
||||||
|
data: MetricData[];
|
||||||
|
dataKeys: string[];
|
||||||
|
colors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
|
||||||
|
|
||||||
|
export const DashboardChart: React.FC<DashboardChartProps> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
dataKeys,
|
||||||
|
colors = COLORS,
|
||||||
|
}) => {
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Нет данных
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
data,
|
||||||
|
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const axisStyle = {
|
||||||
|
stroke: "var(--text-secondary)",
|
||||||
|
tick: { fontSize: 10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "11px",
|
||||||
|
},
|
||||||
|
labelStyle: { color: "var(--text-primary)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "pie") {
|
||||||
|
// Если данные уже в формате { name, value } — используем напрямую
|
||||||
|
const isPieFormat =
|
||||||
|
data.length > 0 && "name" in data[0] && "value" in data[0];
|
||||||
|
|
||||||
|
const pieData = isPieFormat
|
||||||
|
? data
|
||||||
|
: data.map((point, i) => ({
|
||||||
|
name: dataKeys[i % dataKeys.length],
|
||||||
|
value: point[dataKeys[i % dataKeys.length]] || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={60}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip {...tooltipStyle} />
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "11px" }}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartComponent =
|
||||||
|
type === "line" ? LineChart : type === "area" ? AreaChart : BarChart;
|
||||||
|
const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartComponent {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
{...axisStyle}
|
||||||
|
interval={Math.floor(data.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis {...axisStyle} width={35} />
|
||||||
|
<Tooltip {...tooltipStyle} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
{dataKeys.map((key, i) => (
|
||||||
|
<DataComponent
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={colors[i % colors.length]}
|
||||||
|
fill={colors[i % colors.length]}
|
||||||
|
fillOpacity={type === "area" ? 0.2 : undefined}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name={key}
|
||||||
|
radius={type === "bar" ? [2, 2, 0, 0] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChartComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div style={{ height: 180 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// modules/dashboard/Dashboard.tsx
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useDashboardStore } from "./store/dashboard.store";
|
||||||
|
import { useAuthStore } from "../auth/store/useAuthStore";
|
||||||
|
import { ChartWidget } from "./components/chart,widget";
|
||||||
|
import { AddWidgetButton } from "./components/add.widget.button";
|
||||||
|
import { AddWidgetModal } from "./components/add.widget.modal";
|
||||||
|
import { WidgetSettings } from "./components/chart.settings";
|
||||||
|
import { useWidgets } from "./hooks/use.widget";
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const { chartData, loading, error, fetchMetrics, clearData } =
|
||||||
|
useDashboardStore();
|
||||||
|
// const { servicesQueryParams } = useAgentStore();
|
||||||
|
const intervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
|
// Первичная загрузка (не latest)
|
||||||
|
// const fetchPrimaryData = () => {
|
||||||
|
// fetchMetrics(false, token || "", servicesQueryParams, { since: "10m" });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// Периодическое обновление (latest)
|
||||||
|
// const fetchLatestData = () => {
|
||||||
|
// fetchMetrics(true, token || "", servicesQueryParams);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// fetchPrimaryData();
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// intervalRef.current = window.setInterval(() => {
|
||||||
|
// fetchLatestData();
|
||||||
|
// }, 30000);
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// if (intervalRef.current) {
|
||||||
|
// window.clearInterval(intervalRef.current);
|
||||||
|
// }
|
||||||
|
// clearData();
|
||||||
|
// };
|
||||||
|
// }, [servicesQueryParams]);
|
||||||
|
|
||||||
|
const { widgets, addWidget, updateWidget, removeWidget, toggleVisibility } =
|
||||||
|
useWidgets();
|
||||||
|
const [editingWidget, setEditingWidget] = useState<any>(null);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const visibleWidgets = widgets.filter((w) => w.visible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
{loading && chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<span className="text-[10px] text-red-500">{error}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
|
||||||
|
{visibleWidgets.map((widget) => (
|
||||||
|
<ChartWidget
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
data={chartData}
|
||||||
|
onEdit={() => setEditingWidget(widget)}
|
||||||
|
onToggleVisibility={() => toggleVisibility(widget.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddWidgetButton onClick={() => setIsAdding(true)} />
|
||||||
|
|
||||||
|
<AddWidgetModal
|
||||||
|
isOpen={isAdding}
|
||||||
|
onAdd={addWidget}
|
||||||
|
onClose={() => setIsAdding(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editingWidget && (
|
||||||
|
<WidgetSettings
|
||||||
|
widget={editingWidget}
|
||||||
|
onUpdate={updateWidget}
|
||||||
|
onRemove={() => removeWidget(editingWidget.id)}
|
||||||
|
onClose={() => setEditingWidget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ChartType, ChartWidget } from "../types";
|
||||||
|
|
||||||
|
const initialWidgets: ChartWidget[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "line",
|
||||||
|
title: "Линии",
|
||||||
|
dataKey: "chart-line",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "bar",
|
||||||
|
title: "Столбцы",
|
||||||
|
dataKey: "chart-bar",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "area",
|
||||||
|
title: "Закрашенные линии",
|
||||||
|
dataKey: "chart-area",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "pie",
|
||||||
|
title: "Круговая диаграмма",
|
||||||
|
dataKey: "chart-pie",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useWidgets = () => {
|
||||||
|
const [widgets, setWidgets] = useState<ChartWidget[]>(initialWidgets);
|
||||||
|
|
||||||
|
const addWidget = (data: {
|
||||||
|
type: ChartType;
|
||||||
|
title: string;
|
||||||
|
dataKey: string;
|
||||||
|
}) => {
|
||||||
|
const newWidget: ChartWidget = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
...data,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
setWidgets([...widgets, newWidget]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWidget = (updated: ChartWidget) => {
|
||||||
|
setWidgets(widgets.map((w) => (w.id === updated.id ? updated : w)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeWidget = (id: string) => {
|
||||||
|
setWidgets(widgets.filter((w) => w.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVisibility = (id: string) => {
|
||||||
|
setWidgets(
|
||||||
|
widgets.map((w) => (w.id === id ? { ...w, visible: !w.visible } : w)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
widgets,
|
||||||
|
addWidget,
|
||||||
|
updateWidget,
|
||||||
|
removeWidget,
|
||||||
|
toggleVisibility,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { apiService } from "@/shared/api/api.service";
|
||||||
|
import type { MetricData } from "../types";
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
chartData: MetricData[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchMetrics: (
|
||||||
|
isLatest: boolean,
|
||||||
|
token: string,
|
||||||
|
queryParams?: string,
|
||||||
|
extraParams?: Record<string, string>,
|
||||||
|
) => Promise<void>;
|
||||||
|
clearData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDashboardStore = create<DashboardState>((set, get) => {
|
||||||
|
const convertPrimaryData = (response: any) => {
|
||||||
|
set((state) => {
|
||||||
|
if (!response.intervals || !Array.isArray(response.intervals))
|
||||||
|
return { chartData: state.chartData };
|
||||||
|
|
||||||
|
const newData = [...state.chartData];
|
||||||
|
|
||||||
|
response.intervals.forEach((interval: any) => {
|
||||||
|
const newPoint: MetricData = {
|
||||||
|
timestamp: new Date(interval.timestamp).toLocaleTimeString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (interval.group_by && Array.isArray(interval.group_by)) {
|
||||||
|
interval.group_by.forEach((item: any) => {
|
||||||
|
newPoint[item.value] = item.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newData.push(newPoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chartData: newData.slice(-20) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertSingleData = (response: any) => {
|
||||||
|
set((state) => {
|
||||||
|
const newPoint: MetricData = {
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
response.forEach((item: any) => {
|
||||||
|
newPoint[item.value] = item.count;
|
||||||
|
});
|
||||||
|
} else if (response.groupBy && Array.isArray(response.groupBy)) {
|
||||||
|
response.groupBy.forEach((item: any) => {
|
||||||
|
newPoint[item.value] = item.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedData = [...state.chartData, newPoint].slice(-20);
|
||||||
|
return { chartData: updatedData };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMetrics = async (
|
||||||
|
isLatest: boolean,
|
||||||
|
token: string,
|
||||||
|
queryParams?: string,
|
||||||
|
extraParams?: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint = isLatest
|
||||||
|
? "logs/aggregations/latest"
|
||||||
|
: "logs/aggregations";
|
||||||
|
|
||||||
|
// Если есть queryParams, добавляем его к эндпоинту
|
||||||
|
if (queryParams && queryParams.trim() !== "") {
|
||||||
|
endpoint = `${endpoint}?${queryParams}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
agg: "count",
|
||||||
|
groupby: "level",
|
||||||
|
...extraParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiService.get<any>(endpoint, {
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
Authorization: `bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
if (isLatest) {
|
||||||
|
convertSingleData(result);
|
||||||
|
} else {
|
||||||
|
convertPrimaryData(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch ${isLatest ? "latest" : "primary"} metrics:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : "Ошибка запроса",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearData = () => {
|
||||||
|
set({ chartData: [], error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
chartData: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fetchMetrics,
|
||||||
|
clearData,
|
||||||
|
setChartData: (data: MetricData[]) =>
|
||||||
|
set({ chartData: data, loading: false }),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type ChartType = "line" | "bar" | "area" | "pie";
|
||||||
|
|
||||||
|
export interface ChartWidget {
|
||||||
|
id: string;
|
||||||
|
type: ChartType;
|
||||||
|
title: string;
|
||||||
|
dataKey: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricData {
|
||||||
|
timestamp: string;
|
||||||
|
[key: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsItem {
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
import type {
|
||||||
|
GraphData,
|
||||||
|
GraphNode,
|
||||||
|
GraphLink,
|
||||||
|
ContextMenuState,
|
||||||
|
} from "./types";
|
||||||
|
import { useGraphStore } from "./store/useGraphStore";
|
||||||
|
import {
|
||||||
|
ForceGraph,
|
||||||
|
GraphControls,
|
||||||
|
GraphContextMenu,
|
||||||
|
GraphStatusBar,
|
||||||
|
GraphStats,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
interface GraphProps {
|
||||||
|
initialData?: GraphData;
|
||||||
|
onExport?: () => void;
|
||||||
|
onDataChange?: (data: GraphData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Graph: React.FC<GraphProps> = ({
|
||||||
|
initialData,
|
||||||
|
onExport,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const fgRef = useRef<any>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
|
||||||
|
const data = useGraphStore((s) => s.data);
|
||||||
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
const selectedNode = useGraphStore((s) => s.selectedNode);
|
||||||
|
const setData = useGraphStore((s) => s.setData);
|
||||||
|
|
||||||
|
// Инициализация данных
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) setData(initialData);
|
||||||
|
}, [initialData, setData]);
|
||||||
|
|
||||||
|
// Закрыть контекстное меню по клику вне
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeRightClick = (node: GraphNode, event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY, node, link: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || data.nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-4 h-full flex flex-col"
|
||||||
|
style={{ backgroundColor: "var(--card-bg)" }}
|
||||||
|
>
|
||||||
|
{/* Статистика сверху */}
|
||||||
|
<GraphStats data={data} />
|
||||||
|
|
||||||
|
{/* Граф */}
|
||||||
|
<div
|
||||||
|
className="flex-1 rounded-lg overflow-hidden relative mt-2"
|
||||||
|
style={{ border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<ForceGraph
|
||||||
|
ref={fgRef}
|
||||||
|
data={data}
|
||||||
|
onNodeRightClick={handleNodeRightClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GraphContextMenu
|
||||||
|
menu={contextMenu}
|
||||||
|
data={data}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки снизу */}
|
||||||
|
<GraphControls
|
||||||
|
fgRef={fgRef}
|
||||||
|
onExport={onExport}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Graph;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
import ForceGraph2D from "react-force-graph-2d";
|
||||||
|
import type { GraphData, GraphNode, GraphLink } from "../types";
|
||||||
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
|
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||||
|
|
||||||
|
interface ForceGraphProps {
|
||||||
|
data: GraphData;
|
||||||
|
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
||||||
|
({ data, onNodeRightClick }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
|
||||||
|
|
||||||
|
const highlightNodes = useGraphStore((s) => s.highlightNodes);
|
||||||
|
const highlightLinks = useGraphStore((s) => s.highlightLinks);
|
||||||
|
const selectedNode = useGraphStore((s) => s.selectedNode);
|
||||||
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
const theme = useThemeStore((s) => s.theme);
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
// Определяем цвета текста в зависимости от темы
|
||||||
|
const nodeTextColor = isDark ? "#e5e7eb" : "#1f2937";
|
||||||
|
const nodeTextLetterColor = isDark ? "#ffffff" : "#000000";
|
||||||
|
|
||||||
|
// ResizeObserver для корректного отслеживания размеров
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
setDimensions({
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeClick = useCallback((node: GraphNode) => {
|
||||||
|
const store = useGraphStore.getState();
|
||||||
|
if (store.isLinkMode) {
|
||||||
|
if (store.selectedNode === null) {
|
||||||
|
store.setSelectedNode(node);
|
||||||
|
} else if (store.selectedNode.id !== node.id) {
|
||||||
|
store.createLink(store.selectedNode.id, node.id);
|
||||||
|
store.setSelectedNode(null);
|
||||||
|
store.toggleLinkMode();
|
||||||
|
} else {
|
||||||
|
store.setSelectedNode(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeHover = (node: GraphNode | null) => {
|
||||||
|
const newHighlightNodes = new Set<string>();
|
||||||
|
const newHighlightLinks = new Set<GraphLink>();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
newHighlightNodes.add(node.id);
|
||||||
|
data.links.forEach((link) => {
|
||||||
|
if (link.source === node.id || link.target === node.id) {
|
||||||
|
newHighlightLinks.add(link);
|
||||||
|
newHighlightNodes.add(link.source as string);
|
||||||
|
newHighlightNodes.add(link.target as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useGraphStore
|
||||||
|
.getState()
|
||||||
|
.setHighlight(newHighlightNodes, newHighlightLinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeColor = (node: GraphNode) => {
|
||||||
|
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";
|
||||||
|
case "agent":
|
||||||
|
return "#8b5cf6";
|
||||||
|
default:
|
||||||
|
return "#6b7280";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeSize = (node: GraphNode) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case "service":
|
||||||
|
return 3;
|
||||||
|
case "agent":
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNode = (
|
||||||
|
node: GraphNode,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
globalScale: number,
|
||||||
|
) => {
|
||||||
|
const size = getNodeSize(node);
|
||||||
|
const color = getNodeColor(node);
|
||||||
|
|
||||||
|
if (!node.x || !node.y) return;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = nodeTextLetterColor;
|
||||||
|
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
|
if (node.type === "service") {
|
||||||
|
ctx.fillText("S", node.x, node.y);
|
||||||
|
} else if (node.type === "agent") {
|
||||||
|
ctx.fillText("A", node.x, node.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalScale > 0.5) {
|
||||||
|
ctx.fillStyle = nodeTextColor;
|
||||||
|
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(node.name, node.x, node.y + size + 8);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEngineStop = () => {
|
||||||
|
if (typeof ref !== "function" && ref && "current" in ref && ref.current) {
|
||||||
|
ref.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full h-full relative">
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={ref}
|
||||||
|
graphData={data}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
nodeCanvasObject={renderNode}
|
||||||
|
nodeLabel={(node: GraphNode) => {
|
||||||
|
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
|
||||||
|
}}
|
||||||
|
linkLabel={(link: GraphLink) => {
|
||||||
|
const sourceName =
|
||||||
|
data.nodes.find((n) => n.id === link.source)?.name || link.source;
|
||||||
|
const targetName =
|
||||||
|
data.nodes.find((n) => n.id === link.target)?.name || link.target;
|
||||||
|
return `Связь: ${sourceName} → ${targetName}\nПКМ для удаления`;
|
||||||
|
}}
|
||||||
|
linkColor={(link: any) => {
|
||||||
|
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
|
||||||
|
}}
|
||||||
|
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
|
||||||
|
linkDirectionalParticles={0}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeRightClick={onNodeRightClick}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
cooldownTicks={50}
|
||||||
|
cooldownTime={2000}
|
||||||
|
d3AlphaDecay={0.03}
|
||||||
|
d3VelocityDecay={0.4}
|
||||||
|
warmupTicks={50}
|
||||||
|
onEngineStop={handleEngineStop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ForceGraph.displayName = "ForceGraph";
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiLink, FiTrash2 } from "react-icons/fi";
|
||||||
|
import type { ContextMenuState, GraphNode, GraphData } from "../types";
|
||||||
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
|
|
||||||
|
interface GraphContextMenuProps {
|
||||||
|
menu: ContextMenuState | null;
|
||||||
|
data: GraphData;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||||
|
menu,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const removeNode = useGraphStore((s) => s.removeNode);
|
||||||
|
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
||||||
|
const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
|
||||||
|
|
||||||
|
if (!menu) return null;
|
||||||
|
|
||||||
|
const handleDeleteNode = (node: GraphNode) => {
|
||||||
|
removeNode(node.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLink = (node: GraphNode) => {
|
||||||
|
toggleLinkMode();
|
||||||
|
setSelectedNode(node);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed rounded-lg shadow-lg py-1 z-50"
|
||||||
|
style={{
|
||||||
|
top: menu.y,
|
||||||
|
left: menu.x,
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{menu.node && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="px-3 py-1 text-xs border-b"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menu.node.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCreateLink(menu.node!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = "var(--bg-secondary)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = "transparent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FiLink size={14} /> Создать связь
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNode(menu.node!)}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} /> Удалить узел
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
FiDownload,
|
||||||
|
FiZoomIn,
|
||||||
|
FiZoomOut,
|
||||||
|
FiMove,
|
||||||
|
FiLink,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
|
import type { GraphData } from "../types";
|
||||||
|
|
||||||
|
interface GraphControlsProps {
|
||||||
|
fgRef: React.RefObject<any>;
|
||||||
|
onExport?: () => void;
|
||||||
|
onDataChange?: (data: GraphData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GraphControls: React.FC<GraphControlsProps> = ({
|
||||||
|
fgRef,
|
||||||
|
onExport,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
||||||
|
const exportData = useGraphStore((s) => s.exportData);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
const currentZoom = fgRef.current.zoom();
|
||||||
|
fgRef.current.zoom(currentZoom * 1.2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
const currentZoom = fgRef.current.zoom();
|
||||||
|
fgRef.current.zoom(currentZoom / 1.2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFit = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
fgRef.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
|
{/* Режим создания связи */}
|
||||||
|
{/* <button
|
||||||
|
onClick={toggleLinkMode}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isLinkMode ? "#22c55e" : "var(--bg-secondary)",
|
||||||
|
color: isLinkMode ? "#fff" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiLink />
|
||||||
|
<span>{isLinkMode ? "Создание связи..." : "Добавить связь"}</span>
|
||||||
|
</button> */}
|
||||||
|
|
||||||
|
{/* Зум + */}
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiZoomIn />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Зум - */}
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiZoomOut />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Fit */}
|
||||||
|
<button
|
||||||
|
onClick={handleFit}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiMove />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Экспорт */}
|
||||||
|
<button
|
||||||
|
onClick={onExport || exportData}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
<span>Экспорт</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { GraphData } from "../types";
|
||||||
|
|
||||||
|
interface GraphStatsProps {
|
||||||
|
data: GraphData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphStats: React.FC<GraphStatsProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex gap-4 text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
||||||
|
</span>
|
||||||
|
<span>Агенты: {data.nodes.filter((n) => n.type === "agent").length}</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-sm"
|
||||||
|
style={{ backgroundColor: "var(--text-muted)" }}
|
||||||
|
></div>
|
||||||
|
<span>Связи: {data.links.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiLink } from "react-icons/fi";
|
||||||
|
import type { GraphNode } from "../types";
|
||||||
|
|
||||||
|
interface GraphStatusBarProps {
|
||||||
|
isLinkMode: boolean;
|
||||||
|
selectedNode: GraphNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphStatusBar: React.FC<GraphStatusBarProps> = ({
|
||||||
|
isLinkMode,
|
||||||
|
selectedNode,
|
||||||
|
}) => {
|
||||||
|
if (!isLinkMode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2"
|
||||||
|
style={{ backgroundColor: "#22c55e" }}
|
||||||
|
>
|
||||||
|
<FiLink /> Режим создания связей: кликните на два узла для соединения
|
||||||
|
{selectedNode && (
|
||||||
|
<span className="ml-2">Выбран: {selectedNode.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { ForceGraph } from "./ForceGraph";
|
||||||
|
export { GraphControls } from "./GraphControls";
|
||||||
|
export { GraphContextMenu } from "./GraphContextMenu";
|
||||||
|
export { GraphStatusBar } from "./GraphStatusBar";
|
||||||
|
export { GraphStats } from "./GraphStats";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { Graph } from "./Graph";
|
||||||
|
export { useGraphStore } from "./store/useGraphStore";
|
||||||
|
export type { GraphData, GraphNode, GraphLink } from "./types";
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { GraphData, GraphNode, GraphLink } from "../types";
|
||||||
|
|
||||||
|
interface GraphState {
|
||||||
|
data: GraphData;
|
||||||
|
highlightNodes: Set<string>;
|
||||||
|
highlightLinks: Set<GraphLink>;
|
||||||
|
isLinkMode: boolean;
|
||||||
|
selectedNode: GraphNode | null;
|
||||||
|
|
||||||
|
// Действия с данными
|
||||||
|
setData: (data: GraphData) => void;
|
||||||
|
addNode: (node: GraphNode) => void;
|
||||||
|
removeNode: (nodeId: string) => void;
|
||||||
|
addLink: (link: GraphLink) => void;
|
||||||
|
removeLink: (link: GraphLink) => void;
|
||||||
|
|
||||||
|
// Подсветка
|
||||||
|
setHighlight: (nodeIds: Set<string>, links: Set<GraphLink>) => void;
|
||||||
|
|
||||||
|
// Режим связи
|
||||||
|
toggleLinkMode: () => void;
|
||||||
|
setSelectedNode: (node: GraphNode | null) => void;
|
||||||
|
createLink: (sourceId: string, targetId: string) => void;
|
||||||
|
|
||||||
|
// Экспорт
|
||||||
|
exportData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGraphStore = create<GraphState>((set, get) => ({
|
||||||
|
data: { nodes: [], links: [] },
|
||||||
|
highlightNodes: new Set(),
|
||||||
|
highlightLinks: new Set(),
|
||||||
|
isLinkMode: false,
|
||||||
|
selectedNode: null,
|
||||||
|
|
||||||
|
setData: (data) => set({ data }),
|
||||||
|
|
||||||
|
addNode: (node) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
nodes: [...state.data.nodes, node],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNode: (nodeId) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
nodes: state.data.nodes.filter((n) => n.id !== nodeId),
|
||||||
|
links: state.data.links.filter(
|
||||||
|
(l) => l.source !== nodeId && l.target !== nodeId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
addLink: (link) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
links: [...state.data.links, link],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeLink: (linkToRemove) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
links: state.data.links.filter((l) => l !== linkToRemove),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setHighlight: (nodeIds, links) =>
|
||||||
|
set({ highlightNodes: nodeIds, highlightLinks: links }),
|
||||||
|
|
||||||
|
toggleLinkMode: () =>
|
||||||
|
set((state) => ({
|
||||||
|
isLinkMode: !state.isLinkMode,
|
||||||
|
selectedNode: null,
|
||||||
|
})),
|
||||||
|
|
||||||
|
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||||
|
|
||||||
|
createLink: (sourceId, targetId) => {
|
||||||
|
const { data, addLink } = get();
|
||||||
|
|
||||||
|
const linkExists = data.links.some(
|
||||||
|
(link) =>
|
||||||
|
(link.source === sourceId && link.target === targetId) ||
|
||||||
|
(link.source === targetId && link.target === sourceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!linkExists) {
|
||||||
|
addLink({ source: sourceId, target: targetId, type: "custom" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportData: () => {
|
||||||
|
const { data } = get();
|
||||||
|
const dataStr = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([dataStr], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "graph-data.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "agent" | "service";
|
||||||
|
val?: number;
|
||||||
|
description?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
status?: "up" | "down";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { MdAdd, MdArrowBack } from "react-icons/md";
|
||||||
|
import { GoTrash } from "react-icons/go";
|
||||||
|
import {
|
||||||
|
useIDEStore,
|
||||||
|
initialFiles as defaultInitialFiles,
|
||||||
|
} from "./store/useIDEStore";
|
||||||
|
import type { FileNode } from "./types";
|
||||||
|
import {
|
||||||
|
FileExplorer,
|
||||||
|
TabBar,
|
||||||
|
CodeEditor,
|
||||||
|
TitleBar,
|
||||||
|
StatusBar,
|
||||||
|
} from "./components";
|
||||||
|
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||||
|
|
||||||
|
interface IDEProps {
|
||||||
|
initialFiles?: FileNode;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkColors = {
|
||||||
|
bg: "#1e1e1e",
|
||||||
|
bgSecondary: "#252526",
|
||||||
|
bgTertiary: "#2d2d30",
|
||||||
|
border: "#3e3e42",
|
||||||
|
textPrimary: "#cccccc",
|
||||||
|
textSecondary: "#858585",
|
||||||
|
accent: "#0e639c",
|
||||||
|
accentHover: "#1177bb",
|
||||||
|
statusBar: "#007acc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightColors = {
|
||||||
|
bg: "#ffffff",
|
||||||
|
bgSecondary: "#f3f3f3",
|
||||||
|
bgTertiary: "#e8e8e8",
|
||||||
|
border: "#e0e0e0",
|
||||||
|
textPrimary: "#333333",
|
||||||
|
textSecondary: "#616161",
|
||||||
|
accent: "#0e639c",
|
||||||
|
accentHover: "#1177bb",
|
||||||
|
statusBar: "#007acc",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IDE: React.FC<IDEProps> = ({
|
||||||
|
initialFiles: externalFiles,
|
||||||
|
onBack,
|
||||||
|
}: IDEProps = {}) => {
|
||||||
|
const theme = useThemeStore((s) => s.theme);
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
const c = isDark ? darkColors : lightColors;
|
||||||
|
|
||||||
|
const files = useIDEStore((state) => state.files);
|
||||||
|
const openFiles = useIDEStore((state) => state.openFiles);
|
||||||
|
const activeFile = useIDEStore((state) => state.activeFile);
|
||||||
|
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) {
|
||||||
|
fetchTree().catch(() => {
|
||||||
|
// Только при ошибке — используем моковые данные
|
||||||
|
const state = useIDEStore.getState();
|
||||||
|
if (!state.files) {
|
||||||
|
const filesToInit = externalFiles || defaultInitialFiles;
|
||||||
|
initialize(filesToInit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
// Если проект не открыт
|
||||||
|
if (!files) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TitleBar />
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "40px",
|
||||||
|
left: "12px",
|
||||||
|
background: "transparent",
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
color: c.textPrimary,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "12px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.border;
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
e.currentTarget.style.borderColor = "#555";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = c.textPrimary;
|
||||||
|
e.currentTarget.style.borderColor = c.border;
|
||||||
|
}}
|
||||||
|
title="Go back"
|
||||||
|
>
|
||||||
|
<MdArrowBack size={16} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoTrash size={72} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "22px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
color: c.textPrimary,
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No project open
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
color: c.textSecondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create a new project to get started
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={createNewProject}
|
||||||
|
style={{
|
||||||
|
padding: "10px 24px",
|
||||||
|
backgroundColor: c.accent,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.accentHover;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.accent;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAdd size={14} /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "30px",
|
||||||
|
backgroundColor: c.bgTertiary,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 8px",
|
||||||
|
borderBottom: `1px solid ${c.bg}`,
|
||||||
|
fontSize: "12px",
|
||||||
|
color: c.textPrimary,
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: c.textPrimary,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "11px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.border;
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = c.textPrimary;
|
||||||
|
}}
|
||||||
|
title="Go back"
|
||||||
|
>
|
||||||
|
<MdArrowBack size={14} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!onBack && <div />}
|
||||||
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{activeFile
|
||||||
|
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
|
||||||
|
: ""}
|
||||||
|
{files.name}
|
||||||
|
</span>
|
||||||
|
<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 }}>
|
||||||
|
<FileExplorer
|
||||||
|
files={files}
|
||||||
|
onDeleteRoot={useIDEStore.getState().deleteRoot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabBar
|
||||||
|
openFiles={openFiles}
|
||||||
|
activeFile={activeFile}
|
||||||
|
onSelectFile={selectFile}
|
||||||
|
onCloseFile={closeFile}
|
||||||
|
onCloseAll={closeAllFiles}
|
||||||
|
onCloseOthers={closeOtherFiles}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
filePath={activeFile?.path || ""}
|
||||||
|
content={activeFile?.content || ""}
|
||||||
|
onChange={updateFileContent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={activeFile} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IDE;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import { FiFolder } from "react-icons/fi";
|
||||||
|
import { getLanguage } from "../helpers/fileTree";
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||||
|
filePath,
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{filePath ? (
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={getLanguage(filePath)}
|
||||||
|
value={content}
|
||||||
|
onChange={(value) => onChange(value || "")}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
|
||||||
|
tabSize: 4,
|
||||||
|
wordWrap: "on",
|
||||||
|
lineNumbers: "on",
|
||||||
|
automaticLayout: true,
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
smoothScrolling: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: "#858585",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiFolder size={64} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
color: "#cccccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome to Web VS Code
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
|
||||||
|
Right-click on a folder to create files
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#0e639c" }}>
|
||||||
|
Or right-click anywhere in the explorer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
|
||||||
|
|
||||||
|
const MenuItem: React.FC<{
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ onClick, danger, children }) => (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: danger ? "#f48771" : "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onNewFile: () => void;
|
||||||
|
onNewFolder: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
hasNode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onNewFile,
|
||||||
|
onNewFolder,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
hasNode,
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = () => onClose();
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "180px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={onNewFile}>
|
||||||
|
<FiFile /> New File
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onNewFolder}>
|
||||||
|
<FiFolder /> New Folder
|
||||||
|
</MenuItem>
|
||||||
|
{hasNode && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "1px",
|
||||||
|
backgroundColor: "#3e3e42",
|
||||||
|
margin: "4px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem onClick={onRename}>
|
||||||
|
<FiEdit3 /> Rename
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onDelete} danger>
|
||||||
|
<FiTrash2 /> Delete
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
|
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
|
||||||
|
import { GoKebabHorizontal } from "react-icons/go";
|
||||||
|
import { MdClose, MdAdd } from "react-icons/md";
|
||||||
|
import { FileTreeItem } from "./FileTreeItem";
|
||||||
|
import { ContextMenu } from "./ContextMenu";
|
||||||
|
import { InputDialog } from "./InputDialog";
|
||||||
|
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
|
||||||
|
import { useIDEStore } from "../store/useIDEStore";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface FileExplorerProps {
|
||||||
|
files: FileNode;
|
||||||
|
onDeleteRoot: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||||
|
files,
|
||||||
|
onDeleteRoot,
|
||||||
|
}) => {
|
||||||
|
const store = useIDEStore();
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Фокус на инпут при открытии поиска
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSearch) {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [showSearch]);
|
||||||
|
|
||||||
|
const handleSearchBlur = useCallback(() => {
|
||||||
|
// Скрываем поиск при потере фокуса с небольшой задержкой,
|
||||||
|
// чтобы клики по кнопке очистки успели сработать
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
searchInputRef.current &&
|
||||||
|
!searchInputRef.current.contains(document.activeElement)
|
||||||
|
) {
|
||||||
|
setShowSearch(false);
|
||||||
|
store.setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загружаем интерпретаторы при монтировании компонента
|
||||||
|
useEffect(() => {
|
||||||
|
if (store.interpreters.length === 0) {
|
||||||
|
store.fetchInterpreters();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredFiles = store.searchQuery
|
||||||
|
? (files.children || [])
|
||||||
|
.map((child) => filterTree(child, store.searchQuery))
|
||||||
|
.filter((child): child is FileNode => child !== null)
|
||||||
|
: files.children || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (store.searchQuery && files) {
|
||||||
|
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
|
||||||
|
if (pathsToExpand.size > 0) {
|
||||||
|
store.autoExpandPaths(pathsToExpand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [store.searchQuery, files, store.autoExpandPaths]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
}}
|
||||||
|
onContextMenu={handleEmptyContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0 8px",
|
||||||
|
height: "35px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#bbbbbb",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
EXPLORER
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!showSearch) {
|
||||||
|
setShowSearch(true);
|
||||||
|
} else {
|
||||||
|
setShowSearch(false);
|
||||||
|
store.setSearchQuery("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: showSearch ? "#cccccc" : "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Search in files"
|
||||||
|
>
|
||||||
|
<FiSearch size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={store.collapseAllFolders}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
}}
|
||||||
|
title="Collapse All"
|
||||||
|
>
|
||||||
|
<FiMinus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={store.expandAllFolders}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
}}
|
||||||
|
title="Expand All"
|
||||||
|
>
|
||||||
|
<GoKebabHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: store.searchQuery
|
||||||
|
? "1px solid #007acc"
|
||||||
|
: "1px solid transparent",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0 6px",
|
||||||
|
transition: "border-color 0.1s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSearch size={13} color="#858585" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={store.searchQuery}
|
||||||
|
onChange={(e) => store.setSearchQuery(e.target.value)}
|
||||||
|
onBlur={handleSearchBlur}
|
||||||
|
placeholder="Search..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "5px 6px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "12px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{store.searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => store.setSearchQuery("")}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||||
|
{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={{
|
||||||
|
padding: "16px",
|
||||||
|
color: "#858585",
|
||||||
|
fontSize: "13px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{store.contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={store.contextMenu.x}
|
||||||
|
y={store.contextMenu.y}
|
||||||
|
onClose={() => store.setContextMenu(null)}
|
||||||
|
onNewFile={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "newFile",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onNewFolder={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "newFolder",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onRename={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "rename",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (store.contextMenu?.node) {
|
||||||
|
store.handleDeleteNode(store.contextMenu.node);
|
||||||
|
}
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
hasNode={!!store.contextMenu.node}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{store.dialog && (
|
||||||
|
<InputDialog
|
||||||
|
title={
|
||||||
|
store.dialog.type === "newFile"
|
||||||
|
? "New File"
|
||||||
|
: store.dialog.type === "newFolder"
|
||||||
|
? "New Folder"
|
||||||
|
: "Rename"
|
||||||
|
}
|
||||||
|
initialValue={
|
||||||
|
store.dialog.type === "rename" && store.dialog.node
|
||||||
|
? store.dialog.node.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={(value, interpreterId) => {
|
||||||
|
store.handleDialogConfirm(value, interpreterId);
|
||||||
|
}}
|
||||||
|
onCancel={() => store.setDialog(null)}
|
||||||
|
interpreters={
|
||||||
|
store.dialog.type === "newFile" ? store.interpreters : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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;
|
||||||
|
onRun?: (path: string) => void;
|
||||||
|
}> = ({ node, level, onRun }) => {
|
||||||
|
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
||||||
|
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
||||||
|
|
||||||
|
const nodePath = node.path || node.name;
|
||||||
|
const isExpanded = expandedFolders.has(nodePath);
|
||||||
|
|
||||||
|
if (node.type === "file") {
|
||||||
|
return (
|
||||||
|
<FilePickerItem
|
||||||
|
name={node.name}
|
||||||
|
type="file"
|
||||||
|
path={nodePath}
|
||||||
|
level={level}
|
||||||
|
onRun={onRun}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilePickerItem
|
||||||
|
name={node.name}
|
||||||
|
type="folder"
|
||||||
|
path={nodePath}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
level={level}
|
||||||
|
onToggleFolder={toggleFolder}
|
||||||
|
>
|
||||||
|
{node.children?.map((child, idx) => (
|
||||||
|
<FilePickerTree
|
||||||
|
key={idx}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
onRun={onRun}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FilePickerItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
|
||||||
|
const terminalOpen = useTerminalStore((s) => s.isOpen);
|
||||||
|
const jobs = useTerminalStore((s) => s.jobs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
overflowY: "auto",
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
FiChevronRight,
|
||||||
|
FiChevronDown,
|
||||||
|
FiFile,
|
||||||
|
FiFolder,
|
||||||
|
FiPlay,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
|
interface FilePickerItemProps {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
path: string;
|
||||||
|
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,
|
||||||
|
isExpanded,
|
||||||
|
children,
|
||||||
|
level,
|
||||||
|
onToggleSelect,
|
||||||
|
onToggleFolder,
|
||||||
|
onRun,
|
||||||
|
}) => {
|
||||||
|
const isFolder = type === "folder";
|
||||||
|
const extension = name.includes(".")
|
||||||
|
? name.split(".").pop()?.toUpperCase()
|
||||||
|
: "";
|
||||||
|
const paddingLeft = 12 + level * 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingLeft: `${paddingLeft}px`,
|
||||||
|
paddingRight: "12px",
|
||||||
|
height: "36px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isFolder && onToggleFolder) {
|
||||||
|
onToggleFolder(path);
|
||||||
|
} else if (!isFolder && onToggleSelect) {
|
||||||
|
onToggleSelect(path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Folder expand icon */}
|
||||||
|
{isFolder && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
display: "flex",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FiChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<FiChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File/Folder icon */}
|
||||||
|
<span style={{ display: "flex", flexShrink: 0 }}>
|
||||||
|
{isFolder ? (
|
||||||
|
<FiFolder size={15} color="var(--accent)" />
|
||||||
|
) : (
|
||||||
|
<FiFile size={15} color="var(--text-secondary)" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Extension badge — только у файлов */}
|
||||||
|
{!isFolder && extension && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "2px 6px",
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderRadius: "3px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extension}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run button — только у файлов */}
|
||||||
|
{!isFolder && onRun && (
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
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();
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<FiPlay size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children */}
|
||||||
|
{isFolder && isExpanded && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
|
||||||
|
import { GoFile } from "react-icons/go";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface FileTreeItemProps {
|
||||||
|
node: FileNode;
|
||||||
|
level: number;
|
||||||
|
onFileSelect: (node: FileNode) => void;
|
||||||
|
selectedFile: string | null;
|
||||||
|
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
onDelete: (node: FileNode) => void;
|
||||||
|
isRoot?: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
|
||||||
|
node,
|
||||||
|
level,
|
||||||
|
onFileSelect,
|
||||||
|
selectedFile,
|
||||||
|
onContextMenu,
|
||||||
|
expandedFolders,
|
||||||
|
onToggleFolder,
|
||||||
|
onDelete,
|
||||||
|
isRoot,
|
||||||
|
searchQuery,
|
||||||
|
}) => {
|
||||||
|
const isFolder = node.type === "folder";
|
||||||
|
const isSelected = selectedFile === node.path && !isFolder;
|
||||||
|
const isExpanded = expandedFolders.has(node.path || node.name);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isFolder) {
|
||||||
|
onToggleFolder(node.path || node.name);
|
||||||
|
} else {
|
||||||
|
onFileSelect(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightText = (text: string, query: string) => {
|
||||||
|
if (!query) return text;
|
||||||
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (idx === -1) return text;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, idx)}
|
||||||
|
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
|
||||||
|
{text.slice(idx, idx + query.length)}
|
||||||
|
</span>
|
||||||
|
{text.slice(idx + query.length)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, node)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
|
||||||
|
paddingTop: "4px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
backgroundColor: isSelected ? "#094771" : "transparent",
|
||||||
|
color: isSelected ? "#fff" : "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
userSelect: "none",
|
||||||
|
minHeight: "28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
width: "16px",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFolder ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<FiChevronDown />
|
||||||
|
) : (
|
||||||
|
<FiChevronRight />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<GoFile />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
|
||||||
|
</span>
|
||||||
|
{hovered && !isRoot && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
title={`Delete ${node.name}`}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "3px",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#f48771";
|
||||||
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isFolder && isExpanded && node.children && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child, idx) => (
|
||||||
|
<FileTreeItem
|
||||||
|
key={idx}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
onDelete={onDelete}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import type { Interpreter } from "../types";
|
||||||
|
|
||||||
|
interface InputDialogProps {
|
||||||
|
title: string;
|
||||||
|
initialValue?: string;
|
||||||
|
onConfirm: (value: string, interpreterId?: number) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
interpreters?: Interpreter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputDialog: React.FC<InputDialogProps> = ({
|
||||||
|
title,
|
||||||
|
initialValue = "",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
interpreters,
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [interpreterId, setInterpreterId] = useState<number | undefined>(
|
||||||
|
interpreters?.[0]?.id,
|
||||||
|
);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showInterpreterDropdown = interpreters && interpreters.length > 0;
|
||||||
|
|
||||||
|
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={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#2d2d30",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
minWidth: "320px",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
||||||
|
Enter a name
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
value.trim() &&
|
||||||
|
onConfirm(value.trim(), interpreterId)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "14px",
|
||||||
|
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" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "1px solid #0e639c",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#0e639c",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
value.trim() && onConfirm(value.trim(), interpreterId)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiGitBranch, FiCheckCircle, FiAlertCircle } from "react-icons/fi";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBar: React.FC<StatusBarProps> = ({ activeFile }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "22px",
|
||||||
|
backgroundColor: "#007acc",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffffff",
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
|
<FiGitBranch size={12} /> main
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||||
|
{activeFile && (
|
||||||
|
<span>
|
||||||
|
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
||||||
|
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Web VS Code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { GoFile } from "react-icons/go";
|
||||||
|
import { MdClose } from "react-icons/md";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
openFiles: FileNode[];
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
onSelectFile: (file: FileNode) => void;
|
||||||
|
onCloseFile: (file: FileNode) => void;
|
||||||
|
onCloseAll: () => void;
|
||||||
|
onCloseOthers: (file: FileNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabBar: React.FC<TabBarProps> = ({
|
||||||
|
openFiles,
|
||||||
|
activeFile,
|
||||||
|
onSelectFile,
|
||||||
|
onCloseFile,
|
||||||
|
onCloseAll,
|
||||||
|
onCloseOthers,
|
||||||
|
}) => {
|
||||||
|
const [showContextMenu, setShowContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, file: FileNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowContextMenu({ x: e.clientX, y: e.clientY, file });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
overflowX: "auto",
|
||||||
|
minHeight: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
padding: "0 12px",
|
||||||
|
gap: "8px",
|
||||||
|
borderRight: "1px solid #3e3e42",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCloseAll}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Close All"
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
<span style={{ fontSize: "11px" }}>Close All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{openFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => onSelectFile(file)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor:
|
||||||
|
activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30",
|
||||||
|
color: activeFile?.path === file.path ? "#fff" : "#cccccc",
|
||||||
|
borderRight: "1px solid #3e3e42",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
gap: "10px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
borderTop:
|
||||||
|
activeFile?.path === file.path
|
||||||
|
? "2px solid #0e639c"
|
||||||
|
: "2px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoFile />
|
||||||
|
<span>{file.name}</span>
|
||||||
|
{file.dirty && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "8px",
|
||||||
|
height: "8px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#fbbf24",
|
||||||
|
marginLeft: "-4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCloseFile(file);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "16px",
|
||||||
|
padding: "0 4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showContextMenu && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: showContextMenu.y,
|
||||||
|
left: showContextMenu.x,
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "160px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCloseOthers(showContextMenu.file);
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close Others
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCloseAll();
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close All
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiGitBranch, FiCheckCircle } from "react-icons/fi";
|
||||||
|
|
||||||
|
export const TitleBar: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "32px",
|
||||||
|
backgroundColor: "#2d2d30",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#ed6a5e",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#f5bd4f",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#61c454",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
|
||||||
|
Web VS Code
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiGitBranch size={12} color="#858585" />
|
||||||
|
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
|
||||||
|
<FiCheckCircle size={12} color="#61c454" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { ContextMenu } from "./ContextMenu";
|
||||||
|
export { InputDialog } from "./InputDialog";
|
||||||
|
export { FileTreeItem } from "./FileTreeItem";
|
||||||
|
export { FileExplorer } from "./FileExplorer";
|
||||||
|
export { TabBar } from "./TabBar";
|
||||||
|
export { CodeEditor } from "./CodeEditor";
|
||||||
|
export { TitleBar } from "./TitleBar";
|
||||||
|
export { StatusBar } from "./StatusBar";
|
||||||
|
export { FilePickerItem } from "./FilePickerItem";
|
||||||
|
export { FilePicker } from "./FilePicker";
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
export const addPaths = (node: FileNode, parentPath: string = ""): FileNode => {
|
||||||
|
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||||
|
const newNode = { ...node, path: currentPath };
|
||||||
|
if (newNode.children) {
|
||||||
|
newNode.children = newNode.children.map((child) =>
|
||||||
|
addPaths(child, currentPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllFolderPaths = (node: FileNode): string[] => {
|
||||||
|
let paths: string[] = [];
|
||||||
|
if (node.type === "folder") {
|
||||||
|
paths.push(node.path || node.name);
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
paths = [...paths, ...getAllFolderPaths(child)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findNode = (node: FileNode, path: string): FileNode | null => {
|
||||||
|
if (node.path === path) return node;
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findNode(child, path);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNode = (node: FileNode, path: string): FileNode | null => {
|
||||||
|
if (node.path === path) return null;
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const filtered = node.children.filter((child) => child.path !== path);
|
||||||
|
const mapped = filtered
|
||||||
|
.map((child) => deleteNode(child, path))
|
||||||
|
.filter((child): child is FileNode => child !== null);
|
||||||
|
return { ...node, children: mapped };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addNode = (
|
||||||
|
node: FileNode,
|
||||||
|
parentPath: string,
|
||||||
|
newNode: FileNode,
|
||||||
|
): FileNode => {
|
||||||
|
if (node.path === parentPath) {
|
||||||
|
const newPath = addPaths(newNode, node.path);
|
||||||
|
return { ...node, children: [...(node.children || []), newPath] };
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map((child) =>
|
||||||
|
addNode(child, parentPath, newNode),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameNode = (
|
||||||
|
node: FileNode,
|
||||||
|
oldPath: string,
|
||||||
|
newName: string,
|
||||||
|
): FileNode | null => {
|
||||||
|
if (node.path === oldPath) {
|
||||||
|
const pathParts = node.path?.split("/") || [];
|
||||||
|
pathParts[pathParts.length - 1] = newName;
|
||||||
|
const newPath = pathParts.join("/");
|
||||||
|
const renamedNode = { ...node, name: newName, path: newPath };
|
||||||
|
|
||||||
|
if (renamedNode.children) {
|
||||||
|
renamedNode.children = renamedNode.children.map((child) => {
|
||||||
|
const oldChildPath = child.path || "";
|
||||||
|
const newChildPath = oldChildPath.replace(oldPath, newPath);
|
||||||
|
return (
|
||||||
|
renameNode(
|
||||||
|
child,
|
||||||
|
oldChildPath,
|
||||||
|
newChildPath.split("/").pop() || "",
|
||||||
|
) || child
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return renamedNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map(
|
||||||
|
(child) => renameNode(child, oldPath, newName) || child,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterTree = (node: FileNode, query: string): FileNode | null => {
|
||||||
|
if (!query) return node;
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
if (node.type === "file") {
|
||||||
|
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const filteredChildren = node.children
|
||||||
|
.map((child) => filterTree(child, query))
|
||||||
|
.filter((child): child is FileNode => child !== null);
|
||||||
|
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectPathsToExpand = (
|
||||||
|
node: FileNode,
|
||||||
|
query: string,
|
||||||
|
): Set<string> => {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
if (!query) return paths;
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
const search = (n: FileNode, currentPath: string) => {
|
||||||
|
if (n.name.toLowerCase().includes(lowerQuery)) {
|
||||||
|
const pathParts = currentPath.split("/");
|
||||||
|
for (let i = 1; i < pathParts.length; i++) {
|
||||||
|
paths.add(pathParts.slice(0, i).join("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.children) {
|
||||||
|
n.children.forEach((child) => {
|
||||||
|
const childPath = child.path || `${currentPath}/${child.name}`;
|
||||||
|
search(child, childPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
search(node, node.path || node.name);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLanguage = (path: string) => {
|
||||||
|
const ext = path.split(".").pop();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
py: "python",
|
||||||
|
js: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
jsx: "javascript",
|
||||||
|
tsx: "typescript",
|
||||||
|
json: "json",
|
||||||
|
md: "markdown",
|
||||||
|
css: "css",
|
||||||
|
html: "html",
|
||||||
|
};
|
||||||
|
return map[ext || ""] || "plaintext";
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { IDE } from "./IDE";
|
||||||
|
export { FilePicker } from "./components/FilePicker";
|
||||||
|
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
||||||
|
export { useFilePickerStore } from "./store/useFilePickerStore";
|
||||||
|
export type { FileNode } from "./types";
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface FilePickerState {
|
||||||
|
selectedPaths: Set<string>;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
|
||||||
|
toggleSelection: (path: string) => void;
|
||||||
|
selectAll: (paths: string[]) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
toggleFolder: (path: string) => void;
|
||||||
|
getSelectedPaths: () => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
||||||
|
selectedPaths: new Set(),
|
||||||
|
expandedFolders: new Set(),
|
||||||
|
|
||||||
|
toggleSelection: (path: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.selectedPaths);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return { selectedPaths: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll: (paths: string[]) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.selectedPaths);
|
||||||
|
paths.forEach((p) => newSet.add(p));
|
||||||
|
return { selectedPaths: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: () => {
|
||||||
|
set({ selectedPaths: new Set() });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFolder: (path: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.expandedFolders);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return { expandedFolders: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedPaths: () => {
|
||||||
|
return Array.from(get().selectedPaths);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { FileNode, Interpreter, DialogState } from "../types";
|
||||||
|
import {
|
||||||
|
addPaths,
|
||||||
|
getAllFolderPaths,
|
||||||
|
findNode,
|
||||||
|
deleteNode,
|
||||||
|
addNode,
|
||||||
|
renameNode,
|
||||||
|
} from "../helpers/fileTree";
|
||||||
|
import { scriptsApi } from "../api/scripts.api";
|
||||||
|
|
||||||
|
export const initialFiles: FileNode = {
|
||||||
|
name: "my-project",
|
||||||
|
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: "# My Project\n\nWelcome!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IDEFileNode extends FileNode {
|
||||||
|
dirty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IDEState {
|
||||||
|
// Файловая система
|
||||||
|
files: 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: 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;
|
||||||
|
|
||||||
|
// Действия с деревом
|
||||||
|
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => void;
|
||||||
|
toggleFolder: (path: string) => void;
|
||||||
|
expandAllFolders: () => void;
|
||||||
|
collapseAllFolders: () => void;
|
||||||
|
autoExpandPaths: (paths: Set<string>) => void;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Контекстные меню и диалоги
|
||||||
|
setContextMenu: (
|
||||||
|
menu: { x: number; y: number; node: FileNode | null } | null,
|
||||||
|
) => void;
|
||||||
|
setDialog: (
|
||||||
|
dialog: {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
} | null,
|
||||||
|
) => void;
|
||||||
|
setTabContextMenu: (
|
||||||
|
menu: { x: number; y: number; file: FileNode } | null,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
initialize: (initialFiles: FileNode) => void;
|
||||||
|
|
||||||
|
// Диалог подтверждения
|
||||||
|
handleDialogConfirm: (value: string, interpreterId?: number) => Promise<void>;
|
||||||
|
handleDeleteNode: (node: FileNode) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIDEStore = create<IDEState>((set, get) => ({
|
||||||
|
// Начальное состояние
|
||||||
|
files: null,
|
||||||
|
openFiles: [],
|
||||||
|
activeFile: null,
|
||||||
|
expandedFolders: new Set(),
|
||||||
|
searchQuery: "",
|
||||||
|
showSearch: false,
|
||||||
|
isInitialized: false,
|
||||||
|
|
||||||
|
contextMenu: null,
|
||||||
|
dialog: null,
|
||||||
|
tabContextMenu: null,
|
||||||
|
interpreters: [],
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
initialize: (initialFiles: FileNode) => {
|
||||||
|
const filesWithPaths = addPaths(initialFiles);
|
||||||
|
set({
|
||||||
|
files: filesWithPaths,
|
||||||
|
expandedFolders: new Set([filesWithPaths.path || filesWithPaths.name]),
|
||||||
|
isInitialized: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Выбор файла
|
||||||
|
selectFile: (node: FileNode) => {
|
||||||
|
if (node.type === "file") {
|
||||||
|
const { openFiles, files } = get();
|
||||||
|
// Берём актуальную версию из дерева файлов
|
||||||
|
const latestFile = files ? findNode(files, node.path || "") : null;
|
||||||
|
const fileToOpen =
|
||||||
|
latestFile && latestFile.type === "file" ? latestFile : node;
|
||||||
|
|
||||||
|
if (!openFiles.find((f) => f.path === fileToOpen.path)) {
|
||||||
|
set((state) => ({ openFiles: [...state.openFiles, fileToOpen] }));
|
||||||
|
}
|
||||||
|
set({ activeFile: fileToOpen });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновление содержимого файла
|
||||||
|
updateFileContent: (content: string) => {
|
||||||
|
const { activeFile, files } = get();
|
||||||
|
if (activeFile && files) {
|
||||||
|
const updatedFile = { ...activeFile, content, dirty: true };
|
||||||
|
set({ activeFile: updatedFile });
|
||||||
|
set((state) => ({
|
||||||
|
openFiles: state.openFiles.map((f) =>
|
||||||
|
f.path === activeFile.path ? updatedFile : f,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Обновляем также в дереве файлов
|
||||||
|
const updateFileInTree = (node: FileNode): FileNode => {
|
||||||
|
if (node.path === activeFile.path) {
|
||||||
|
return updatedFile;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map((child) => updateFileInTree(child)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
set({ files: updateFileInTree(files) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрытие файла
|
||||||
|
closeFile: (file: FileNode) => {
|
||||||
|
const { openFiles, activeFile } = get();
|
||||||
|
const newOpenFiles = openFiles.filter((f) => f.path !== file.path);
|
||||||
|
set({ openFiles: newOpenFiles });
|
||||||
|
|
||||||
|
if (activeFile?.path === file.path) {
|
||||||
|
set({ activeFile: newOpenFiles[newOpenFiles.length - 1] || null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрыть все файлы
|
||||||
|
closeAllFiles: () => {
|
||||||
|
set({ openFiles: [], activeFile: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрыть другие файлы
|
||||||
|
closeOtherFiles: (file: FileNode) => {
|
||||||
|
set({ openFiles: [file], activeFile: file });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить файловую систему
|
||||||
|
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => {
|
||||||
|
const { openFiles, activeFile, selectFile } = get();
|
||||||
|
|
||||||
|
set({ files: newFiles });
|
||||||
|
|
||||||
|
if (!newFiles) {
|
||||||
|
set({ openFiles: [], activeFile: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOpenFiles = openFiles
|
||||||
|
.map((f) => {
|
||||||
|
const found = findNode(newFiles, f.path || "");
|
||||||
|
return found && found.type === "file" ? found : null;
|
||||||
|
})
|
||||||
|
.filter((f): f is FileNode => f !== null);
|
||||||
|
|
||||||
|
set({ openFiles: updatedOpenFiles });
|
||||||
|
|
||||||
|
if (newFile) {
|
||||||
|
selectFile(newFile);
|
||||||
|
} else if (activeFile) {
|
||||||
|
const stillExists = findNode(newFiles, activeFile.path || "");
|
||||||
|
if (!stillExists) {
|
||||||
|
set({
|
||||||
|
activeFile: updatedOpenFiles[updatedOpenFiles.length - 1] || null,
|
||||||
|
});
|
||||||
|
} else if (stillExists.type === "file") {
|
||||||
|
set({ activeFile: stillExists });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Переключить папку
|
||||||
|
toggleFolder: (path: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.expandedFolders);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return { expandedFolders: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Раскрыть все папки
|
||||||
|
expandAllFolders: () => {
|
||||||
|
const { files } = get();
|
||||||
|
if (files) {
|
||||||
|
set({ expandedFolders: new Set(getAllFolderPaths(files)) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Свернуть все папки
|
||||||
|
collapseAllFolders: () => {
|
||||||
|
set({ expandedFolders: new Set() });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Автоматически раскрыть пути
|
||||||
|
autoExpandPaths: (paths: Set<string>) => {
|
||||||
|
set((state) => ({
|
||||||
|
expandedFolders: new Set([...state.expandedFolders, ...paths]),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить корень
|
||||||
|
deleteRoot: () => {
|
||||||
|
set({
|
||||||
|
files: null,
|
||||||
|
openFiles: [],
|
||||||
|
activeFile: null,
|
||||||
|
expandedFolders: new Set(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Создать новый проект
|
||||||
|
createNewProject: () => {
|
||||||
|
const newProject = addPaths(initialFiles);
|
||||||
|
set({
|
||||||
|
files: newProject,
|
||||||
|
expandedFolders: new Set([newProject.path || newProject.name]),
|
||||||
|
searchQuery: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Интерпретаторы
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSearch: () => {
|
||||||
|
set((state) => ({ showSearch: !state.showSearch }));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Контекстные меню и диалоги
|
||||||
|
setContextMenu: (menu) => set({ contextMenu: menu }),
|
||||||
|
setDialog: (dialog) => set({ dialog: dialog }),
|
||||||
|
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
|
||||||
|
|
||||||
|
// Подтверждение диалога
|
||||||
|
handleDialogConfirm: async (value: string, interpreterId?: number) => {
|
||||||
|
const { dialog, files, toggleFolder, autoExpandPaths } = get();
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
if (dialog.type === "rename" && dialog.node) {
|
||||||
|
const parentPath =
|
||||||
|
dialog.node.path?.split("/").slice(0, -1).join("/") || "";
|
||||||
|
const parentNode = parentPath ? findNode(files!, parentPath) : files;
|
||||||
|
if (
|
||||||
|
parentNode?.children?.some(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase() === value.toLowerCase() &&
|
||||||
|
c.path !== dialog.node?.path,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
alert(`"${value}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
} 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("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие расширения
|
||||||
|
const hasExtension =
|
||||||
|
value.includes(".") && value.split(".").pop() !== value;
|
||||||
|
let finalName = value;
|
||||||
|
let isFile = false;
|
||||||
|
|
||||||
|
// Если диалог создания файла
|
||||||
|
if (dialog.type === "newFile") {
|
||||||
|
isFile = true;
|
||||||
|
// Если нет расширения — добавляем .txt
|
||||||
|
if (!hasExtension) {
|
||||||
|
finalName = `${value}.txt`;
|
||||||
|
}
|
||||||
|
} else if (dialog.type === "newFolder") {
|
||||||
|
// Если диалог создания папки — но имя с расширением, считаем файлом
|
||||||
|
if (hasExtension) {
|
||||||
|
isFile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
} else {
|
||||||
|
// Создание файла
|
||||||
|
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: async (node: FileNode) => {
|
||||||
|
const { files } = get();
|
||||||
|
const isRootNode = node.path === files?.path;
|
||||||
|
if (isRootNode) {
|
||||||
|
get().deleteRoot();
|
||||||
|
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
content?: string;
|
||||||
|
children?: 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;
|
||||||
|
node: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogState {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
interpreterId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
||||||
import { FiPlusCircle, FiSend } from "react-icons/fi";
|
import { agentApiService } from "../modules/agent/api/agent.api.service";
|
||||||
|
import type { SSHAgentConfig } from "../modules/agent/ui/SSHAgentForm";
|
||||||
interface SSHAgentConfig {
|
import type {
|
||||||
user: string;
|
DeployAgentsRequest,
|
||||||
ip: string;
|
DeployResult,
|
||||||
authMethod: string;
|
} from "../modules/agent/types/agent.types";
|
||||||
sshKey?: string;
|
import {
|
||||||
password?: string;
|
FiPlusCircle,
|
||||||
extraFields: { key: string; value: string }[];
|
FiSend,
|
||||||
deployType: string;
|
FiCheck,
|
||||||
}
|
FiX,
|
||||||
|
FiAlertCircle,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
||||||
|
agentLabel: "",
|
||||||
user: "",
|
user: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
|
port: 22,
|
||||||
authMethod: "key",
|
authMethod: "key",
|
||||||
sshKey: "",
|
sshKey: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -50,7 +54,9 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Валидация
|
// Валидация
|
||||||
const isValid = agents.every((agent) => {
|
const isValid = agents.every((agent) => {
|
||||||
if (!agent.user || !agent.ip) return false;
|
if (!agent.agentLabel || !agent.user || !agent.ip || !agent.port)
|
||||||
|
return false;
|
||||||
|
if (agent.port < 1 || agent.port > 65535) return false;
|
||||||
if (agent.authMethod === "key" && !agent.sshKey) return false;
|
if (agent.authMethod === "key" && !agent.sshKey) return false;
|
||||||
if (agent.authMethod === "password" && !agent.password) return false;
|
if (agent.authMethod === "password" && !agent.password) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -66,18 +72,53 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Реальный API вызов для развертывания агентов
|
// Преобразуем данные из формы в формат API
|
||||||
console.log("Deploying agents:", agents);
|
const deployData: DeployAgentsRequest = {
|
||||||
|
servers: agents.map((agent) => ({
|
||||||
|
agentLabel: agent.agentLabel,
|
||||||
|
ip: agent.ip,
|
||||||
|
user: agent.user,
|
||||||
|
port: agent.port,
|
||||||
|
authMethod: agent.authMethod as "key" | "password",
|
||||||
|
deployType: (agent.deployType === "deploy"
|
||||||
|
? "docker"
|
||||||
|
: agent.deployType) as "docker" | "binary",
|
||||||
|
...(agent.authMethod === "key"
|
||||||
|
? { sshKey: agent.sshKey }
|
||||||
|
: { password: agent.password }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
// Имитация задержки API
|
// Вызываем API для развертывания агентов
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
const response = await agentApiService.deployAgents(deployData);
|
||||||
|
|
||||||
setSubmitMessage(
|
// Формируем сообщение о результатах
|
||||||
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`,
|
const successCount = response.results.filter(
|
||||||
);
|
(r: DeployResult) => r.success,
|
||||||
setAgents([createEmptyAgentConfig()]);
|
).length;
|
||||||
|
const failCount = response.results.length - successCount;
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
setSubmitMessage(
|
||||||
|
`Успешно развернуто ${successCount} агент(ов) на ${agents.length} сервер(ах)`,
|
||||||
|
);
|
||||||
|
setAgents([createEmptyAgentConfig()]);
|
||||||
|
} else {
|
||||||
|
const errorMsg = response.results
|
||||||
|
.filter((r: DeployResult) => !r.success)
|
||||||
|
.map(
|
||||||
|
(r: DeployResult) => `${r.ip}: ${r.error || "Неизвестная ошибка"}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
setSubmitMessage(`Успешно: ${successCount}, Ошибки: ${failCount}`);
|
||||||
|
setSubmitError(`Ошибки при развертывании:\n${errorMsg}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError("Ошибка при развертывании на серверах");
|
setSubmitError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка при развертывании агентов",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -162,20 +203,26 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
color: "var(--success-text)",
|
color: "var(--success-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{submitMessage}
|
<div className="flex items-start gap-2">
|
||||||
|
<FiCheck className="mt-0.5 flex-shrink-0" size={16} />
|
||||||
|
<span>{submitMessage}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<div
|
<div
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
className="mb-6 p-4 rounded-lg border text-sm whitespace-pre-wrap"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--error-bg)",
|
backgroundColor: "var(--error-bg)",
|
||||||
borderColor: "var(--error-border)",
|
borderColor: "var(--error-border)",
|
||||||
color: "var(--error-text)",
|
color: "var(--error-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{submitError}
|
<div className="flex items-start gap-2">
|
||||||
|
<FiAlertCircle className="mt-0.5 flex-shrink-0" size={16} />
|
||||||
|
<span>{submitError}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminPanel } from "@/modules/admin";
|
||||||
|
|
||||||
|
export const AdminPage = () => {
|
||||||
|
return <AdminPanel />;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { DashboardChart } from "@/modules/dashboard/components/dashboard.chart";
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
const generateTimeData = (count: number, base: number, variance: number) => {
|
||||||
|
const data = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const time = new Date(now.getTime() - i * 60000);
|
||||||
|
const h = time.getHours().toString().padStart(2, "0");
|
||||||
|
const m = time.getMinutes().toString().padStart(2, "0");
|
||||||
|
data.push({
|
||||||
|
timestamp: `${h}:${m}`,
|
||||||
|
value: Math.round(
|
||||||
|
base + Math.sin(i / 3) * variance + Math.random() * variance * 0.5,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cpuData = generateTimeData(20, 45, 25).map((d, i) => ({
|
||||||
|
timestamp: d.timestamp,
|
||||||
|
"Использование %": d.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ramData = generateTimeData(20, 60, 15).map((d) => ({
|
||||||
|
timestamp: d.timestamp,
|
||||||
|
"Использовано ГБ": d.value / 10,
|
||||||
|
"Свободно ГБ": 16 - d.value / 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const diskData = generateTimeData(20, 70, 5).map((d) => ({
|
||||||
|
timestamp: d.timestamp,
|
||||||
|
"Занято ГБ": d.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const networkData = generateTimeData(20, 50, 30).map((d) => ({
|
||||||
|
timestamp: d.timestamp,
|
||||||
|
"Входящий Мбит/с": d.value,
|
||||||
|
"Исходящий Мбит/с": Math.round(d.value * 0.4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const metricData = [
|
||||||
|
{ name: "INFO", value: 125 },
|
||||||
|
{ name: "WARN", value: 42 },
|
||||||
|
{ name: "ERROR", value: 18 },
|
||||||
|
{ name: "CRITICAL", value: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DashboardPage = () => {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "16px 20px" }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Мониторинг системы
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "1100px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Центр: Метрика логов — круговая диаграмма */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: "100%", maxWidth: 600 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Метрики логов
|
||||||
|
</h3>
|
||||||
|
<div style={{ height: 320 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={metricData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={65}
|
||||||
|
outerRadius={110}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{metricData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={
|
||||||
|
["#10b981", "#f59e0b", "#ef4444", "#dc2626"][index]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "11px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{
|
||||||
|
fontSize: "11px",
|
||||||
|
paddingTop: "4px",
|
||||||
|
}}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Верхний ряд: CPU + RAM */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, 1fr)",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardChart
|
||||||
|
title="CPU"
|
||||||
|
type="line"
|
||||||
|
data={cpuData}
|
||||||
|
dataKeys={["Использование %"]}
|
||||||
|
colors={["#3b82f6"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardChart
|
||||||
|
title="Оперативная память"
|
||||||
|
type="area"
|
||||||
|
data={ramData}
|
||||||
|
dataKeys={["Использовано ГБ", "Свободно ГБ"]}
|
||||||
|
colors={["#10b981", "#64748b"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Нижний ряд: Диск + Сеть */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, 1fr)",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardChart
|
||||||
|
title="Жесткий диск"
|
||||||
|
type="line"
|
||||||
|
data={diskData}
|
||||||
|
dataKeys={["Занято ГБ"]}
|
||||||
|
colors={["#f59e0b"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardChart
|
||||||
|
title="Сеть"
|
||||||
|
type="area"
|
||||||
|
data={networkData}
|
||||||
|
dataKeys={["Входящий Мбит/с", "Исходящий Мбит/с"]}
|
||||||
|
colors={["#8b5cf6", "#06b6d4"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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 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) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { IDE } from "../modules/ide";
|
||||||
|
import type { FileNode } from "../modules/ide";
|
||||||
|
|
||||||
|
export const IDEPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const files: FileNode | undefined = location.state?.files;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 w-full h-full z-90"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<IDE onBack={() => navigate("/templates")} initialFiles={files} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { agentApiService } from "@/modules/agent";
|
||||||
|
import type { LogEntry } from "@/modules/agent";
|
||||||
|
import { LogFilters } from "@/modules/agent/ui/LogFilters";
|
||||||
|
import { useLogFilterStore } from "@/modules/agent/store/logFilter.store";
|
||||||
|
import {
|
||||||
|
FiFileText,
|
||||||
|
FiRefreshCw,
|
||||||
|
FiChevronLeft,
|
||||||
|
FiChevronRight,
|
||||||
|
FiInfo,
|
||||||
|
FiAlertTriangle,
|
||||||
|
FiAlertCircle,
|
||||||
|
FiXOctagon,
|
||||||
|
} 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} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logLevelColors: Record<
|
||||||
|
string,
|
||||||
|
{ bg: string; text: string; border: string }
|
||||||
|
> = {
|
||||||
|
info: {
|
||||||
|
bg: "rgba(59, 130, 246, 0.1)",
|
||||||
|
text: "#3b82f6",
|
||||||
|
border: "rgba(59, 130, 246, 0.3)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "rgba(245, 158, 11, 0.1)",
|
||||||
|
text: "#f59e0b",
|
||||||
|
border: "rgba(245, 158, 11, 0.3)",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "var(--error-bg)",
|
||||||
|
text: "var(--error-text)",
|
||||||
|
border: "var(--error-border)",
|
||||||
|
},
|
||||||
|
fatal: {
|
||||||
|
bg: "rgba(168, 85, 247, 0.1)",
|
||||||
|
text: "#a855f7",
|
||||||
|
border: "rgba(168, 85, 247, 0.3)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogsPage: React.FC = () => {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [availableServices, setAvailableServices] = useState<string[]>([]);
|
||||||
|
const [availableAgents, setAvailableAgents] = useState<string[]>([]);
|
||||||
|
const [totalLogs, setTotalLogs] = useState(0);
|
||||||
|
|
||||||
|
const { getFilters, limit, offset, setOffset } = useLogFilterStore();
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters = getFilters();
|
||||||
|
const data = await agentApiService.searchLogs(filters);
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
console.error("[Logs] Expected array, got:", typeof data);
|
||||||
|
setLogs([]);
|
||||||
|
setTotalLogs(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogs(data);
|
||||||
|
setTotalLogs(data.length);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Ошибка при загрузке логов",
|
||||||
|
);
|
||||||
|
setLogs([]);
|
||||||
|
setTotalLogs(0);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [getFilters]);
|
||||||
|
|
||||||
|
const fetchDistinctData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [servicesResult, agentsResult] = await Promise.allSettled([
|
||||||
|
agentApiService.getDistinctServices(),
|
||||||
|
agentApiService.getDistinctAgents(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
servicesResult.status === "fulfilled" &&
|
||||||
|
Array.isArray(servicesResult.value)
|
||||||
|
) {
|
||||||
|
setAvailableServices(servicesResult.value);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[Logs] Failed to fetch services:",
|
||||||
|
servicesResult.status === "rejected"
|
||||||
|
? servicesResult.reason
|
||||||
|
: "non-array response",
|
||||||
|
);
|
||||||
|
setAvailableServices([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
agentsResult.status === "fulfilled" &&
|
||||||
|
Array.isArray(agentsResult.value)
|
||||||
|
) {
|
||||||
|
setAvailableAgents(agentsResult.value);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[Logs] Failed to fetch agents:",
|
||||||
|
agentsResult.status === "rejected"
|
||||||
|
? agentsResult.reason
|
||||||
|
: "non-array response",
|
||||||
|
);
|
||||||
|
setAvailableAgents([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Logs] Failed to fetch distinct data:", err);
|
||||||
|
setAvailableServices([]);
|
||||||
|
setAvailableAgents([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDistinctData();
|
||||||
|
}, [fetchDistinctData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs, offset, limit]);
|
||||||
|
|
||||||
|
const handleFilterApply = () => {
|
||||||
|
setOffset(0);
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
setOffset(offset + limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
setOffset(Math.max(0, offset - limit));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string | undefined | null) => {
|
||||||
|
if (!timestamp) return "—";
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (isNaN(date.getTime())) return "—";
|
||||||
|
return date.toLocaleString("ru-RU", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen py-8 px-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
|
>
|
||||||
|
<FiFileText
|
||||||
|
className="w-7 h-7"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold mb-1"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Поиск логов
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
||||||
|
Фильтрация и анализ логов системы
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<LogFilters
|
||||||
|
onApply={handleFilterApply}
|
||||||
|
availableServices={availableServices}
|
||||||
|
availableAgents={availableAgents}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--error-bg)",
|
||||||
|
borderColor: "var(--error-border)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Table Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Найдено: {totalLogs} записей
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={fetchLogs}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--accent)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiRefreshCw
|
||||||
|
size={12}
|
||||||
|
className={isLoading ? "animate-spin" : ""}
|
||||||
|
/>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center py-12"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<FiRefreshCw size={24} className="animate-spin mr-3" />
|
||||||
|
Загрузка логов...
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="text-center py-12"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
Логи не найдены
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Время
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Уровень
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Сервис
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Агент
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Сообщение
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log, index) => {
|
||||||
|
const level = log.Level?.toLowerCase() || "info";
|
||||||
|
const colors =
|
||||||
|
logLevelColors[level] || logLevelColors.info;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className="border-t transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor:
|
||||||
|
index % 2 === 0
|
||||||
|
? "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)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
color: colors.text,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logLevelIcons[level]}
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 text-sm"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{log.Service || "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 text-sm font-mono"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{log.Agent || "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 text-sm"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{log.Message || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-t"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={offset === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiChevronLeft size={16} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Показано {logs.length} записей (смещение: {offset})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={logs.length < limit}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
<FiChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|||||||
|
|
||||||
export const RegisterPage: React.FC = () => {
|
export const RegisterPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { register, isLoading, error, clearError, token } = useAuthStore();
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -14,12 +14,7 @@ export const RegisterPage: React.FC = () => {
|
|||||||
lastName: "",
|
lastName: "",
|
||||||
});
|
});
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
|
||||||
if (token) {
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
}, [token, navigate]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -38,7 +33,17 @@ export const RegisterPage: React.FC = () => {
|
|||||||
firstName: formData.firstName,
|
firstName: formData.firstName,
|
||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
});
|
});
|
||||||
navigate("/");
|
setSuccessMessage("Аккаунт успешно создан! Теперь вы можете войти.");
|
||||||
|
setFormData({
|
||||||
|
login: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/auth");
|
||||||
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by store
|
// Error is handled by store
|
||||||
}
|
}
|
||||||
@@ -82,7 +87,10 @@ export const RegisterPage: React.FC = () => {
|
|||||||
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
>
|
>
|
||||||
<FiUserPlus className="w-8 h-8" style={{ color: "var(--accent)" }} />
|
<FiUserPlus
|
||||||
|
className="w-8 h-8"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl font-bold mb-2"
|
className="text-3xl font-bold mb-2"
|
||||||
@@ -109,6 +117,20 @@ export const RegisterPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--success-bg)",
|
||||||
|
borderColor: "var(--success-border)",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Name Fields */}
|
{/* Name Fields */}
|
||||||
@@ -293,8 +315,16 @@ export const RegisterPage: React.FC = () => {
|
|||||||
className="mt-2 text-sm flex items-center gap-1"
|
className="mt-2 text-sm flex items-center gap-1"
|
||||||
style={{ color: "var(--error-text)" }}
|
style={{ color: "var(--error-text)" }}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{passwordError}
|
{passwordError}
|
||||||
</p>
|
</p>
|
||||||
@@ -311,7 +341,8 @@ export const RegisterPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)";
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--button-primary-hover)";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import { FiKey, FiPlus, FiTrash2, FiCopy, FiCheck, FiX } from "react-icons/fi";
|
||||||
|
|
||||||
|
interface RegistrationTokenForm {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegistrationResult {
|
||||||
|
label: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegistrationTokenPage: React.FC = () => {
|
||||||
|
const [tokens, setTokens] = useState<RegistrationTokenForm[]>([
|
||||||
|
{ label: "" },
|
||||||
|
]);
|
||||||
|
const [results, setResults] = useState<RegistrationResult[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleTokenChange = (index: number, label: string) => {
|
||||||
|
const newTokens = [...tokens];
|
||||||
|
newTokens[index] = { label };
|
||||||
|
setTokens(newTokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToken = () => {
|
||||||
|
setTokens([...tokens, { label: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveToken = (index: number) => {
|
||||||
|
const newTokens = tokens.filter((_, i) => i !== index);
|
||||||
|
setTokens(newTokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyToken = async (token: string, index: number) => {
|
||||||
|
await navigator.clipboard.writeText(token);
|
||||||
|
setCopiedIndex(index);
|
||||||
|
setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
const validTokens = tokens.filter((t) => t.label.trim());
|
||||||
|
if (validTokens.length === 0) {
|
||||||
|
setError("Введите хотя бы одну метку для токена");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
setResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdTokens: RegistrationResult[] = [];
|
||||||
|
|
||||||
|
for (const tokenData of validTokens) {
|
||||||
|
const response = await agentApiService.createRegistrationToken({
|
||||||
|
label: tokenData.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API возвращает объект с токеном
|
||||||
|
const token = response.token || Object.values(response)[0] as string;
|
||||||
|
|
||||||
|
createdTokens.push({
|
||||||
|
label: tokenData.label,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(createdTokens);
|
||||||
|
setSuccessMessage(
|
||||||
|
`Успешно создано ${createdTokens.length} токен(ов)`
|
||||||
|
);
|
||||||
|
setTokens([{ label: "" }]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Ошибка при создании токенов"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
transition: "border-color 0.2s, box-shadow 0.2s",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "8px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen py-8 px-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "900px", margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
|
>
|
||||||
|
<FiKey className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold mb-1"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Регистрация токенов для агентов
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
||||||
|
Создайте токены для регистрации новых агентов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Token Forms */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{tokens.map((token, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-2xl shadow-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
padding: "24px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "20px",
|
||||||
|
paddingBottom: "16px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
|
>
|
||||||
|
<FiKey style={{ color: "var(--accent)", fontSize: "20px" }} />
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Токен #{index + 1}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{tokens.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveToken(index)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--error-bg)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
border: "1px solid var(--error-border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--error-text)";
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--error-bg)";
|
||||||
|
e.currentTarget.style.color = "var(--error-text)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label Input */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiKey size={14} />
|
||||||
|
Метка токена *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={token.label}
|
||||||
|
onChange={(e) => handleTokenChange(index, e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
placeholder="agent-production-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Token Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddToken}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl border-2 border-dashed transition-all mb-6 font-medium"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--accent)",
|
||||||
|
fontSize: "15px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent)";
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--accent)10";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiPlus size={18} />
|
||||||
|
Добавить ещё один токен
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{successMessage && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--success-bg)",
|
||||||
|
borderColor: "var(--success-border)",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{successMessage}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuccessMessage(null)}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
|
||||||
|
>
|
||||||
|
<FiX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--error-bg)",
|
||||||
|
borderColor: "var(--error-border)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
|
||||||
|
>
|
||||||
|
<FiX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-base mb-8"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSubmitting
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
boxShadow: isSubmitting
|
||||||
|
? "none"
|
||||||
|
: "0 4px 14px var(--shadow-color)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--button-primary-hover)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isSubmitting
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--button-primary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
|
Создание токенов...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FiKey size={18} />
|
||||||
|
Создать {tokens.filter((t) => t.label.trim()).length || 1} токен(ов)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className="text-xl font-bold mb-4"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Созданные токены
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{result.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyToken(result.token, index)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
copiedIndex === index
|
||||||
|
? "var(--success-text)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color:
|
||||||
|
copiedIndex === index
|
||||||
|
? "#fff"
|
||||||
|
: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedIndex === index ? (
|
||||||
|
<>
|
||||||
|
<FiCheck size={12} />
|
||||||
|
Скопировано
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FiCopy size={12} />
|
||||||
|
Копировать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<code
|
||||||
|
className="block p-3 rounded-lg text-xs font-mono break-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--accent)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.token}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
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 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 { 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",
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
padding: "12px 16px",
|
||||||
|
gap: "12px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Add Interpreter button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddInterpreter(true)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "6px 14px",
|
||||||
|
backgroundColor: "#238636",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ffffff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2ea043";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#238636";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAdd size={14} />
|
||||||
|
Add Interpreter
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Open in Editor button — только с правом manage_agent */}
|
||||||
|
{canManageAgent && (
|
||||||
|
<button
|
||||||
|
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 (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>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
import { ThemeChanger } from "@/modules/theme-changer";
|
|
||||||
|
|
||||||
export const ThemesPage = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ThemeChanger label="Выбор тем приложения" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -16,13 +16,40 @@ class ApiClient {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.axiosInstance = axios.create({
|
this.axiosInstance = axios.create({
|
||||||
baseURL: "http://194.113.106.59:8080/api/v1",
|
baseURL: "http://213.165.213.170:8080/api/v1",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
validateStatus: (status) => {
|
validateStatus: (status) => {
|
||||||
return status >= 200 && status < 500;
|
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("&");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// shared/api/websocket.service.ts
|
// shared/api/websocket.service.ts
|
||||||
import { useAgentStore } from "@/components/layout/sidebar/store/agent.store";
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket";
|
import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket";
|
||||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
@@ -25,12 +25,12 @@ export const useWebSocketService = ({
|
|||||||
const selectedServices: string[] = [];
|
const selectedServices: string[] = [];
|
||||||
const selectedHosts: string[] = [];
|
const selectedHosts: string[] = [];
|
||||||
|
|
||||||
|
// TODO: реализовать механизм выбора сервисов
|
||||||
|
// Пока выбираем все
|
||||||
agents.forEach((agent) => {
|
agents.forEach((agent) => {
|
||||||
agent.services.forEach((service) => {
|
agent.services.forEach((service) => {
|
||||||
if (service.isSelected) {
|
selectedServices.push(service);
|
||||||
selectedServices.push(service.name);
|
selectedHosts.push(agent.token);
|
||||||
selectedHosts.push(agent.token);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,17 @@
|
|||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { ThemeToggle } from "@/modules/theme-bw/ui/ThemeToggle";
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
import React from "react";
|
import { Layout } from "@/app/providers/layout/layout";
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
interface DefaultLayoutProps {
|
export const DefaultLayout = () => {
|
||||||
children?: React.ReactNode;
|
const { token } = useAuthStore();
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => {
|
// if (!token) {
|
||||||
const { user, logout } = useAuthStore();
|
// return <Navigate to="/auth" replace />;
|
||||||
const navigate = useNavigate();
|
// }
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
navigate("/auth");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
|
<Layout>
|
||||||
{/* Header */}
|
<Outlet />
|
||||||
<header
|
</Layout>
|
||||||
className="border-b sticky top-0 z-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--header-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="container mx-auto px-4 py-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
{/* Logo */}
|
|
||||||
<div
|
|
||||||
className="text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
>
|
|
||||||
HellreigN
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ThemeToggle />
|
|
||||||
{user && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{user.firstName} {user.lastName}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg transition-colors font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-danger)",
|
|
||||||
color: "var(--button-danger-text)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--button-danger-hover)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--button-danger)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main className="flex-1">{children || <Outlet />}</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer
|
|
||||||
className="border-t py-4 mt-auto"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<p className="text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
|
||||||
© 2026 HellreigN. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,3 +2,13 @@
|
|||||||
@import "./normalize.css";
|
@import "./normalize.css";
|
||||||
@import "./root.css";
|
@import "./root.css";
|
||||||
@import "./themes.css";
|
@import "./themes.css";
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
|
}
|
||||||
|
|||||||
+1332
-510
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user