@@ -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 }),
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user