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

This commit is contained in:
nikita
2026-04-04 16:53:12 +03:00
parent 55cb214458
commit 78f35f6811
11 changed files with 1221 additions and 0 deletions
@@ -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 = () => {
<Route path="/IDE" element={<IDEPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/graphs" element={<GraphsPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
</Route>
<Route path="/test" element={<TestPage />} />
@@ -0,0 +1,20 @@
import React from "react";
import { FaPlus } from "react-icons/fa";
interface AddWidgetButtonProps {
onClick: () => void;
}
export const AddWidgetButton: React.FC<AddWidgetButtonProps> = ({
onClick,
}) => {
return (
<button
onClick={onClick}
className="w-full py-1.5 bg-tertiary hover:bg-tertiary/70 rounded-lg border border-primary transition-colors flex items-center justify-center gap-1 cursor-pointer"
>
<FaPlus size={10} className="text-tertiary" />
<span className="text-[10px] text-secondary">Добавить график</span>
</button>
);
};
@@ -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<AddWidgetModalProps> = ({
isOpen,
onAdd,
onClose,
}) => {
const [type, setType] = useState<ChartType>("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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xs font-semibold text-primary mb-3">
Добавить график
</h3>
<div className="space-y-2">
<div>
<label className="block text-[10px] text-secondary mb-1">
Тип
</label>
<div className="flex gap-1">
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
<button
key={t}
onClick={() => setType(t)}
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
type === t
? "bg-accent-primary text-white"
: "bg-tertiary text-secondary hover:bg-tertiary/70"
}`}
>
{t === "line" && "📈"}
{t === "bar" && "📊"}
{t === "area" && "📉"}
{t === "pie" && "🥧"}
</button>
))}
</div>
</div>
<div>
<label className="block text-[10px] text-secondary mb-1">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-1 pt-2">
<button
onClick={handleAdd}
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
>
Добавить
</button>
<button
onClick={onClose}
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
>
Отмена
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -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<string, string> = {
INFO: "#10b981", // зеленый
WARN: "#f59e0b", // оранжевый
ERROR: "#ef4444", // красный
DEBUG: "#3b82f6", // синий
};
export const ChartWidget: React.FC<ChartWidgetProps> = ({
widget,
data,
onEdit,
onToggleVisibility,
}) => {
const renderChart = () => {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-[10px] text-tertiary">Нет данных</span>
</div>
);
}
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 (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
stroke="#64748b"
tick={{ fontSize: 9 }}
interval={Math.floor(normalizedData.length / 5)}
/>
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
verticalAlign="top"
height={25}
/>
{METRICS.map((metric) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={METRIC_COLORS[metric]}
strokeWidth={2}
dot={false}
name={metric}
/>
))}
</LineChart>
);
case "bar":
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
stroke="#64748b"
tick={{ fontSize: 9 }}
interval={Math.floor(normalizedData.length / 5)}
/>
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
verticalAlign="top"
height={25}
/>
{METRICS.map((metric) => (
<Bar
key={metric}
dataKey={metric}
fill={METRIC_COLORS[metric]}
radius={[2, 2, 0, 0]}
name={metric}
/>
))}
</BarChart>
);
case "area":
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
stroke="#64748b"
tick={{ fontSize: 9 }}
interval={Math.floor(normalizedData.length / 5)}
/>
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
verticalAlign="top"
height={25}
/>
{METRICS.map((metric) => (
<Area
key={metric}
type="monotone"
dataKey={metric}
stroke={METRIC_COLORS[metric]}
fill={METRIC_COLORS[metric]}
fillOpacity={0.2}
name={metric}
/>
))}
</AreaChart>
);
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 (
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={55}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={METRIC_COLORS[entry.name]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
layout="vertical"
verticalAlign="middle"
align="right"
/>
</PieChart>
);
default:
return null;
}
};
const getIcon = () => {
switch (widget.type) {
case "line":
return <FaChartLine size={10} />;
case "bar":
return <FaChartBar size={10} />;
case "area":
return <FaChartArea size={10} />;
case "pie":
return <FaChartPie size={10} />;
default:
return null;
}
};
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={`bg-secondary rounded-lg border border-primary p-2 transition-all ${!widget.visible ? "opacity-50" : ""}`}
>
<div className="flex items-center justify-between mb-1 px-1">
<div className="flex items-center gap-1">
<span className="text-tertiary">{getIcon()}</span>
<h3 className="text-[11px] font-medium text-primary">
{widget.title}
</h3>
</div>
<div className="flex gap-0.5">
<button
onClick={onToggleVisibility}
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
title={widget.visible ? "Скрыть" : "Показать"}
>
{widget.visible ? (
<FaEye size={9} className="text-tertiary" />
) : (
<FaEyeSlash size={9} className="text-tertiary" />
)}
</button>
<button
onClick={onEdit}
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
title="Настройки"
>
<FaCog size={9} className="text-tertiary" />
</button>
</div>
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
</motion.div>
);
};
@@ -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<WidgetSettingsProps> = ({
widget,
onUpdate,
onRemove,
onClose,
}) => {
const [type, setType] = useState<ChartType>(widget.type);
const [title, setTitle] = useState(widget.title);
const handleSave = () => {
onUpdate({ ...widget, type, title });
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xs font-semibold text-primary mb-3">
Настройки графика
</h3>
<div className="space-y-2">
<div>
<label className="block text-[10px] text-secondary mb-1">Тип</label>
<div className="flex gap-1">
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
<button
key={t}
onClick={() => setType(t)}
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
type === t
? "bg-accent-primary text-white"
: "bg-tertiary text-secondary hover:bg-tertiary/70"
}`}
>
{t === "line" && "📈"}
{t === "bar" && "📊"}
{t === "area" && "📉"}
{t === "pie" && "🥧"}
</button>
))}
</div>
</div>
<div>
<label className="block text-[10px] text-secondary mb-1">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-1 pt-2">
<button
onClick={handleSave}
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
>
Сохранить
</button>
<button
onClick={onRemove}
className="px-2 py-1 bg-red-500/10 text-red-500 rounded text-[10px] hover:bg-red-500/20 transition-colors cursor-pointer"
>
Удалить
</button>
<button
onClick={onClose}
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
>
Отмена
</button>
</div>
</div>
</motion.div>
</motion.div>
);
};
@@ -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<DashboardChartProps> = ({
title,
type,
data,
dataKeys,
colors = COLORS,
}) => {
const renderChart = () => {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Нет данных
</span>
</div>
);
}
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 (
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={60}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{pieData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
))}
</Pie>
<Tooltip {...tooltipStyle} />
<Legend
wrapperStyle={{ fontSize: "11px" }}
layout="vertical"
verticalAlign="middle"
align="right"
/>
</PieChart>
);
}
const ChartComponent =
type === "line" ? LineChart : type === "area" ? AreaChart : BarChart;
const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar;
return (
<ChartComponent {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
{...axisStyle}
interval={Math.floor(data.length / 5)}
/>
<YAxis {...axisStyle} width={35} />
<Tooltip {...tooltipStyle} />
<Legend wrapperStyle={{ fontSize: "11px" }} />
{dataKeys.map((key, i) => (
<DataComponent
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={type === "area" ? 0.2 : undefined}
strokeWidth={2}
dot={false}
name={key}
radius={type === "bar" ? [2, 2, 0, 0] : undefined}
/>
))}
</ChartComponent>
);
};
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
style={{
padding: "8px",
}}
>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
{title}
</h3>
<div style={{ height: 180 }}>
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
</motion.div>
);
};
@@ -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<number | null>(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<any>(null);
const [isAdding, setIsAdding] = useState(false);
const visibleWidgets = widgets.filter((w) => w.visible);
return (
<div className="p-4">
{loading && chartData.length === 0 ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-primary border-t-transparent" />
</div>
) : error ? (
<div className="flex items-center justify-center h-40">
<span className="text-[10px] text-red-500">{error}</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
{visibleWidgets.map((widget) => (
<ChartWidget
key={widget.id}
widget={widget}
data={chartData}
onEdit={() => setEditingWidget(widget)}
onToggleVisibility={() => toggleVisibility(widget.id)}
/>
))}
</div>
)}
<AddWidgetButton onClick={() => setIsAdding(true)} />
<AddWidgetModal
isOpen={isAdding}
onAdd={addWidget}
onClose={() => setIsAdding(false)}
/>
{editingWidget && (
<WidgetSettings
widget={editingWidget}
onUpdate={updateWidget}
onRemove={() => removeWidget(editingWidget.id)}
onClose={() => setEditingWidget(null)}
/>
)}
</div>
);
};
@@ -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<ChartWidget[]>(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,
};
};
@@ -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<string, string>,
) => Promise<void>;
clearData: () => void;
}
export const useDashboardStore = create<DashboardState>((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<string, string>,
) => {
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<string, string> = {
agg: "count",
groupby: "level",
...extraParams,
};
const result = await apiService.get<any>(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 }),
};
});
+22
View File
@@ -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;
}
+197
View File
@@ -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 (
<div style={{ padding: "16px 20px" }}>
<h1
style={{
fontSize: "16px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "16px",
}}
>
Мониторинг системы
</h1>
<div
style={{
maxWidth: "1100px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{/* Центр: Метрика логов — круговая диаграмма */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div style={{ width: "100%", maxWidth: 600 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
textAlign: "center",
}}
>
Метрики логов
</h3>
<div style={{ height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={metricData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={110}
paddingAngle={4}
dataKey="value"
nameKey="name"
strokeWidth={0}
>
{metricData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
["#10b981", "#f59e0b", "#ef4444", "#dc2626"][index]
}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "11px",
padding: "4px 8px",
}}
/>
<Legend
wrapperStyle={{
fontSize: "11px",
paddingTop: "4px",
}}
iconType="circle"
iconSize={8}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Верхний ряд: CPU + RAM */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "12px",
}}
>
<DashboardChart
title="CPU"
type="line"
data={cpuData}
dataKeys={["Использование %"]}
colors={["#3b82f6"]}
/>
<DashboardChart
title="Оперативная память"
type="area"
data={ramData}
dataKeys={["Использовано ГБ", "Свободно ГБ"]}
colors={["#10b981", "#64748b"]}
/>
</div>
{/* Нижний ряд: Диск + Сеть */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "12px",
}}
>
<DashboardChart
title="Жесткий диск"
type="line"
data={diskData}
dataKeys={["Занято ГБ"]}
colors={["#f59e0b"]}
/>
<DashboardChart
title="Сеть"
type="area"
data={networkData}
dataKeys={["Входящий Мбит/с", "Исходящий Мбит/с"]}
colors={["#8b5cf6", "#06b6d4"]}
/>
</div>
</div>
</div>
);
};