redezign: list agents & services; feat: button remove agents
ci-front / build (push) Successful in 2m5s

This commit is contained in:
nikita
2026-04-04 11:08:45 +03:00
parent dd921e5892
commit 26ca7c0d51
3 changed files with 279 additions and 67 deletions
@@ -1,5 +1,16 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { FaBars, FaMicrochip, FaTimes, FaSpinner, FaCopy, FaCheck } from "react-icons/fa"; import {
FaBars,
FaMicrochip,
FaTimes,
FaSpinner,
FaCopy,
FaCheck,
FaChevronRight,
FaChevronDown,
FaProjectDiagram,
FaTrash,
} from "react-icons/fa";
import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useAuthStore } from "@/modules/auth/store/useAuthStore"; import { useAuthStore } from "@/modules/auth/store/useAuthStore";
@@ -8,12 +19,28 @@ interface SidebarProps {
onToggle?: () => void; onToggle?: () => void;
} }
export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) => { export const Sidebar: React.FC<SidebarProps> = ({
const { agents, isLoading, error, fetchAgents } = useAgentStore(); isOpen = true,
onToggle,
}) => {
const { agents, isLoading, error, fetchAgents, removeAgent } =
useAgentStore();
const { token } = useAuthStore(); const { token } = useAuthStore();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false); const [showTokenModal, setShowTokenModal] = useState(false);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
new Set(agents.map((a) => a.name)),
);
const toggleAgent = (name: string) => {
setExpandedAgents((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const filteredAgents = useMemo(() => { const filteredAgents = useMemo(() => {
if (!searchQuery) return agents; if (!searchQuery) return agents;
@@ -21,7 +48,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
return agents.filter( return agents.filter(
(agent) => (agent) =>
agent.name.toLowerCase().includes(query) || agent.name.toLowerCase().includes(query) ||
agent.services.some((s) => s.name.toLowerCase().includes(query)) agent.services.some((s) => s.name.toLowerCase().includes(query)),
); );
}, [agents, searchQuery]); }, [agents, searchQuery]);
@@ -38,7 +65,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<button <button
onClick={onToggle} onClick={onToggle}
className="fixed top-4 left-4 z-50 p-2.5 rounded-lg shadow-lg transition-colors md:hidden" 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)" }} style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
aria-label="Открыть sidebar" aria-label="Открыть sidebar"
> >
<FaBars size={18} /> <FaBars size={18} />
@@ -49,7 +79,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
return ( return (
<> <>
{/* Overlay для мобильных */} {/* Overlay для мобильных */}
<div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onToggle} /> <div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={onToggle}
/>
<aside <aside
className={`fixed md:relative w-72 h-screen z-50 transition-transform duration-300 ease-in-out flex flex-col ${ className={`fixed md:relative w-72 h-screen z-50 transition-transform duration-300 ease-in-out flex flex-col ${
@@ -67,12 +100,18 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} /> <FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}> <h2
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Агенты Агенты
</h2> </h2>
<span <span
className="text-xs px-1.5 py-0.5 rounded" className="text-xs px-1.5 py-0.5 rounded"
style={{ backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)" }} style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
> >
{agents.length} {agents.length}
</span> </span>
@@ -114,14 +153,20 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<div className="flex-1 overflow-y-auto px-2 py-2"> <div className="flex-1 overflow-y-auto px-2 py-2">
{isLoading && agents.length === 0 ? ( {isLoading && agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<FaSpinner className="animate-spin mb-3" style={{ color: "var(--accent)", fontSize: "20px" }} /> <FaSpinner
className="animate-spin mb-3"
style={{ color: "var(--accent)", fontSize: "20px" }}
/>
<p className="text-xs" style={{ color: "var(--text-secondary)" }}> <p className="text-xs" style={{ color: "var(--text-secondary)" }}>
Загрузка агентов... Загрузка агентов...
</p> </p>
</div> </div>
) : error ? ( ) : error ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-xs mb-2" style={{ color: "var(--error-text)" }}> <div
className="text-xs mb-2"
style={{ color: "var(--error-text)" }}
>
{error} {error}
</div> </div>
<button <button
@@ -133,7 +178,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
</button> </button>
</div> </div>
) : filteredAgents.length === 0 ? ( ) : filteredAgents.length === 0 ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}> <div
className="text-center py-8"
style={{ color: "var(--text-muted)" }}
>
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} /> <FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
<p className="text-xs"> <p className="text-xs">
{searchQuery ? "Ничего не найдено" : "Нет агентов"} {searchQuery ? "Ничего не найдено" : "Нет агентов"}
@@ -141,58 +189,153 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{filteredAgents.map((agent) => ( {filteredAgents.map((agent) => {
const isExpanded = expandedAgents.has(agent.name);
return (
<div <div
key={agent.name} key={agent.name}
className="rounded-lg border p-3 transition-all hover:shadow-md" className="rounded-lg border overflow-hidden transition-all group"
style={{ style={{
backgroundColor: "var(--bg-secondary)", backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)", borderColor: "var(--border)",
}} }}
> >
<div className="flex items-center justify-between mb-2"> {/* Agent header — кликабельный для сворачивания */}
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}> <div
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => toggleAgent(agent.name)}
>
<span style={{ color: "var(--text-muted)" }}>
{isExpanded ? (
<FaChevronDown size={10} />
) : (
<FaChevronRight size={10} />
)}
</span>
<FaMicrochip
size={12}
style={{ color: "var(--accent)" }}
/>
<span
className="text-sm font-medium flex-1 truncate"
style={{ color: "var(--text-primary)" }}
>
{agent.name} {agent.name}
</span> </span>
</div> {/* Статус-индикатор агента (сколько сервисов запущено) */}
<div className="space-y-1"> <div className="flex items-center gap-1">
{agent.services.map((service) => ( {agent.services.filter((s) => s.status === "running")
<div .length > 0 && (
key={service.name}
className="flex items-center justify-between text-xs"
>
<span style={{ color: "var(--text-secondary)" }}>{service.name}</span>
<span <span
className="px-1.5 py-0.5 rounded text-[10px] font-medium" className="w-2 h-2 rounded-full"
style={{ style={{ backgroundColor: "#4ade80" }}
backgroundColor: />
service.status === "running" )}
? "var(--success-bg)" <span
: service.status === "error" className="text-[10px]"
? "var(--error-bg)" style={{ color: "var(--text-muted)" }}
: "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} {
agent.services.filter((s) => s.status === "running")
.length
}
/{agent.services.length}
</span> </span>
</div> </div>
))} {/* Кнопка удаления — появляется при наведении */}
<button
onClick={(e) => {
e.stopPropagation();
if (
window.confirm(`Удалить агента "${agent.name}"?`)
) {
removeAgent(agent.name);
}
}}
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all flex-shrink-0"
style={{
color: "var(--text-muted)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#f87171";
e.currentTarget.style.backgroundColor =
"rgba(248, 113, 113, 0.15)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.backgroundColor = "transparent";
}}
title="Удалить агента"
>
<FaTrash size={10} />
</button>
</div>
{/* Services list — сворачивается */}
{isExpanded && (
<div
className="px-3 pb-2"
style={{ paddingLeft: "24px" }}
>
<div
className="border-l-2 pl-3 space-y-1"
style={{ borderColor: "var(--border)" }}
>
{agent.services.map((service) => {
const isRunning = service.status === "running";
const isError = service.status === "error";
const isStopped =
service.status === "stopped" || !isRunning;
return (
<div
key={service.name}
className="flex items-center justify-between py-1"
>
<span
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
{service.name}
</span>
{/* Status indicator */}
<div className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{
backgroundColor: isRunning
? "#4ade80"
: isError
? "#f87171"
: "#555",
}}
/>
<span
className="text-[10px] font-medium"
style={{
color: isRunning
? "#4ade80"
: isError
? "#f87171"
: "#777",
}}
>
{isRunning
? "run"
: isError
? "err"
: "stop"}
</span>
</div> </div>
</div> </div>
))} );
})}
</div>
</div>
)}
</div>
);
})}
</div> </div>
)} )}
</div> </div>
@@ -200,8 +343,29 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
{/* Footer с кнопками */} {/* Footer с кнопками */}
<div <div
className="p-2 border-t flex gap-2" className="p-2 border-t flex gap-2"
style={{ borderColor: "var(--border)", backgroundColor: "var(--card-bg)" }} style={{
borderColor: "var(--border)",
backgroundColor: "var(--card-bg)",
}}
> >
{/* Кнопка Графы */}
<button
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
}}
>
<FaProjectDiagram size={10} />
Графы
</button>
<button <button
onClick={() => setShowTokenModal(true)} onClick={() => setShowTokenModal(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors" className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
@@ -225,7 +389,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
> >
<div <div
className="w-full max-w-md rounded-xl shadow-2xl border" className="w-full max-w-md rounded-xl shadow-2xl border"
style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }} style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div <div
@@ -234,7 +401,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaCopy style={{ color: "var(--accent)" }} size={14} /> <FaCopy style={{ color: "var(--accent)" }} size={14} />
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}> <h2
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Ваш токен доступа Ваш токен доступа
</h2> </h2>
</div> </div>
@@ -249,12 +419,18 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div> <div>
<label className="block text-xs font-medium mb-2" style={{ color: "var(--text-secondary)" }}> <label
className="block text-xs font-medium mb-2"
style={{ color: "var(--text-secondary)" }}
>
Токен Токен
</label> </label>
<div <div
className="flex items-center gap-2 rounded-lg p-3 border" className="flex items-center gap-2 rounded-lg p-3 border"
style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border)" }} style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
> >
<code <code
className="flex-1 text-xs font-mono break-all" className="flex-1 text-xs font-mono break-all"
@@ -269,7 +445,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
style={{ color: "var(--text-secondary)" }} style={{ color: "var(--text-secondary)" }}
> >
{copied ? ( {copied ? (
<FaCheck size={12} style={{ color: "var(--success-text)" }} /> <FaCheck
size={12}
style={{ color: "var(--success-text)" }}
/>
) : ( ) : (
<FaCopy size={12} /> <FaCopy size={12} />
)} )}
@@ -281,7 +460,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<button <button
onClick={() => setShowTokenModal(false)} onClick={() => setShowTokenModal(false)}
className="w-full py-2 rounded-lg text-xs font-medium transition-colors" className="w-full py-2 rounded-lg text-xs font-medium transition-colors"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }} style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
> >
Закрыть Закрыть
</button> </button>
@@ -10,8 +10,37 @@ interface AgentState {
removeAgent: (name: string) => void; removeAgent: (name: string) => void;
} }
const mockAgents: AgentInfo[] = [
{
name: "agent-core-01",
token: "tok_a1b2c3d4e5f6g7h8",
services: [
{ name: "postgres", status: "running" },
{ name: "redis", status: "running" },
{ name: "log-collector", status: "running" },
],
},
{
name: "agent-worker-02",
token: "tok_x9y8z7w6v5u4t3s2",
services: [
{ name: "celery-worker", status: "running" },
{ name: "flower", status: "stopped" },
],
},
{
name: "agent-monitor-03",
token: "tok_m1n2o3p4q5r6s7t8",
services: [
{ name: "prometheus", status: "running" },
{ name: "grafana", status: "running" },
{ name: "alertmanager", status: "stopped" },
],
},
];
export const useAgentStore = create<AgentState>()((set, get) => ({ export const useAgentStore = create<AgentState>()((set, get) => ({
agents: [], agents: mockAgents,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -22,7 +51,8 @@ export const useAgentStore = create<AgentState>()((set, get) => ({
set({ agents, isLoading: false }); set({ agents, isLoading: false });
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : "Failed to fetch agents", error:
error instanceof Error ? error.message : "Failed to fetch agents",
isLoading: false, isLoading: false,
}); });
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// shared/api/websocket.service.ts // shared/api/websocket.service.ts
import { useAgentStore } from "@/components/layout/sidebar/store/agent.store"; import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket"; import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket";
import { useEffect, useRef, useCallback, useMemo } from "react"; import { useEffect, useRef, useCallback, useMemo } from "react";