Compare commits
10 Commits
debug
...
95a6902dae
| Author | SHA1 | Date | |
|---|---|---|---|
| 95a6902dae | |||
| 96f82b4162 | |||
| ed439656f8 | |||
| d62205b329 | |||
| 11cef95929 | |||
| 43e16b1360 | |||
| f537f1eab9 | |||
| 9d1096a9b4 | |||
| 57b43da2e3 | |||
| 691e1fced5 |
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": {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
|
||||||
|
import { Navigation } from "@/app/providers/layout/navigation/navigation";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
|
||||||
|
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [isOpen, setOpen] = useState(true);
|
||||||
|
const { fetchAgents } = useAgentStore();
|
||||||
|
|
||||||
|
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={isOpen} onToggle={() => setOpen(!isOpen)} />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<Navigation />
|
||||||
|
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
FaHome,
|
||||||
|
FaServer,
|
||||||
|
FaPalette,
|
||||||
|
FaUser,
|
||||||
|
FaUsers,
|
||||||
|
FaRocket,
|
||||||
|
FaKey,
|
||||||
|
FaFileAlt,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
|
export const Navigation = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/", label: "Главная", icon: FaHome },
|
||||||
|
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
|
||||||
|
{ path: "/registration", label: "Регистрация", icon: FaKey },
|
||||||
|
{ path: "/logs", label: "Логи", icon: FaFileAlt },
|
||||||
|
{ path: "/admin", label: "Админка", icon: FaUsers, adminOnly: true },
|
||||||
|
{ path: "/themes", label: "Темы", icon: FaPalette },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Логотип */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
<FaServer style={{ color: "var(--accent)", fontSize: "18px" }} />
|
||||||
|
<span
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Навигация */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{navItems
|
||||||
|
.filter((item) => {
|
||||||
|
if (item.adminOnly && !user?.permission_admin) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||||
|
color: active
|
||||||
|
? "var(--accent-text)"
|
||||||
|
: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!active) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!active) {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={12} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Профиль пользователя */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
|
>
|
||||||
|
<FaUser size={12} style={{ color: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
navigate("/auth");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--error-bg)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
border: "1px solid var(--error-border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--error-text)";
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--error-bg)";
|
||||||
|
e.currentTarget.style.color = "var(--error-text)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { FaBars, FaMicrochip, FaTimes, FaSpinner, FaCopy, FaCheck } from "react-icons/fa";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) => {
|
||||||
|
const { agents, isLoading, error, fetchAgents } = useAgentStore();
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
if (!searchQuery) return agents;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.name.toLowerCase().includes(query) ||
|
||||||
|
agent.services.some((s) => s.name.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
|
const handleCopyToken = () => {
|
||||||
|
if (token) {
|
||||||
|
navigator.clipboard.writeText(token);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="fixed top-4 left-4 z-50 p-2.5 rounded-lg shadow-lg transition-colors md:hidden"
|
||||||
|
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
|
||||||
|
aria-label="Открыть sidebar"
|
||||||
|
>
|
||||||
|
<FaBars size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay для мобильных */}
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onToggle} />
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`fixed md:relative w-72 h-screen z-50 transition-transform duration-300 ease-in-out flex flex-col ${
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
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 md:hidden"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</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) => (
|
||||||
|
<div
|
||||||
|
key={agent.name}
|
||||||
|
className="rounded-lg border p-3 transition-all hover:shadow-md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{agent.services.map((service) => (
|
||||||
|
<div
|
||||||
|
key={service.name}
|
||||||
|
className="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>{service.name}</span>
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
service.status === "running"
|
||||||
|
? "var(--success-bg)"
|
||||||
|
: service.status === "error"
|
||||||
|
? "var(--error-bg)"
|
||||||
|
: "var(--bg-secondary)",
|
||||||
|
color:
|
||||||
|
service.status === "running"
|
||||||
|
? "var(--success-text)"
|
||||||
|
: service.status === "error"
|
||||||
|
? "var(--error-text)"
|
||||||
|
: "var(--text-muted)",
|
||||||
|
border: `1px solid ${
|
||||||
|
service.status === "running"
|
||||||
|
? "var(--success-border)"
|
||||||
|
: service.status === "error"
|
||||||
|
? "var(--error-border)"
|
||||||
|
: "var(--border)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{service.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer с кнопками */}
|
||||||
|
<div
|
||||||
|
className="p-2 border-t flex gap-2"
|
||||||
|
style={{ borderColor: "var(--border)", backgroundColor: "var(--card-bg)" }}
|
||||||
|
>
|
||||||
|
<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={() => setShowTokenModal(false)}
|
||||||
|
>
|
||||||
|
<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={() => setShowTokenModal(false)}
|
||||||
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<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)" }}
|
||||||
|
>
|
||||||
|
{token || "Токен не найден"}
|
||||||
|
</code>
|
||||||
|
{token && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTokenModal(false)}
|
||||||
|
className="w-full py-2 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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.name !== name) });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useAuthStore } from "@/store/auth/auth.store";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
// if (!isAuthenticated) {
|
||||||
return <Navigate to="/auth" replace />;
|
// return <Navigate to="/auth" replace />;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,108 @@ 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 { ThemesPage } from "@/pages/themes.page";
|
||||||
|
import { TestPage } from "@/pages/test.page";
|
||||||
|
import { Test2Page, type GraphData } from "@/pages/test2.page";
|
||||||
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 { AdminPage } from "@/pages/admin.page";
|
||||||
|
import { RegistrationTokenPage } from "@/pages/registration.page";
|
||||||
|
import { LogsPage } from "@/pages/logs.page";
|
||||||
|
|
||||||
|
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 +115,24 @@ export const Routing = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
<Route element={<DefaultLayout />}>
|
<Route element={<DefaultLayout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
<Route path="/themes" element={<ThemesPage />} />
|
||||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
<Route path="/add-agents" element={<AddAgentsPage />} />
|
||||||
|
<Route path="/registration" element={<RegistrationTokenPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/IDE" element={<IDEPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/test" element={<TestPage />} />
|
||||||
|
|
||||||
|
<Route path="/test2" element={<Test2Page data={mockGraphData} />} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
import type {
|
||||||
|
AgentInfo,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
LogFilters,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
DeployResponse,
|
||||||
|
} from "../types/agent.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 response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/tokens`,
|
||||||
|
);
|
||||||
|
return 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,
|
||||||
|
service: filters?.service,
|
||||||
|
agent: filters?.agent,
|
||||||
|
date_from: filters?.date_from,
|
||||||
|
date_to: filters?.date_to,
|
||||||
|
limit: filters?.limit ?? 100,
|
||||||
|
offset: filters?.offset ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctLevels(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/levels`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctServices(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/services`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User management methods
|
||||||
|
async getUserByLogin(login: string): Promise<TokenUser> {
|
||||||
|
const response = await apiClient.get<TokenUser>(
|
||||||
|
`${this.authBasePath}/users/${login}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInactiveUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/users/inactive`,
|
||||||
|
);
|
||||||
|
return 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,86 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type LogLevel = "INFO" | "WARNING" | "ERROR" | "FATAL";
|
||||||
|
|
||||||
|
interface LogFilterState {
|
||||||
|
searchQuery: string;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
selectedLogLevels: LogLevel[];
|
||||||
|
selectedService: string;
|
||||||
|
selectedAgent: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setStartDate: (date: Date | null) => void;
|
||||||
|
setEndDate: (date: Date | null) => void;
|
||||||
|
toggleLogLevel: (level: LogLevel) => 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,
|
||||||
|
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
setStartDate: (date) => set({ startDate: date }),
|
||||||
|
setEndDate: (date) => set({ endDate: date }),
|
||||||
|
toggleLogLevel: (level) => {
|
||||||
|
const { selectedLogLevels } = get();
|
||||||
|
if (selectedLogLevels.includes(level)) {
|
||||||
|
set({ selectedLogLevels: selectedLogLevels.filter((l) => l !== level) });
|
||||||
|
} else {
|
||||||
|
set({ selectedLogLevels: [...selectedLogLevels, level] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilters: () => {
|
||||||
|
const { selectedLogLevels, selectedService, selectedAgent, startDate, endDate, limit, offset } = get();
|
||||||
|
return {
|
||||||
|
level: selectedLogLevels.length > 0 ? selectedLogLevels.join(",") : undefined,
|
||||||
|
service: selectedService || undefined,
|
||||||
|
agent: selectedAgent || undefined,
|
||||||
|
date_from: startDate ? startDate.toISOString() : undefined,
|
||||||
|
date_to: endDate ? endDate.toISOString() : undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export interface AgentService {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
name: string;
|
||||||
|
services: AgentService[];
|
||||||
|
token: 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;
|
||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
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: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
|
||||||
|
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
|
||||||
|
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
|
||||||
|
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogFiltersProps {
|
||||||
|
onApply: () => void;
|
||||||
|
availableServices: string[];
|
||||||
|
availableAgents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogFilters: React.FC<LogFiltersProps> = ({ onApply, availableServices, availableAgents }) => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedLogLevels,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
setSearchQuery,
|
||||||
|
setStartDate,
|
||||||
|
setEndDate,
|
||||||
|
toggleLogLevel,
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSearchQuery(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStartDate(startDate);
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalEndDate(endDate);
|
||||||
|
}, [endDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalService(selectedService);
|
||||||
|
}, [selectedService]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalAgent(selectedAgent);
|
||||||
|
}, [selectedAgent]);
|
||||||
|
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
setSearchQuery(localSearchQuery);
|
||||||
|
setStartDate(localStartDate);
|
||||||
|
setEndDate(localEndDate);
|
||||||
|
setSelectedService(localService);
|
||||||
|
setSelectedAgent(localAgent);
|
||||||
|
onApply();
|
||||||
|
}, [localSearchQuery, localStartDate, localEndDate, localService, localAgent, onApply]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setLocalEndDate(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 (selectedLogLevels.length < 4) 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 md: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}
|
||||||
|
placeholder="Дата от"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={localEndDate ? localEndDate.toISOString().split("T")[0] : ""}
|
||||||
|
onChange={(e) => setLocalEndDate(e.target.value ? new Date(e.target.value) : null)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Дата до"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
({selectedLogLevels.length}/4)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["INFO", "WARNING", "ERROR", "FATAL"] as LogLevel[]).map((level) => {
|
||||||
|
const isSelected = selectedLogLevels.includes(level);
|
||||||
|
const colors = logLevelColors[level];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => toggleLogLevel(level)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? colors.bg : "transparent",
|
||||||
|
color: isSelected ? colors.text : "var(--text-secondary)",
|
||||||
|
borderColor: isSelected ? colors.border : "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && <FiCheck size={10} className="inline mr-1" />}
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCheck size={14} />
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-4 py-2 rounded-lg transition-all text-sm font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{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,12 +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,
|
||||||
|
): Promise<Record<string, string>> => {
|
||||||
|
const response = await apiClient.post<Record<string, string>>("/auth/token", {
|
||||||
login: data.login,
|
login: data.login,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
name: data.firstName,
|
name: data.firstName,
|
||||||
last_name: data.lastName,
|
last_name: data.lastName,
|
||||||
|
is_active: data.is_active,
|
||||||
|
permission_admin: data.permission_admin,
|
||||||
|
permission_manage_agent: data.permission_manage_agent,
|
||||||
|
permission_view: data.permission_view,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@@ -62,9 +68,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:
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface RegisterData {
|
|||||||
password: string;
|
password: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
interface IDEProps {
|
||||||
|
initialFiles?: FileNode;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IDE: React.FC<IDEProps> = ({
|
||||||
|
initialFiles: externalFiles,
|
||||||
|
onBack,
|
||||||
|
}: IDEProps = {}) => {
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
// Инициализация файлов
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) {
|
||||||
|
const filesToInit = externalFiles || defaultInitialFiles;
|
||||||
|
initialize(filesToInit);
|
||||||
|
}
|
||||||
|
}, [isInitialized, externalFiles, initialize]);
|
||||||
|
|
||||||
|
// Если проект не открыт
|
||||||
|
if (!files) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
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 #3e3e42",
|
||||||
|
color: "#cccccc",
|
||||||
|
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 = "#3e3e42";
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
e.currentTarget.style.borderColor = "#555";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
e.currentTarget.style.borderColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
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: "#cccccc",
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No project open
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
color: "#858585",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create a new project to get started
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={createNewProject}
|
||||||
|
style={{
|
||||||
|
padding: "10px 24px",
|
||||||
|
backgroundColor: "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#1177bb";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#0e639c";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAdd size={14} /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "30px",
|
||||||
|
backgroundColor: "#323233",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 8px",
|
||||||
|
borderBottom: "1px solid #1e1e1e",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#cccccc",
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
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 = "#3e3e42";
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
title="Go back"
|
||||||
|
>
|
||||||
|
<MdArrowBack size={14} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!onBack && <div />}
|
||||||
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{activeFile ? `${activeFile.name} - ` : ""}
|
||||||
|
{files.name}
|
||||||
|
</span>
|
||||||
|
<div style={{ width: 60 }} />
|
||||||
|
</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,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,348 @@
|
|||||||
|
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();
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredFiles = store.searchQuery
|
||||||
|
? filterTree(files, store.searchQuery)
|
||||||
|
: files;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiFolder size={14} color="#858585" />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#cccccc",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.3px",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{files.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
||||||
|
<div
|
||||||
|
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 ? (
|
||||||
|
<FileTreeItem
|
||||||
|
node={filteredFiles}
|
||||||
|
level={0}
|
||||||
|
onFileSelect={store.selectFile}
|
||||||
|
selectedFile={store.activeFile?.path || null}
|
||||||
|
onContextMenu={handleNodeContextMenu}
|
||||||
|
expandedFolders={store.expandedFolders}
|
||||||
|
onToggleFolder={store.toggleFolder}
|
||||||
|
onDelete={store.handleDeleteNode}
|
||||||
|
isRoot
|
||||||
|
searchQuery={store.searchQuery}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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={store.handleDialogConfirm}
|
||||||
|
onCancel={() => store.setDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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,119 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface InputDialogProps {
|
||||||
|
title: string;
|
||||||
|
initialValue?: string;
|
||||||
|
onConfirm: (value: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputDialog: React.FC<InputDialogProps> = ({
|
||||||
|
title,
|
||||||
|
initialValue = "",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 new 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())
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "14px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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())}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</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,196 @@
|
|||||||
|
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>
|
||||||
|
<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,8 @@
|
|||||||
|
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";
|
||||||
@@ -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,3 @@
|
|||||||
|
export { IDE } from "./IDE";
|
||||||
|
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
||||||
|
export type { FileNode } from "./types";
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
import {
|
||||||
|
addPaths,
|
||||||
|
getAllFolderPaths,
|
||||||
|
findNode,
|
||||||
|
deleteNode,
|
||||||
|
addNode,
|
||||||
|
renameNode,
|
||||||
|
} from "../helpers/fileTree";
|
||||||
|
|
||||||
|
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 IDEState {
|
||||||
|
// Файловая система
|
||||||
|
files: FileNode | null;
|
||||||
|
openFiles: FileNode[];
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
searchQuery: string;
|
||||||
|
showSearch: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
|
||||||
|
// Диалоги и контекстные меню
|
||||||
|
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
||||||
|
dialog: {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
} | null;
|
||||||
|
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
||||||
|
|
||||||
|
// Действия с файлами
|
||||||
|
selectFile: (node: FileNode) => void;
|
||||||
|
updateFileContent: (content: string) => 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;
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
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) => void;
|
||||||
|
handleDeleteNode: (node: FileNode) => 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,
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
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 } = get();
|
||||||
|
if (!openFiles.find((f) => f.path === node.path)) {
|
||||||
|
set((state) => ({ openFiles: [...state.openFiles, node] }));
|
||||||
|
}
|
||||||
|
set({ activeFile: node });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновление содержимого файла
|
||||||
|
updateFileContent: (content: string) => {
|
||||||
|
const { activeFile } = get();
|
||||||
|
if (activeFile) {
|
||||||
|
const updatedFile = { ...activeFile, content };
|
||||||
|
set({ activeFile: updatedFile });
|
||||||
|
set((state) => ({
|
||||||
|
openFiles: state.openFiles.map((f) =>
|
||||||
|
f.path === activeFile.path ? updatedFile : f,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрытие файла
|
||||||
|
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: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
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: (value: string) => {
|
||||||
|
const { dialog, files, refreshFiles, 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 newFiles = renameNode(
|
||||||
|
files!,
|
||||||
|
dialog.node.path || dialog.node.name,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (newFiles) {
|
||||||
|
refreshFiles(newFiles);
|
||||||
|
}
|
||||||
|
set({ dialog: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentPath: string;
|
||||||
|
|
||||||
|
if (!dialog.node) {
|
||||||
|
parentPath = files!.path || files!.name;
|
||||||
|
} else if (dialog.node.type === "folder") {
|
||||||
|
parentPath = dialog.node.path || dialog.node.name;
|
||||||
|
} else {
|
||||||
|
const pathParts = (dialog.node.path || dialog.node.name).split("/");
|
||||||
|
pathParts.pop();
|
||||||
|
parentPath = pathParts.join("/") || files!.path || files!.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentNode = findNode(files!, parentPath);
|
||||||
|
if (
|
||||||
|
parentNode?.children?.some(
|
||||||
|
(c) => c.name.toLowerCase() === value.toLowerCase(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
alert(`"${value}" already exists in this folder.`);
|
||||||
|
set({ dialog: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newFiles: FileNode | null = null;
|
||||||
|
let createdNode: FileNode | null = null;
|
||||||
|
|
||||||
|
if (dialog.type === "newFile") {
|
||||||
|
createdNode = { name: value, type: "file", content: "" };
|
||||||
|
newFiles = addNode(files!, parentPath, createdNode);
|
||||||
|
} else if (dialog.type === "newFolder") {
|
||||||
|
createdNode = { name: value, type: "folder", children: [] };
|
||||||
|
newFiles = addNode(files!, parentPath, createdNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFiles) {
|
||||||
|
const allParentPaths: string[] = [];
|
||||||
|
let current = parentPath;
|
||||||
|
while (current) {
|
||||||
|
allParentPaths.push(current);
|
||||||
|
const parts = current.split("/");
|
||||||
|
parts.pop();
|
||||||
|
current = parts.join("/");
|
||||||
|
}
|
||||||
|
allParentPaths.forEach((p) => {
|
||||||
|
if (!get().expandedFolders.has(p)) {
|
||||||
|
toggleFolder(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
autoExpandPaths(new Set(allParentPaths));
|
||||||
|
|
||||||
|
if (createdNode && createdNode.type === "file") {
|
||||||
|
const findAndOpen = (node: FileNode, name: string): FileNode | null => {
|
||||||
|
if (node.name === name && node.type === "file") return node;
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findAndOpen(child, name);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const openedFile = findAndOpen(newFiles, value);
|
||||||
|
refreshFiles(newFiles, openedFile || undefined);
|
||||||
|
} else {
|
||||||
|
refreshFiles(newFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ dialog: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление узла
|
||||||
|
handleDeleteNode: (node: FileNode) => {
|
||||||
|
const { files, refreshFiles } = get();
|
||||||
|
const isRootNode = node.path === files?.path;
|
||||||
|
if (isRootNode) {
|
||||||
|
get().deleteRoot();
|
||||||
|
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
||||||
|
const newFiles = deleteNode(files!, node.path || node.name);
|
||||||
|
if (newFiles) refreshFiles(newFiles);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
content?: string;
|
||||||
|
children?: FileNode[];
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
node: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogState {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
}
|
||||||
@@ -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,730 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { agentApiService } from "@/modules/agent";
|
||||||
|
import type { TokenUser, TokenCreate, TokenUpdatePermissions, TokenPasswordReset } from "@/modules/agent";
|
||||||
|
import { FiUsers, FiUserPlus, FiEdit2, FiTrash2, FiUnlock, FiLock, FiKey, FiX, FiCheck, FiSearch } from "react-icons/fi";
|
||||||
|
|
||||||
|
export const AdminPage: React.FC = () => {
|
||||||
|
const [users, setUsers] = useState<TokenUser[]>([]);
|
||||||
|
const [inactiveUsers, setInactiveUsers] = useState<TokenUser[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<"active" | "inactive">("active");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<TokenUser | null>(null);
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [createData, setCreateData] = useState<TokenCreate>({
|
||||||
|
login: "",
|
||||||
|
name: "",
|
||||||
|
last_name: "",
|
||||||
|
password: "",
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: false,
|
||||||
|
is_active: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [editData, setEditData] = useState<TokenUpdatePermissions>({
|
||||||
|
is_active: false,
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [passwordData, setPasswordData] = useState<TokenPasswordReset>({
|
||||||
|
new_password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [active, inactive] = await Promise.all([
|
||||||
|
agentApiService.getUsers(),
|
||||||
|
agentApiService.getInactiveUsers(),
|
||||||
|
]);
|
||||||
|
setUsers(active);
|
||||||
|
setInactiveUsers(inactive);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при загрузке пользователей");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await agentApiService.createUser(createData);
|
||||||
|
setSuccessMessage("Пользователь успешно создан");
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setCreateData({
|
||||||
|
login: "",
|
||||||
|
name: "",
|
||||||
|
last_name: "",
|
||||||
|
password: "",
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: false,
|
||||||
|
is_active: false,
|
||||||
|
});
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при создании пользователя");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePermissions = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedUser) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await agentApiService.updateUserPermissions(selectedUser.login, editData);
|
||||||
|
setSuccessMessage("Права пользователя обновлены");
|
||||||
|
setShowEditModal(false);
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при обновлении прав");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedUser) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await agentApiService.resetUserPassword(selectedUser.login, passwordData);
|
||||||
|
setSuccessMessage("Пароль изменен");
|
||||||
|
setShowPasswordModal(false);
|
||||||
|
setPasswordData({ new_password: "" });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при сбросе пароля");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivateUser = async (login: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await agentApiService.activateUser(login);
|
||||||
|
setSuccessMessage("Пользователь активирован");
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при активации пользователя");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivateUser = async (login: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await agentApiService.deactivateUser(login);
|
||||||
|
setSuccessMessage("Пользователь деактивирован");
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при деактивации пользователя");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (login: string) => {
|
||||||
|
if (!confirm(`Вы уверены, что хотите удалить пользователя ${login}?`)) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await agentApiService.deleteUser(login);
|
||||||
|
setSuccessMessage("Пользователь удален");
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при удалении пользователя");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: TokenUser) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setEditData({
|
||||||
|
is_active: user.is_active,
|
||||||
|
permission_admin: user.permission_admin,
|
||||||
|
permission_manage_agent: user.permission_manage_agent,
|
||||||
|
permission_view: user.permission_view,
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPasswordModal = (user: TokenUser) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setPasswordData({ new_password: "" });
|
||||||
|
setShowPasswordModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = (activeTab === "active" ? users : inactiveUsers).filter(
|
||||||
|
(user) =>
|
||||||
|
user.login.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
user.last_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "8px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonBaseStyle: React.CSSProperties = {
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen py-8 px-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "1200px", 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)" }}
|
||||||
|
>
|
||||||
|
<FiUsers 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs and Actions */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("active")}
|
||||||
|
className="px-4 py-2 rounded-lg border transition-all font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: activeTab === "active" ? "var(--accent)" : "var(--input-bg)",
|
||||||
|
color: activeTab === "active" ? "var(--accent-text)" : "var(--text-primary)",
|
||||||
|
borderColor: activeTab === "active" ? "var(--accent)" : "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Активные ({users.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("inactive")}
|
||||||
|
className="px-4 py-2 rounded-lg border transition-all font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: activeTab === "inactive" ? "var(--accent)" : "var(--input-bg)",
|
||||||
|
color: activeTab === "inactive" ? "var(--accent-text)" : "var(--text-primary)",
|
||||||
|
borderColor: activeTab === "inactive" ? "var(--accent)" : "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Неактивные ({inactiveUsers.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
boxShadow: "0 4px 14px var(--shadow-color)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiUserPlus size={16} />
|
||||||
|
Создать пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<FiSearch
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "12px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск по логину, имени или фамилии..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="text-center py-12 rounded-xl border border-dashed"
|
||||||
|
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
Пользователи не найдены
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Админ</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Управление</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Просмотр</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsers.map((user, index) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="border-t"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor: index % 2 === 0 ? "var(--card-bg)" : "var(--bg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm" style={{ color: "var(--text-primary)" }}>{user.login}</td>
|
||||||
|
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>{user.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>{user.last_name}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{user.permission_admin ? (
|
||||||
|
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
|
||||||
|
) : (
|
||||||
|
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{user.permission_manage_agent ? (
|
||||||
|
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
|
||||||
|
) : (
|
||||||
|
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{user.permission_view ? (
|
||||||
|
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
|
||||||
|
) : (
|
||||||
|
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(user)}
|
||||||
|
className="p-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
title="Редактировать права"
|
||||||
|
>
|
||||||
|
<FiEdit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openPasswordModal(user)}
|
||||||
|
className="p-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--warning-bg)",
|
||||||
|
color: "var(--warning-text)",
|
||||||
|
}}
|
||||||
|
title="Сбросить пароль"
|
||||||
|
>
|
||||||
|
<FiKey size={14} />
|
||||||
|
</button>
|
||||||
|
{activeTab === "active" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeactivateUser(user.login)}
|
||||||
|
className="p-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--warning-bg)",
|
||||||
|
color: "var(--warning-text)",
|
||||||
|
}}
|
||||||
|
title="Деактивировать"
|
||||||
|
>
|
||||||
|
<FiLock size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivateUser(user.login)}
|
||||||
|
className="p-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--success-bg)",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
border: "1px solid var(--success-border)",
|
||||||
|
}}
|
||||||
|
title="Активировать"
|
||||||
|
>
|
||||||
|
<FiUnlock size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user.login)}
|
||||||
|
className="p-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--error-bg)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
border: "1px solid var(--error-border)",
|
||||||
|
}}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-2xl border w-full max-w-md"
|
||||||
|
style={{ backgroundColor: "var(--card-bg)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
|
||||||
|
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>Создать пользователя</h2>
|
||||||
|
<button onClick={() => setShowCreateModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
|
||||||
|
<FiX size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreateUser} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Логин *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createData.login}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, login: e.target.value })}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Имя *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createData.name}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Фамилия *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createData.last_name}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, last_name: e.target.value })}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Пароль *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={createData.password}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createData.permission_admin}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, permission_admin: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Администратор</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createData.permission_manage_agent}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, permission_manage_agent: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Управление агентами</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createData.permission_view}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, permission_view: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Просмотр</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createData.is_active}
|
||||||
|
onChange={(e) => setCreateData({ ...createData, is_active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Активен сразу</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg border transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Permissions Modal */}
|
||||||
|
{showEditModal && selectedUser && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-2xl border w-full max-w-md"
|
||||||
|
style={{ backgroundColor: "var(--card-bg)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
|
||||||
|
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
|
||||||
|
Редактировать: {selectedUser.login}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setShowEditModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
|
||||||
|
<FiX size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdatePermissions} className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editData.permission_admin || false}
|
||||||
|
onChange={(e) => setEditData({ ...editData, permission_admin: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Администратор</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editData.permission_manage_agent || false}
|
||||||
|
onChange={(e) => setEditData({ ...editData, permission_manage_agent: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Управление агентами</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editData.permission_view || false}
|
||||||
|
onChange={(e) => setEditData({ ...editData, permission_view: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Просмотр</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editData.is_active || false}
|
||||||
|
onChange={(e) => setEditData({ ...editData, is_active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>Активен</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg border transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{showPasswordModal && selectedUser && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-2xl border w-full max-w-md"
|
||||||
|
style={{ backgroundColor: "var(--card-bg)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
|
||||||
|
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
|
||||||
|
Сброс пароля: {selectedUser.login}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setShowPasswordModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
|
||||||
|
<FiX size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleResetPassword} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Новый пароль *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.new_password}
|
||||||
|
onChange={(e) => setPasswordData({ new_password: e.target.value })}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPasswordModal(false)}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg border transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { IDE } from "../modules/ide";
|
||||||
|
|
||||||
|
export const IDEPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full z-90">
|
||||||
|
<IDE
|
||||||
|
onBack={() => navigate("/home")}
|
||||||
|
initialFiles={{ name: "тест", type: "folder" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
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: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
|
||||||
|
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
|
||||||
|
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
|
||||||
|
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
setLogs(data);
|
||||||
|
setTotalLogs(data.length);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка при загрузке логов");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [getFilters]);
|
||||||
|
|
||||||
|
const fetchDistinctData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [services, agents] = await Promise.all([
|
||||||
|
agentApiService.getDistinctServices(),
|
||||||
|
agentApiService.getDistinctAgents(),
|
||||||
|
]);
|
||||||
|
setAvailableServices(services);
|
||||||
|
setAvailableAgents(agents);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch distinct data:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
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 colors = logLevelColors[log.level] || logLevelColors.INFO;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className="border-t"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
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[log.level]}
|
||||||
|
{log.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,534 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
|
import ForceGraph2D from "react-force-graph-2d";
|
||||||
|
import {
|
||||||
|
FiDownload,
|
||||||
|
FiZoomIn,
|
||||||
|
FiZoomOut,
|
||||||
|
FiMove,
|
||||||
|
FiCpu,
|
||||||
|
FiServer,
|
||||||
|
FiPlus,
|
||||||
|
FiTrash2,
|
||||||
|
FiLink,
|
||||||
|
FiMinusCircle,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "agent" | "service";
|
||||||
|
val?: number;
|
||||||
|
description?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomGraphProps {
|
||||||
|
data: GraphData;
|
||||||
|
onExport?: () => void;
|
||||||
|
onDataChange?: (newData: GraphData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Test2Page: React.FC<CustomGraphProps> = ({
|
||||||
|
data: initialData,
|
||||||
|
onExport,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const fgRef = useRef<any>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [data, setData] = useState<GraphData>(initialData);
|
||||||
|
const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set());
|
||||||
|
const [highlightLinks, setHighlightLinks] = useState<Set<any>>(new Set());
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
const [isLinkMode, setIsLinkMode] = useState(false);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
node: GraphNode | null;
|
||||||
|
link: GraphLink | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) setData(initialData);
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// Отслеживаем размеры контейнера через ResizeObserver
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
setDimensions({
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight || window.innerHeight - 160,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Функция для подсветки связанных элементов
|
||||||
|
const handleNodeHover = (node: GraphNode | null) => {
|
||||||
|
const newHighlightNodes = new Set<string>();
|
||||||
|
const newHighlightLinks = new Set<any>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightNodes(newHighlightNodes);
|
||||||
|
setHighlightLinks(newHighlightLinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик клика по узлу для создания связей
|
||||||
|
const handleNodeClick = (node: GraphNode) => {
|
||||||
|
if (isLinkMode) {
|
||||||
|
if (selectedNode === null) {
|
||||||
|
setSelectedNode(node);
|
||||||
|
} else if (selectedNode.id !== node.id) {
|
||||||
|
const newLink: GraphLink = {
|
||||||
|
source: selectedNode.id,
|
||||||
|
target: node.id,
|
||||||
|
type: "custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkExists = data.links.some(
|
||||||
|
(link) =>
|
||||||
|
(link.source === selectedNode.id && link.target === node.id) ||
|
||||||
|
(link.source === node.id && link.target === selectedNode.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!linkExists) {
|
||||||
|
const newData = {
|
||||||
|
nodes: [...data.nodes],
|
||||||
|
links: [...data.links, newLink],
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
}
|
||||||
|
setSelectedNode(null);
|
||||||
|
setIsLinkMode(false);
|
||||||
|
} else {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// УДАЛЕНИЕ СВЯЗИ
|
||||||
|
const handleDeleteLink = (linkToDelete: GraphLink) => {
|
||||||
|
const filteredLinks = data.links.filter((link) => link !== linkToDelete);
|
||||||
|
const newData = {
|
||||||
|
nodes: [...data.nodes],
|
||||||
|
links: filteredLinks,
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// УДАЛЕНИЕ УЗЛА
|
||||||
|
const handleDeleteNode = (nodeToDelete: GraphNode) => {
|
||||||
|
const filteredNodes = data.nodes.filter(
|
||||||
|
(node) => node.id !== nodeToDelete.id,
|
||||||
|
);
|
||||||
|
const filteredLinks = data.links.filter(
|
||||||
|
(link) =>
|
||||||
|
link.source !== nodeToDelete.id && link.target !== nodeToDelete.id,
|
||||||
|
);
|
||||||
|
const newData = {
|
||||||
|
nodes: filteredNodes,
|
||||||
|
links: filteredLinks,
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавление нового узла
|
||||||
|
const handleAddNode = () => {
|
||||||
|
const newNodeName = prompt(
|
||||||
|
"Введите имя узла:",
|
||||||
|
`Node ${data.nodes.length + 1}`,
|
||||||
|
);
|
||||||
|
if (newNodeName) {
|
||||||
|
const isService = window.confirm(
|
||||||
|
"Выберите тип: OK - Сервис, Отмена - Агент",
|
||||||
|
);
|
||||||
|
const newNode: GraphNode = {
|
||||||
|
id: `node-${Date.now()}`,
|
||||||
|
name: newNodeName,
|
||||||
|
type: isService ? "service" : "agent",
|
||||||
|
val: isService ? 12 : 8,
|
||||||
|
description: "Новый узел",
|
||||||
|
};
|
||||||
|
|
||||||
|
const newData = {
|
||||||
|
nodes: [...data.nodes, newNode],
|
||||||
|
links: [...data.links],
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открытие контекстного меню
|
||||||
|
const openContextMenu = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
node?: GraphNode,
|
||||||
|
link?: GraphLink,
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, node, link: null });
|
||||||
|
} else if (link) {
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, node: null, link });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Закрыть контекстное меню
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Функция для определения цвета узла
|
||||||
|
const getNodeColor = (node: GraphNode) => {
|
||||||
|
if (highlightNodes.has(node.id)) return "#fbbf24";
|
||||||
|
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
|
||||||
|
|
||||||
|
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 = "#ffffff";
|
||||||
|
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 = "#e5e7eb";
|
||||||
|
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 handleExport = () => {
|
||||||
|
if (onExport) {
|
||||||
|
onExport();
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={handleAddNode}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors text-white mx-auto"
|
||||||
|
>
|
||||||
|
<FiPlus /> Добавить первый узел
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="graph-container border border-gray-800 rounded-lg overflow-hidden relative"
|
||||||
|
style={{
|
||||||
|
height: "calc(100vh - 200px)",
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={fgRef}
|
||||||
|
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={(node, event) =>
|
||||||
|
openContextMenu(event as any, node, undefined)
|
||||||
|
}
|
||||||
|
onLinkRightClick={(link, event) =>
|
||||||
|
openContextMenu(event as any, undefined, link)
|
||||||
|
}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
cooldownTicks={50}
|
||||||
|
cooldownTime={2000}
|
||||||
|
d3AlphaDecay={0.03}
|
||||||
|
d3VelocityDecay={0.4}
|
||||||
|
warmupTicks={50}
|
||||||
|
onEngineStop={() => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
fgRef.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed bg-gray-800 rounded-lg shadow-lg border border-gray-700 py-1 z-50"
|
||||||
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{contextMenu.node && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
|
||||||
|
{contextMenu.node.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLinkMode(true);
|
||||||
|
setSelectedNode(contextMenu.node);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiLink size={14} /> Создать связь
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNode(contextMenu.node!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} /> Удалить узел
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contextMenu.link && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
|
||||||
|
Связь:{" "}
|
||||||
|
{typeof contextMenu.link.source === "string"
|
||||||
|
? contextMenu.link.source
|
||||||
|
: (contextMenu.link.source as any).name ||
|
||||||
|
(contextMenu.link.source as any).id}{" "}
|
||||||
|
→{" "}
|
||||||
|
{typeof contextMenu.link.target === "string"
|
||||||
|
? contextMenu.link.target
|
||||||
|
: (contextMenu.link.target as any).name ||
|
||||||
|
(contextMenu.link.target as any).id}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLink(contextMenu.link!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiMinusCircle size={14} /> Удалить связь
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLinkMode && (
|
||||||
|
<div className="absolute bottom-4 left-4 bg-green-600 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<FiLink /> Режим создания связей: кликните на два узла для
|
||||||
|
соединения
|
||||||
|
{selectedNode && (
|
||||||
|
<span className="ml-2">Выбран: {selectedNode.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-500 flex justify-between items-center">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiServer className="text-gray-400" />
|
||||||
|
<span>
|
||||||
|
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiCpu className="text-gray-400" />
|
||||||
|
<span>
|
||||||
|
Агенты: {data.nodes.filter((n) => n.type === "agent").length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-gray-500 rounded-sm"></div>
|
||||||
|
<span>Связи: {data.links.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLinkMode(!isLinkMode);
|
||||||
|
setSelectedNode(null);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
isLinkMode
|
||||||
|
? "bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
: "bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FiLink />
|
||||||
|
<span className="text-sm">
|
||||||
|
{isLinkMode ? "Создание связи..." : "Добавить связь"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddNode}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiPlus />
|
||||||
|
<span className="text-sm">Узел</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiZoomIn />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiZoomOut />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFit}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiMove />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
<span className="text-sm">Экспорт</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1332
-510
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user