feat: add customizable widget sizes with chart visualizations

Add three-tier size system (small/medium/large) for server stats widgets.
Integrate recharts library for visualizing trends in large widgets with
line charts (CPU), area charts (Memory), and radial bar charts (Disk).
Fix layout overflow issues with proper flexbox patterns.
This commit is contained in:
ZacharyZcR
2025-10-09 10:29:37 +08:00
parent 6e6b173e23
commit 5446875113
11 changed files with 1014 additions and 135 deletions

View File

@@ -25,6 +25,7 @@ import {
DiskWidget,
generateWidgetId,
getWidgetConfig,
getWidgetSize,
} from "./widgets";
import { AddWidgetDialog } from "./widgets/AddWidgetDialog";
import "react-grid-layout/css/styles.css";
@@ -54,6 +55,9 @@ export function Server({
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
@@ -120,10 +124,36 @@ export function Server({
setHasUnsavedChanges(true);
};
const handleAddWidget = (widgetType: string) => {
const handleChangeWidgetSize = (
widgetId: string,
newSize: any,
e: React.MouseEvent<HTMLButtonElement>,
) => {
e.stopPropagation();
e.preventDefault();
setWidgets((prev) =>
prev.map((widget) => {
if (widget.id === widgetId) {
const sizeConfig = getWidgetSize(widget.type, newSize);
return {
...widget,
size: newSize,
w: sizeConfig.w,
h: sizeConfig.h,
};
}
return widget;
}),
);
setHasUnsavedChanges(true);
};
const handleAddWidget = (widgetType: string, size: any) => {
const existingIds = widgets.map((w) => w.id);
const newId = generateWidgetId(widgetType as any, existingIds);
const config = getWidgetConfig(widgetType as any);
const sizeConfig = getWidgetSize(widgetType as any, size);
// Find the next available position
const maxY = widgets.reduce((max, w) => Math.max(max, w.y + w.h), 0);
@@ -131,10 +161,11 @@ export function Server({
const newWidget: Widget = {
id: newId,
type: widgetType as any,
size: size,
x: 0,
y: maxY,
w: config.defaultSize.w,
h: config.defaultSize.h,
w: sizeConfig.w,
h: sizeConfig.h,
};
setWidgets((prev) => [...prev, newWidget]);
@@ -173,9 +204,12 @@ export function Server({
return (
<CpuWidget
metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode}
widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/>
);
@@ -183,9 +217,12 @@ export function Server({
return (
<MemoryWidget
metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode}
widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/>
);
@@ -193,9 +230,12 @@ export function Server({
return (
<DiskWidget
metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode}
widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/>
);
@@ -275,6 +315,11 @@ export function Server({
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
// Keep last 20 data points for chart
return newHistory.slice(-20);
});
setShowStatsUI(true);
}
} catch (error: any) {

View File

@@ -1,11 +1,12 @@
import React from "react";
import { getAvailableWidgets, type WidgetRegistryItem } from "./registry";
import { Plus, X } from "lucide-react";
import type { WidgetSize } from "@/types/stats-widgets";
interface AddWidgetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAddWidget: (widgetType: string) => void;
onAddWidget: (widgetType: string, size: WidgetSize) => void;
existingWidgetTypes: string[];
}
@@ -16,6 +17,13 @@ export function AddWidgetDialog({
existingWidgetTypes,
}: AddWidgetDialogProps) {
const availableWidgets = getAvailableWidgets();
const [selectedSize, setSelectedSize] = React.useState<WidgetSize>("medium");
const sizeLabels: Record<WidgetSize, string> = {
small: "Small",
medium: "Medium",
large: "Large",
};
if (!open) return null;
@@ -36,8 +44,26 @@ export function AddWidgetDialog({
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Choose a widget to add to your dashboard
Choose a widget and size to add to your dashboard
</p>
{/* Size selector */}
<div className="flex gap-2 mb-4">
{(["small", "medium", "large"] as WidgetSize[]).map((size) => (
<button
key={size}
onClick={() => setSelectedSize(size)}
className={`flex-1 py-2 px-4 rounded-lg border transition-all ${
selectedSize === size
? "bg-blue-500 border-blue-500 text-white"
: "bg-dark-bg-darker border-dark-border text-gray-400 hover:border-blue-500/50 hover:text-white"
}`}
>
{sizeLabels[size]}
</button>
))}
</div>
<div className="grid gap-3 max-h-[400px] overflow-y-auto">
{availableWidgets.map((widget: WidgetRegistryItem) => {
const Icon = widget.icon;
@@ -45,7 +71,7 @@ export function AddWidgetDialog({
<button
key={widget.type}
onClick={() => {
onAddWidget(widget.type);
onAddWidget(widget.type, selectedSize);
onOpenChange(false);
}}
className="flex items-start gap-4 p-4 rounded-lg border border-dark-border bg-dark-bg/50 hover:bg-dark-bg hover:border-blue-500/50 transition-all duration-200 text-left group"

View File

@@ -1,74 +1,195 @@
import React from "react";
import { Cpu, X } from "lucide-react";
import { Cpu, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import type { WidgetSize } from "@/types/stats-widgets";
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} = RechartsPrimitive;
interface CpuWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean;
widgetId: string;
widgetSize: WidgetSize;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
onChangeSize: (
widgetId: string,
newSize: WidgetSize,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
}
export function CpuWidget({
metrics,
metricsHistory,
isEditMode,
widgetId,
widgetSize,
onDelete,
onChangeSize,
}: CpuWidgetProps) {
const { t } = useTranslation();
const sizeOrder: WidgetSize[] = ["small", "medium", "large"];
const nextSize =
sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length];
// Prepare chart data
const chartData = React.useMemo(() => {
return metricsHistory.map((m, index) => ({
index,
cpu: m.cpu?.percent || 0,
}));
}, [metricsHistory]);
return (
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
{isEditMode && (
<button
onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
>
<X className="h-4 w-4" />
</button>
<>
<button
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
title={`Change to ${nextSize}`}
>
<Maximize2 className="h-4 w-4" />
</button>
<button
onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
>
<X className="h-4 w-4" />
</button>
</>
)}
<div
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
className={`flex items-center gap-2 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
>
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">CPU Usage</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-blue-400">
{typeof metrics?.cpu?.percent === "number"
? `${metrics.cpu.percent}%`
: "N/A"}
</div>
<div className="text-sm text-gray-400 mt-2">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
)}
{widgetSize === "medium" && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
)}
{widgetSize === "large" && (
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-blue-400">
{typeof metrics?.cpu?.percent === "number"
? `${metrics.cpu.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)} / ${metrics.cpu.load[1].toFixed(2)} / ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [`${value.toFixed(1)}%`, "CPU"]}
/>
<Line
type="monotone"
dataKey="cpu"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
animationDuration={300}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,74 +1,200 @@
import React from "react";
import { HardDrive, X } from "lucide-react";
import { HardDrive, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import type { WidgetSize } from "@/types/stats-widgets";
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
RechartsPrimitive;
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean;
widgetId: string;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
widgetSize: WidgetSize;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonButton>) => void;
onChangeSize: (
widgetId: string,
newSize: WidgetSize,
e: React.MouseEvent<HTMLButtonButton>,
) => void;
}
export function DiskWidget({
metrics,
metricsHistory,
isEditMode,
widgetId,
widgetSize,
onDelete,
onChangeSize,
}: DiskWidgetProps) {
const { t } = useTranslation();
const sizeOrder: WidgetSize[] = ["small", "medium", "large"];
const nextSize =
sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length];
// Prepare radial chart data
const radialData = React.useMemo(() => {
const percent = metrics?.disk?.percent || 0;
return [
{
name: "Disk",
value: percent,
fill: "#fb923c",
},
];
}, [metrics]);
return (
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
{isEditMode && (
<button
onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
>
<X className="h-4 w-4" />
</button>
<>
<button
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
title={`Change to ${nextSize}`}
>
<Maximize2 className="h-4 w-4" />
</button>
<button
onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
>
<X className="h-4 w-4" />
</button>
</>
)}
<div
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
className={`flex items-center gap-2 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
>
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">Disk Usage</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-orange-400">
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</div>
<div className="text-sm text-gray-400 mt-2">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
const usedText = used ?? "N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</span>
</div>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
)}
{widgetSize === "medium" && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
const usedText = used ?? "N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const available = metrics?.disk?.availableHuman;
return available ? `Available: ${available}` : "Available: N/A";
})()}
</div>
</div>
<div className="text-xs text-gray-500">
{(() => {
const available = metrics?.disk?.availableHuman;
return available ? `Available: ${available}` : "Available: N/A";
})()}
)}
{widgetSize === "large" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="90%"
data={radialData}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background
dataKey="value"
cornerRadius={10}
fill="#fb923c"
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
className="text-2xl font-bold fill-orange-400"
>
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</text>
</RadialBarChart>
</ResponsiveContainer>
</div>
<div className="flex-shrink-0 space-y-1 text-center pb-2">
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</div>
<div className="text-xs text-gray-500">
{(() => {
const available = metrics?.disk?.availableHuman;
return available ? `Available: ${available}` : "Available: N/A";
})()}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,81 +1,233 @@
import React from "react";
import { MemoryStick, X } from "lucide-react";
import { MemoryStick, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import type { WidgetSize } from "@/types/stats-widgets";
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} = RechartsPrimitive;
interface MemoryWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean;
widgetId: string;
widgetSize: WidgetSize;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
onChangeSize: (
widgetId: string,
newSize: WidgetSize,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
}
export function MemoryWidget({
metrics,
metricsHistory,
isEditMode,
widgetId,
widgetSize,
onDelete,
onChangeSize,
}: MemoryWidgetProps) {
const { t } = useTranslation();
const sizeOrder: WidgetSize[] = ["small", "medium", "large"];
const nextSize =
sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length];
// Prepare chart data
const chartData = React.useMemo(() => {
return metricsHistory.map((m, index) => ({
index,
memory: m.memory?.percent || 0,
}));
}, [metricsHistory]);
return (
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
{isEditMode && (
<button
onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
>
<X className="h-4 w-4" />
</button>
<>
<button
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
title={`Change to ${nextSize}`}
>
<Maximize2 className="h-4 w-4" />
</button>
<button
onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
>
<X className="h-4 w-4" />
</button>
</>
)}
<div
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
className={`flex items-center gap-2 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
>
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">Memory Usage</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-green-400">
{typeof metrics?.memory?.percent === "number"
? `${metrics.memory.percent}%`
: "N/A"}
</div>
<div className="text-sm text-gray-400 mt-2">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number" ? `${used.toFixed(1)} GiB` : "N/A";
const totalText =
typeof total === "number" ? `${total.toFixed(1)} GiB` : "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
if (typeof used === "number" && typeof total === "number") {
return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`;
}
return "N/A";
})()}
</span>
</div>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
)}
{widgetSize === "medium" && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number" ? `${used.toFixed(1)} GiB` : "N/A";
const totalText =
typeof total === "number" ? `${total.toFixed(1)} GiB` : "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`;
})()}
</div>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`;
})()}
)}
{widgetSize === "large" && (
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-green-400">
{typeof metrics?.memory?.percent === "number"
? `${metrics.memory.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
if (typeof used === "number" && typeof total === "number") {
return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`;
}
return "N/A";
})()}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`;
})()}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient
id="memoryGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [
`${value.toFixed(1)}%`,
"Memory",
]}
/>
<Area
type="monotone"
dataKey="memory"
stroke="#34d399"
strokeWidth={2}
fill="url(#memoryGradient)"
animationDuration={300}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ export {
WIDGET_REGISTRY,
getAvailableWidgets,
getWidgetConfig,
getWidgetSize,
generateWidgetId,
type WidgetRegistryItem,
} from "./registry";

View File

@@ -1,5 +1,10 @@
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from "lucide-react";
import type { WidgetType } from "@/types/stats-widgets";
import type { WidgetType, WidgetSize } from "@/types/stats-widgets";
export interface WidgetSizeConfig {
w: number;
h: number;
}
export interface WidgetRegistryItem {
type: WidgetType;
@@ -7,7 +12,7 @@ export interface WidgetRegistryItem {
description: string;
icon: LucideIcon;
iconColor: string;
defaultSize: { w: number; h: number };
sizes: Record<WidgetSize, WidgetSizeConfig>;
minSize: { w: number; h: number };
maxSize: { w: number; h: number };
}
@@ -19,7 +24,11 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
description: "Monitor CPU utilization and load average",
icon: Cpu,
iconColor: "text-blue-400",
defaultSize: { w: 4, h: 2 },
sizes: {
small: { w: 3, h: 2 }, // 紧凑:大号百分比+核心数
medium: { w: 4, h: 2 }, // 标准:进度条+load average
large: { w: 7, h: 3 }, // 图表:折线图需要宽度展示趋势
},
minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 },
},
@@ -29,7 +38,11 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
description: "Track RAM usage and availability",
icon: MemoryStick,
iconColor: "text-green-400",
defaultSize: { w: 4, h: 2 },
sizes: {
small: { w: 3, h: 2 }, // 紧凑:百分比+用量
medium: { w: 4, h: 2 }, // 标准:进度条+详细信息
large: { w: 6, h: 3 }, // 图表:面积图展示
},
minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 },
},
@@ -39,7 +52,11 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
description: "View disk space consumption",
icon: HardDrive,
iconColor: "text-orange-400",
defaultSize: { w: 4, h: 2 },
sizes: {
small: { w: 3, h: 2 }, // 紧凑:百分比+用量
medium: { w: 4, h: 2 }, // 标准:进度条+可用空间
large: { w: 4, h: 4 }, // 图表:径向图(方形,不需要太宽)
},
minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 },
},
@@ -59,6 +76,16 @@ export function getWidgetConfig(type: WidgetType): WidgetRegistryItem {
return WIDGET_REGISTRY[type];
}
/**
* Get widget size configuration
*/
export function getWidgetSize(
type: WidgetType,
size: WidgetSize,
): WidgetSizeConfig {
return WIDGET_REGISTRY[type].sizes[size];
}
/**
* Generate unique widget ID
*/