feat: dashboard
ci-front / build (push) Successful in 2m18s

This commit is contained in:
nikita
2026-04-05 07:17:33 +03:00
parent 337e5891f3
commit 17d4770de6
6 changed files with 529 additions and 1 deletions
@@ -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<SidebarProps> = ({
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<SidebarProps> = ({
style={{ color: "var(--accent)" }}
/>
<span
className="text-sm font-medium flex-1 truncate"
className="text-sm font-medium flex-1 truncate cursor-pointer"
style={{ color: "var(--text-primary)" }}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/${agent.label}`);
}}
title="Открыть дашборд агента"
>
{agent.label}
</span>
@@ -0,0 +1,50 @@
import { create } from "zustand";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import type { SystemMetrics } from "@/modules/agent/types/agent.types";
interface MetricsState {
metrics: SystemMetrics[];
isLoading: boolean;
error: string | null;
lastUpdated: number | null;
}
const POLLING_INTERVAL = 30_000;
let _pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useMetricsStore = create<MetricsState>(() => ({
metrics: [],
isLoading: false,
error: null,
lastUpdated: null,
}));
export const startMetricsPolling = async () => {
if (_pollingTimer) return;
const fetchMetrics = async () => {
try {
const data = await agentApiService.getSystemMetrics();
useMetricsStore.setState({
metrics: data,
isLoading: false,
error: null,
lastUpdated: Date.now(),
});
} catch (e) {
useMetricsStore.setState({
error: e instanceof Error ? e.message : "Failed to fetch metrics",
isLoading: false,
});
}
};
await fetchMetrics();
_pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL);
};
export const stopMetricsPolling = () => {
if (_pollingTimer) {
clearInterval(_pollingTimer);
_pollingTimer = null;
}
};
@@ -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 = () => {
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/graphs" element={<GraphsPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route
path="/dashboard/:agentLabel"
element={<AgentDashboardPage />}
/>
</Route>
<Route path="/test" element={<TestPage />} />
@@ -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<SystemMetrics[]> {
const response = await apiClient.get<SystemMetrics[]>(
`${this.basePath}/system-metrics`,
);
return Array.isArray(response.data) ? response.data : [];
}
}
export const agentApiService = new AgentApiService();
@@ -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;
}
+447
View File
@@ -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<string, SystemMetrics>;
}
interface SystemMetrics {
connected_at: string;
cpu_percent: number;
disk_percent: number;
id: string;
label: string;
memory_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
}
export const AgentDashboardPage = () => {
const { agentLabel } = useParams<{ agentLabel: string }>();
const navigate = useNavigate();
const { metrics, lastUpdated } = useMetricsStore();
const [history, setHistory] = useState<MetricsSnapshot[]>([]);
useEffect(() => {
startMetricsPolling();
return () => stopMetricsPolling();
}, []);
const agentMetric = useMemo(
() => metrics.find((m) => m.label === agentLabel),
[metrics, agentLabel],
);
useEffect(() => {
if (metrics.length > 0) {
const now = new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
const map: Record<string, SystemMetrics> = {};
metrics.forEach((m) => {
map[m.label] = m;
});
setHistory((prev) =>
[...prev, { timestamp: now, metrics: map }].slice(-60),
);
}
}, [metrics]);
const historyData = useMemo(() => {
return history
.map((s) => {
const m = s.metrics[agentLabel || ""];
return m
? [
{ timestamp: s.timestamp, value: m.cpu_percent, metric: "CPU" },
{
timestamp: s.timestamp,
value: m.memory_percent,
metric: "RAM",
},
{ timestamp: s.timestamp, value: m.disk_percent, metric: "Disk" },
]
: [];
})
.flat();
}, [history, agentLabel]);
const cpuHistory = useMemo(
() => historyData.filter((d) => d.metric === "CPU"),
[historyData],
);
const ramHistory = useMemo(
() => historyData.filter((d) => d.metric === "RAM"),
[historyData],
);
const diskHistory = useMemo(
() => historyData.filter((d) => d.metric === "Disk"),
[historyData],
);
const displayMetric = agentMetric;
if (!displayMetric) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p style={{ color: "var(--text-muted)" }}>
Метрики для агента "{agentLabel}" не найдены
</p>
</div>
</div>
);
}
return (
<div style={{ padding: "16px 20px" }}>
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => navigate("/dashboard")}
style={{
background: "transparent",
border: "1px solid var(--border)",
color: "var(--text-primary)",
padding: "6px 10px",
borderRadius: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<FiArrowLeft size={14} />
Назад
</button>
<div>
<h1
style={{
fontSize: "16px",
fontWeight: 600,
color: "var(--text-primary)",
margin: 0,
}}
>
{displayMetric.label}
</h1>
<p
style={{
fontSize: "12px",
color: "var(--text-secondary)",
margin: "2px 0 0 0",
}}
>
{lastUpdated && (
<span>
Обновлено {new Date(lastUpdated).toLocaleTimeString()}
</span>
)}
</p>
</div>
</div>
{/* Metric cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "12px",
marginBottom: "20px",
}}
>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FiCpu size={16} style={{ color: "#3b82f6" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
CPU
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.cpu_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FaMemory size={16} style={{ color: "#10b981" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
RAM
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.memory_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FiHardDrive size={16} style={{ color: "#f59e0b" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Disk
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.disk_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FaNetworkWired size={16} style={{ color: "#8b5cf6" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Network
</span>
</div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatBytes(displayMetric.network_rx_bytes)}
</div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatBytes(displayMetric.network_tx_bytes)}
</div>
</div>
</div>
{/* Charts */}
<div
style={{
maxWidth: "1100px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{/* CPU History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
CPU Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cpuHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#3b82f6"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* RAM History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
Memory Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={ramHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#10b981"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* Disk History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
Disk Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={diskHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#f59e0b"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};