diff --git a/frontend/src/app/providers/routing/routing.tsx b/frontend/src/app/providers/routing/routing.tsx index 158f76d..3fd0a16 100644 --- a/frontend/src/app/providers/routing/routing.tsx +++ b/frontend/src/app/providers/routing/routing.tsx @@ -13,6 +13,7 @@ import { AdminPage } from "@/pages/admin.page"; import { RegistrationTokenPage } from "@/pages/registration.page"; import { LogsPage } from "@/pages/logs.page"; import { GraphsPage } from "@/pages/graphs.page"; +import { DashboardPage } from "@/pages/dashboard.page"; export const mockGraphData: GraphData = { nodes: [ @@ -128,6 +129,7 @@ export const Routing = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/modules/dashboard/components/add.widget.button.tsx b/frontend/src/modules/dashboard/components/add.widget.button.tsx new file mode 100644 index 0000000..0ca90a9 --- /dev/null +++ b/frontend/src/modules/dashboard/components/add.widget.button.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { FaPlus } from "react-icons/fa"; + +interface AddWidgetButtonProps { + onClick: () => void; +} + +export const AddWidgetButton: React.FC = ({ + onClick, +}) => { + return ( + + ); +}; diff --git a/frontend/src/modules/dashboard/components/add.widget.modal.tsx b/frontend/src/modules/dashboard/components/add.widget.modal.tsx new file mode 100644 index 0000000..30e46e4 --- /dev/null +++ b/frontend/src/modules/dashboard/components/add.widget.modal.tsx @@ -0,0 +1,108 @@ +import React, { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import type { ChartType } from "../types"; + +interface AddWidgetModalProps { + isOpen: boolean; + onAdd: (data: { type: ChartType; title: string; dataKey: string }) => void; + onClose: () => void; +} + +export const AddWidgetModal: React.FC = ({ + isOpen, + onAdd, + onClose, +}) => { + const [type, setType] = useState("line"); + const [title, setTitle] = useState(""); + const [dataKey, setDataKey] = useState("requests"); + + const handleAdd = () => { + if (!title.trim()) return; + onAdd({ type, title: title.trim(), dataKey }); + setTitle(""); + setType("line"); + setDataKey("requests"); + onClose(); + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > +

+ Добавить график +

+ +
+
+ +
+ {(["line", "bar", "area", "pie"] as ChartType[]).map((t) => ( + + ))} +
+
+ +
+ + setTitle(e.target.value)} + placeholder="Название" + className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary" + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/modules/dashboard/components/chart,widget.tsx b/frontend/src/modules/dashboard/components/chart,widget.tsx new file mode 100644 index 0000000..709d150 --- /dev/null +++ b/frontend/src/modules/dashboard/components/chart,widget.tsx @@ -0,0 +1,299 @@ +// modules/dashboard/components/ChartWidget.tsx +import React from "react"; +import { + LineChart, + Line, + BarChart, + Bar, + AreaChart, + Area, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { + FaChartLine, + FaChartBar, + FaChartArea, + FaChartPie, + FaCog, + FaEye, + FaEyeSlash, +} from "react-icons/fa"; +import { motion } from "framer-motion"; +import type { ChartWidget as ChartWidgetType, MetricData } from "../types"; + +interface ChartWidgetProps { + widget: ChartWidgetType; + data: MetricData[]; + onEdit: () => void; + onToggleVisibility: () => void; +} + +// Все возможные уровни логов (метрики) +const METRICS = ["INFO", "WARN", "ERROR", "DEBUG"]; + +// Цвета для каждой метрики +const METRIC_COLORS: Record = { + INFO: "#10b981", // зеленый + WARN: "#f59e0b", // оранжевый + ERROR: "#ef4444", // красный + DEBUG: "#3b82f6", // синий +}; + +export const ChartWidget: React.FC = ({ + widget, + data, + onEdit, + onToggleVisibility, +}) => { + const renderChart = () => { + if (!data || !Array.isArray(data) || data.length === 0) { + return ( +
+ Нет данных +
+ ); + } + + const normalizedData = data.map((point) => { + const normalized: MetricData = { timestamp: point.timestamp }; + METRICS.forEach((metric) => { + normalized[metric] = point[metric] || 0; + }); + return normalized; + }); + + const commonProps = { + data: normalizedData, + margin: { top: 5, right: 10, left: 0, bottom: 5 }, + }; + + switch (widget.type) { + case "line": + return ( + + + + + + + {METRICS.map((metric) => ( + + ))} + + ); + + case "bar": + return ( + + + + + + + {METRICS.map((metric) => ( + + ))} + + ); + + case "area": + return ( + + + + + + + {METRICS.map((metric) => ( + + ))} + + ); + + case "pie": + // Для круговой диаграммы берем последнюю точку + const lastPoint = normalizedData[normalizedData.length - 1]; + const pieData = METRICS.map((metric) => ({ + name: metric, + value: lastPoint[metric] || 0, + })).filter((item) => Number(item.value) > 0); + + return ( + + + {pieData.map((entry, index) => ( + + ))} + + + + + ); + + default: + return null; + } + }; + + const getIcon = () => { + switch (widget.type) { + case "line": + return ; + case "bar": + return ; + case "area": + return ; + case "pie": + return ; + default: + return null; + } + }; + + return ( + +
+
+ {getIcon()} +

+ {widget.title} +

+
+
+ + +
+
+
+ + {renderChart()} + +
+
+ ); +}; diff --git a/frontend/src/modules/dashboard/components/chart.settings.tsx b/frontend/src/modules/dashboard/components/chart.settings.tsx new file mode 100644 index 0000000..cc4f78d --- /dev/null +++ b/frontend/src/modules/dashboard/components/chart.settings.tsx @@ -0,0 +1,105 @@ +// modules/dashboard/components/WidgetSettings.tsx +import React, { useState } from "react"; +import { motion } from "framer-motion"; +import type { ChartType, ChartWidget } from "../types"; + +interface WidgetSettingsProps { + widget: ChartWidget; + onUpdate: (widget: ChartWidget) => void; + onRemove: () => void; + onClose: () => void; +} + +export const WidgetSettings: React.FC = ({ + widget, + onUpdate, + onRemove, + onClose, +}) => { + const [type, setType] = useState(widget.type); + const [title, setTitle] = useState(widget.title); + + const handleSave = () => { + onUpdate({ ...widget, type, title }); + onClose(); + }; + + return ( + + e.stopPropagation()} + > +

+ Настройки графика +

+ +
+
+ +
+ {(["line", "bar", "area", "pie"] as ChartType[]).map((t) => ( + + ))} +
+
+ +
+ + setTitle(e.target.value)} + className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary" + /> +
+ +
+ + + +
+
+
+
+ ); +}; diff --git a/frontend/src/modules/dashboard/components/dashboard.chart.tsx b/frontend/src/modules/dashboard/components/dashboard.chart.tsx new file mode 100644 index 0000000..e68b996 --- /dev/null +++ b/frontend/src/modules/dashboard/components/dashboard.chart.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { + LineChart, + Line, + AreaChart, + Area, + BarChart, + Bar, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { motion } from "framer-motion"; +import type { ChartType, MetricData } from "../types"; + +interface DashboardChartProps { + title: string; + type: ChartType; + data: MetricData[]; + dataKeys: string[]; + colors?: string[]; +} + +const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"]; + +export const DashboardChart: React.FC = ({ + title, + type, + data, + dataKeys, + colors = COLORS, +}) => { + const renderChart = () => { + if (!data || data.length === 0) { + return ( +
+ + Нет данных + +
+ ); + } + + const commonProps = { + data, + margin: { top: 5, right: 10, left: 0, bottom: 5 }, + }; + + const axisStyle = { + stroke: "var(--text-secondary)", + tick: { fontSize: 10 }, + }; + + const tooltipStyle = { + contentStyle: { + backgroundColor: "var(--card-bg)", + border: "1px solid var(--border)", + borderRadius: "6px", + fontSize: "11px", + }, + labelStyle: { color: "var(--text-primary)" }, + }; + + if (type === "pie") { + // Если данные уже в формате { name, value } — используем напрямую + const isPieFormat = + data.length > 0 && "name" in data[0] && "value" in data[0]; + + const pieData = isPieFormat + ? data + : data.map((point, i) => ({ + name: dataKeys[i % dataKeys.length], + value: point[dataKeys[i % dataKeys.length]] || 0, + })); + + return ( + + + {pieData.map((entry, index) => ( + + ))} + + + + + ); + } + + const ChartComponent = + type === "line" ? LineChart : type === "area" ? AreaChart : BarChart; + const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar; + + return ( + + + + + + + {dataKeys.map((key, i) => ( + + ))} + + ); + }; + + return ( + +

+ {title} +

+
+ + {renderChart()} + +
+
+ ); +}; diff --git a/frontend/src/modules/dashboard/dashboard.tsx b/frontend/src/modules/dashboard/dashboard.tsx new file mode 100644 index 0000000..6b1999c --- /dev/null +++ b/frontend/src/modules/dashboard/dashboard.tsx @@ -0,0 +1,96 @@ +// modules/dashboard/Dashboard.tsx +import { useAgentStore } from "@/app/providers/layout/store/agent.store"; +import { useEffect, useRef, useState } from "react"; +import { useDashboardStore } from "./store/dashboard.store"; +import { useAuthStore } from "../auth/store/useAuthStore"; +import { ChartWidget } from "./components/chart,widget"; +import { AddWidgetButton } from "./components/add.widget.button"; +import { AddWidgetModal } from "./components/add.widget.modal"; +import { WidgetSettings } from "./components/chart.settings"; +import { useWidgets } from "./hooks/use.widget"; + +export const Dashboard: React.FC = () => { + const { chartData, loading, error, fetchMetrics, clearData } = + useDashboardStore(); + // const { servicesQueryParams } = useAgentStore(); + const intervalRef = useRef(null); + + const { token } = useAuthStore(); + + // Первичная загрузка (не latest) + // const fetchPrimaryData = () => { + // fetchMetrics(false, token || "", servicesQueryParams, { since: "10m" }); + // }; + + // Периодическое обновление (latest) + // const fetchLatestData = () => { + // fetchMetrics(true, token || "", servicesQueryParams); + // }; + + // useEffect(() => { + // fetchPrimaryData(); + // }, []); + + // useEffect(() => { + // intervalRef.current = window.setInterval(() => { + // fetchLatestData(); + // }, 30000); + + // return () => { + // if (intervalRef.current) { + // window.clearInterval(intervalRef.current); + // } + // clearData(); + // }; + // }, [servicesQueryParams]); + + const { widgets, addWidget, updateWidget, removeWidget, toggleVisibility } = + useWidgets(); + const [editingWidget, setEditingWidget] = useState(null); + const [isAdding, setIsAdding] = useState(false); + + const visibleWidgets = widgets.filter((w) => w.visible); + + return ( +
+ {loading && chartData.length === 0 ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+ {visibleWidgets.map((widget) => ( + setEditingWidget(widget)} + onToggleVisibility={() => toggleVisibility(widget.id)} + /> + ))} +
+ )} + + setIsAdding(true)} /> + + setIsAdding(false)} + /> + + {editingWidget && ( + removeWidget(editingWidget.id)} + onClose={() => setEditingWidget(null)} + /> + )} +
+ ); +}; diff --git a/frontend/src/modules/dashboard/hooks/use.widget.ts b/frontend/src/modules/dashboard/hooks/use.widget.ts new file mode 100644 index 0000000..7d701a7 --- /dev/null +++ b/frontend/src/modules/dashboard/hooks/use.widget.ts @@ -0,0 +1,72 @@ +import { useState } from "react"; +import type { ChartType, ChartWidget } from "../types"; + +const initialWidgets: ChartWidget[] = [ + { + id: "1", + type: "line", + title: "Линии", + dataKey: "chart-line", + visible: true, + }, + { + id: "2", + type: "bar", + title: "Столбцы", + dataKey: "chart-bar", + visible: true, + }, + { + id: "3", + type: "area", + title: "Закрашенные линии", + dataKey: "chart-area", + visible: true, + }, + { + id: "4", + type: "pie", + title: "Круговая диаграмма", + dataKey: "chart-pie", + visible: true, + }, +]; + +export const useWidgets = () => { + const [widgets, setWidgets] = useState(initialWidgets); + + const addWidget = (data: { + type: ChartType; + title: string; + dataKey: string; + }) => { + const newWidget: ChartWidget = { + id: Date.now().toString(), + ...data, + visible: true, + }; + setWidgets([...widgets, newWidget]); + }; + + const updateWidget = (updated: ChartWidget) => { + setWidgets(widgets.map((w) => (w.id === updated.id ? updated : w))); + }; + + const removeWidget = (id: string) => { + setWidgets(widgets.filter((w) => w.id !== id)); + }; + + const toggleVisibility = (id: string) => { + setWidgets( + widgets.map((w) => (w.id === id ? { ...w, visible: !w.visible } : w)), + ); + }; + + return { + widgets, + addWidget, + updateWidget, + removeWidget, + toggleVisibility, + }; +}; diff --git a/frontend/src/modules/dashboard/store/dashboard.store.ts b/frontend/src/modules/dashboard/store/dashboard.store.ts new file mode 100644 index 0000000..ae0a368 --- /dev/null +++ b/frontend/src/modules/dashboard/store/dashboard.store.ts @@ -0,0 +1,129 @@ +import { create } from "zustand"; +import { apiService } from "@/shared/api/api.service"; +import type { MetricData } from "../types"; + +interface DashboardState { + chartData: MetricData[]; + loading: boolean; + error: string | null; + fetchMetrics: ( + isLatest: boolean, + token: string, + queryParams?: string, + extraParams?: Record, + ) => Promise; + clearData: () => void; +} + +export const useDashboardStore = create((set, get) => { + const convertPrimaryData = (response: any) => { + set((state) => { + if (!response.intervals || !Array.isArray(response.intervals)) + return { chartData: state.chartData }; + + const newData = [...state.chartData]; + + response.intervals.forEach((interval: any) => { + const newPoint: MetricData = { + timestamp: new Date(interval.timestamp).toLocaleTimeString(), + }; + + if (interval.group_by && Array.isArray(interval.group_by)) { + interval.group_by.forEach((item: any) => { + newPoint[item.value] = item.count; + }); + } + + newData.push(newPoint); + }); + + return { chartData: newData.slice(-20) }; + }); + }; + + const convertSingleData = (response: any) => { + set((state) => { + const newPoint: MetricData = { + timestamp: new Date().toLocaleTimeString(), + }; + + if (Array.isArray(response)) { + response.forEach((item: any) => { + newPoint[item.value] = item.count; + }); + } else if (response.groupBy && Array.isArray(response.groupBy)) { + response.groupBy.forEach((item: any) => { + newPoint[item.value] = item.count; + }); + } + + const updatedData = [...state.chartData, newPoint].slice(-20); + return { chartData: updatedData }; + }); + }; + + const fetchMetrics = async ( + isLatest: boolean, + token: string, + queryParams?: string, + extraParams?: Record, + ) => { + set({ loading: true, error: null }); + + try { + let endpoint = isLatest + ? "logs/aggregations/latest" + : "logs/aggregations"; + + // Если есть queryParams, добавляем его к эндпоинту + if (queryParams && queryParams.trim() !== "") { + endpoint = `${endpoint}?${queryParams}`; + } + + const params: Record = { + agg: "count", + groupby: "level", + ...extraParams, + }; + + const result = await apiService.get(endpoint, { + params, + headers: { + Authorization: `bearer ${token}`, + }, + }); + + if (result) { + if (isLatest) { + convertSingleData(result); + } else { + convertPrimaryData(result); + } + } + } catch (error) { + console.error( + `Failed to fetch ${isLatest ? "latest" : "primary"} metrics:`, + error, + ); + set({ + error: error instanceof Error ? error.message : "Ошибка запроса", + }); + } finally { + set({ loading: false }); + } + }; + + const clearData = () => { + set({ chartData: [], error: null }); + }; + + return { + chartData: [], + loading: false, + error: null, + fetchMetrics, + clearData, + setChartData: (data: MetricData[]) => + set({ chartData: data, loading: false }), + }; +}); diff --git a/frontend/src/modules/dashboard/types.ts b/frontend/src/modules/dashboard/types.ts new file mode 100644 index 0000000..7dc1fac --- /dev/null +++ b/frontend/src/modules/dashboard/types.ts @@ -0,0 +1,22 @@ +export type ChartType = "line" | "bar" | "area" | "pie"; + +export interface ChartWidget { + id: string; + type: ChartType; + title: string; + dataKey: string; + visible: boolean; +} + +export interface MetricData { + timestamp: string; + [key: string]: number | string; +} + +export interface StatsItem { + label: string; + key: string; + icon: string; + color: string; + suffix?: string; +} diff --git a/frontend/src/pages/dashboard.page.tsx b/frontend/src/pages/dashboard.page.tsx new file mode 100644 index 0000000..0141041 --- /dev/null +++ b/frontend/src/pages/dashboard.page.tsx @@ -0,0 +1,197 @@ +import { DashboardChart } from "@/modules/dashboard/components/dashboard.chart"; +import { + ResponsiveContainer, + PieChart, + Pie, + Cell, + Tooltip, + Legend, +} from "recharts"; + +const generateTimeData = (count: number, base: number, variance: number) => { + const data = []; + const now = new Date(); + for (let i = count - 1; i >= 0; i--) { + const time = new Date(now.getTime() - i * 60000); + const h = time.getHours().toString().padStart(2, "0"); + const m = time.getMinutes().toString().padStart(2, "0"); + data.push({ + timestamp: `${h}:${m}`, + value: Math.round( + base + Math.sin(i / 3) * variance + Math.random() * variance * 0.5, + ), + }); + } + return data; +}; + +const cpuData = generateTimeData(20, 45, 25).map((d, i) => ({ + timestamp: d.timestamp, + "Использование %": d.value, +})); + +const ramData = generateTimeData(20, 60, 15).map((d) => ({ + timestamp: d.timestamp, + "Использовано ГБ": d.value / 10, + "Свободно ГБ": 16 - d.value / 10, +})); + +const diskData = generateTimeData(20, 70, 5).map((d) => ({ + timestamp: d.timestamp, + "Занято ГБ": d.value, +})); + +const networkData = generateTimeData(20, 50, 30).map((d) => ({ + timestamp: d.timestamp, + "Входящий Мбит/с": d.value, + "Исходящий Мбит/с": Math.round(d.value * 0.4), +})); + +const metricData = [ + { name: "INFO", value: 125 }, + { name: "WARN", value: 42 }, + { name: "ERROR", value: 18 }, + { name: "CRITICAL", value: 5 }, +]; + +export const DashboardPage = () => { + return ( +
+

+ Мониторинг системы +

+ +
+ {/* Центр: Метрика логов — круговая диаграмма */} +
+
+

+ Метрики логов +

+
+ + + + {metricData.map((entry, index) => ( + + ))} + + + + + +
+
+
+ + {/* Верхний ряд: CPU + RAM */} +
+ + + +
+ + {/* Нижний ряд: Диск + Сеть */} +
+ + + +
+
+
+ ); +};