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 { 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 { useAuthStore } from "@/modules/auth/store/useAuthStore";
@@ -8,12 +19,28 @@ interface SidebarProps {
onToggle?: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) => {
const { agents, isLoading, error, fetchAgents } = useAgentStore();
export const Sidebar: React.FC<SidebarProps> = ({
isOpen = true,
onToggle,
}) => {
const { agents, isLoading, error, fetchAgents, removeAgent } =
useAgentStore();
const { token } = useAuthStore();
const [searchQuery, setSearchQuery] = useState("");
const [copied, setCopied] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false);
const [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(() => {
if (!searchQuery) return agents;
@@ -21,7 +48,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
return agents.filter(
(agent) =>
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]);
@@ -38,7 +65,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<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)" }}
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
aria-label="Открыть sidebar"
>
<FaBars size={18} />
@@ -49,7 +79,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
return (
<>
{/* 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
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">
<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>
<span
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}
</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">
{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" }} />
<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)" }}>
<div
className="text-xs mb-2"
style={{ color: "var(--error-text)" }}
>
{error}
</div>
<button
@@ -133,7 +178,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
</button>
</div>
) : 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} />
<p className="text-xs">
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
@@ -141,58 +189,153 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
</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"
{filteredAgents.map((agent) => {
const isExpanded = expandedAgents.has(agent.name);
return (
<div
key={agent.name}
className="rounded-lg border overflow-hidden transition-all group"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
{/* Agent header — кликабельный для сворачивания */}
<div
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => toggleAgent(agent.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)" }}
>
<span style={{ color: "var(--text-secondary)" }}>{service.name}</span>
{agent.name}
</span>
{/* Статус-индикатор агента (сколько сервисов запущено) */}
<div className="flex items-center gap-1">
{agent.services.filter((s) => s.status === "running")
.length > 0 && (
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: "#4ade80" }}
/>
)}
<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)"
}`,
}}
className="text-[10px]"
style={{ color: "var(--text-muted)" }}
>
{service.status}
{
agent.services.filter((s) => s.status === "running")
.length
}
/{agent.services.length}
</span>
</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>
@@ -200,8 +343,29 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
{/* Footer с кнопками */}
<div
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
onClick={() => setShowTokenModal(true)}
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
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()}
>
<div
@@ -234,7 +401,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
>
<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
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Ваш токен доступа
</h2>
</div>
@@ -249,12 +419,18 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<div className="p-4 space-y-3">
<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>
<div
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
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)" }}
>
{copied ? (
<FaCheck size={12} style={{ color: "var(--success-text)" }} />
<FaCheck
size={12}
style={{ color: "var(--success-text)" }}
/>
) : (
<FaCopy size={12} />
)}
@@ -281,7 +460,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) =>
<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)" }}
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
>
Закрыть
</button>
@@ -10,8 +10,37 @@ interface AgentState {
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) => ({
agents: [],
agents: mockAgents,
isLoading: false,
error: null,
@@ -22,7 +51,8 @@ export const useAgentStore = create<AgentState>()((set, get) => ({
set({ agents, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch agents",
error:
error instanceof Error ? error.message : "Failed to fetch agents",
isLoading: false,
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
// 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 { useEffect, useRef, useCallback, useMemo } from "react";