From 17d4770de669c5adfed8bebff233993e9f0f602b Mon Sep 17 00:00:00 2001 From: nikita Date: Sun, 5 Apr 2026 07:17:33 +0300 Subject: [PATCH] feat: dashboard --- .../app/providers/layout/sidebar/sidebar.tsx | 9 +- .../providers/layout/store/metrics.store.ts | 50 ++ .../src/app/providers/routing/routing.tsx | 5 + .../modules/agent/api/agent.api.service.ts | 8 + .../src/modules/agent/types/agent.types.ts | 11 + frontend/src/pages/agent-dashboard.page.tsx | 447 ++++++++++++++++++ 6 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/providers/layout/store/metrics.store.ts create mode 100644 frontend/src/pages/agent-dashboard.page.tsx diff --git a/frontend/src/app/providers/layout/sidebar/sidebar.tsx b/frontend/src/app/providers/layout/sidebar/sidebar.tsx index d633c71..763cf74 100644 --- a/frontend/src/app/providers/layout/sidebar/sidebar.tsx +++ b/frontend/src/app/providers/layout/sidebar/sidebar.tsx @@ -12,6 +12,7 @@ import { FaTrash, FaArrowLeft, } from "react-icons/fa"; +import { useNavigate } from "react-router-dom"; import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAuthStore } from "@/modules/auth/store/useAuthStore"; import { Graph, type GraphData } from "@/modules/graph"; @@ -26,6 +27,7 @@ export const Sidebar: React.FC = ({ isOpen = true, onToggle, }) => { + const navigate = useNavigate(); const { agents, isLoading, error, fetchAgents, removeAgent } = useAgentStore(); const { token } = useAuthStore(); @@ -298,8 +300,13 @@ export const Sidebar: React.FC = ({ style={{ color: "var(--accent)" }} /> { + e.stopPropagation(); + navigate(`/dashboard/${agent.label}`); + }} + title="Открыть дашборд агента" > {agent.label} diff --git a/frontend/src/app/providers/layout/store/metrics.store.ts b/frontend/src/app/providers/layout/store/metrics.store.ts new file mode 100644 index 0000000..a5f7b80 --- /dev/null +++ b/frontend/src/app/providers/layout/store/metrics.store.ts @@ -0,0 +1,50 @@ +import { create } from "zustand"; +import { agentApiService } from "@/modules/agent/api/agent.api.service"; +import type { SystemMetrics } from "@/modules/agent/types/agent.types"; + +interface MetricsState { + metrics: SystemMetrics[]; + isLoading: boolean; + error: string | null; + lastUpdated: number | null; +} + +const POLLING_INTERVAL = 30_000; + +let _pollingTimer: ReturnType | null = null; + +export const useMetricsStore = create(() => ({ + metrics: [], + isLoading: false, + error: null, + lastUpdated: null, +})); + +export const startMetricsPolling = async () => { + if (_pollingTimer) return; + const fetchMetrics = async () => { + try { + const data = await agentApiService.getSystemMetrics(); + useMetricsStore.setState({ + metrics: data, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }); + } catch (e) { + useMetricsStore.setState({ + error: e instanceof Error ? e.message : "Failed to fetch metrics", + isLoading: false, + }); + } + }; + await fetchMetrics(); + _pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL); +}; + +export const stopMetricsPolling = () => { + if (_pollingTimer) { + clearInterval(_pollingTimer); + _pollingTimer = null; + } +}; diff --git a/frontend/src/app/providers/routing/routing.tsx b/frontend/src/app/providers/routing/routing.tsx index 3fd0a16..c827af4 100644 --- a/frontend/src/app/providers/routing/routing.tsx +++ b/frontend/src/app/providers/routing/routing.tsx @@ -14,6 +14,7 @@ import { RegistrationTokenPage } from "@/pages/registration.page"; import { LogsPage } from "@/pages/logs.page"; import { GraphsPage } from "@/pages/graphs.page"; import { DashboardPage } from "@/pages/dashboard.page"; +import { AgentDashboardPage } from "@/pages/agent-dashboard.page"; export const mockGraphData: GraphData = { nodes: [ @@ -130,6 +131,10 @@ export const Routing = () => { } /> } /> } /> + } + /> } /> diff --git a/frontend/src/modules/agent/api/agent.api.service.ts b/frontend/src/modules/agent/api/agent.api.service.ts index 906371e..1a18464 100644 --- a/frontend/src/modules/agent/api/agent.api.service.ts +++ b/frontend/src/modules/agent/api/agent.api.service.ts @@ -13,6 +13,7 @@ import type { RegistrationRequest, DeployAgentsRequest, DeployResponse, + SystemMetrics, } from "../types/agent.types"; class AgentApiService { @@ -162,6 +163,13 @@ class AgentApiService { ); return response.data; } + + async getSystemMetrics(): Promise { + const response = await apiClient.get( + `${this.basePath}/system-metrics`, + ); + return Array.isArray(response.data) ? response.data : []; + } } export const agentApiService = new AgentApiService(); diff --git a/frontend/src/modules/agent/types/agent.types.ts b/frontend/src/modules/agent/types/agent.types.ts index addeaea..6d41005 100644 --- a/frontend/src/modules/agent/types/agent.types.ts +++ b/frontend/src/modules/agent/types/agent.types.ts @@ -118,3 +118,14 @@ export interface DeployResponse { message?: string; results: DeployResult[]; } + +export interface SystemMetrics { + connected_at: string; + cpu_percent: number; + disk_percent: number; + id: string; + label: string; + memory_percent: number; + network_rx_bytes: number; + network_tx_bytes: number; +} diff --git a/frontend/src/pages/agent-dashboard.page.tsx b/frontend/src/pages/agent-dashboard.page.tsx new file mode 100644 index 0000000..d7b00d6 --- /dev/null +++ b/frontend/src/pages/agent-dashboard.page.tsx @@ -0,0 +1,447 @@ +import { useEffect, useMemo, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from "recharts"; +import { + startMetricsPolling, + stopMetricsPolling, +} from "@/app/providers/layout/store/metrics.store"; +import { useMetricsStore } from "@/app/providers/layout/store/metrics.store"; +import { FiArrowLeft, FiCpu, FiHardDrive } from "react-icons/fi"; +import { FaMemory, FaNetworkWired } from "react-icons/fa"; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +}; + +interface MetricsSnapshot { + timestamp: string; + metrics: Record; +} + +interface SystemMetrics { + connected_at: string; + cpu_percent: number; + disk_percent: number; + id: string; + label: string; + memory_percent: number; + network_rx_bytes: number; + network_tx_bytes: number; +} + +export const AgentDashboardPage = () => { + const { agentLabel } = useParams<{ agentLabel: string }>(); + const navigate = useNavigate(); + const { metrics, lastUpdated } = useMetricsStore(); + const [history, setHistory] = useState([]); + + useEffect(() => { + startMetricsPolling(); + return () => stopMetricsPolling(); + }, []); + + const agentMetric = useMemo( + () => metrics.find((m) => m.label === agentLabel), + [metrics, agentLabel], + ); + + useEffect(() => { + if (metrics.length > 0) { + const now = new Date().toLocaleTimeString("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }); + const map: Record = {}; + metrics.forEach((m) => { + map[m.label] = m; + }); + setHistory((prev) => + [...prev, { timestamp: now, metrics: map }].slice(-60), + ); + } + }, [metrics]); + + const historyData = useMemo(() => { + return history + .map((s) => { + const m = s.metrics[agentLabel || ""]; + return m + ? [ + { timestamp: s.timestamp, value: m.cpu_percent, metric: "CPU" }, + { + timestamp: s.timestamp, + value: m.memory_percent, + metric: "RAM", + }, + { timestamp: s.timestamp, value: m.disk_percent, metric: "Disk" }, + ] + : []; + }) + .flat(); + }, [history, agentLabel]); + + const cpuHistory = useMemo( + () => historyData.filter((d) => d.metric === "CPU"), + [historyData], + ); + const ramHistory = useMemo( + () => historyData.filter((d) => d.metric === "RAM"), + [historyData], + ); + const diskHistory = useMemo( + () => historyData.filter((d) => d.metric === "Disk"), + [historyData], + ); + + const displayMetric = agentMetric; + + if (!displayMetric) { + return ( +
+
+

+ Метрики для агента "{agentLabel}" не найдены +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {displayMetric.label} +

+

+ {lastUpdated && ( + + Обновлено {new Date(lastUpdated).toLocaleTimeString()} + + )} +

+
+
+ + {/* Metric cards */} +
+
+
+ + + CPU + +
+
+ {displayMetric.cpu_percent.toFixed(1)}% +
+
+ +
+
+ + + RAM + +
+
+ {displayMetric.memory_percent.toFixed(1)}% +
+
+ +
+
+ + + Disk + +
+
+ {displayMetric.disk_percent.toFixed(1)}% +
+
+ +
+
+ + + Network + +
+
+ ↓ {formatBytes(displayMetric.network_rx_bytes)} +
+
+ ↑ {formatBytes(displayMetric.network_tx_bytes)} +
+
+
+ + {/* Charts */} +
+ {/* CPU History */} +
+

+ CPU Usage History (%) +

+ + + + + + + `${v.toFixed(0)}%`, + }} + /> + + +
+ + {/* RAM History */} +
+

+ Memory Usage History (%) +

+ + + + + + + `${v.toFixed(0)}%`, + }} + /> + + +
+ + {/* Disk History */} +
+

+ Disk Usage History (%) +

+ + + + + + + `${v.toFixed(0)}%`, + }} + /> + + +
+
+
+ ); +};