refactor: simplify server stats widget system

Replaced complex drag-and-drop grid layout with simple checkbox-based
configuration and static responsive grid display.

- Removed react-grid-layout dependency and 6 related packages
- Simplified StatsConfig from complex Widget objects to simple array
- Added Statistics tab in HostManagerEditor for checkbox selection
- Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout
- Simplified widget components by removing edit mode and size selection
- Deleted unused AddWidgetDialog and registry files
- Fixed statsConfig serialization in backend routes

Net result: -787 lines of code, cleaner architecture.
This commit is contained in:
ZacharyZcR
2025-10-09 12:51:13 +08:00
parent 5446875113
commit d87cabb708
14 changed files with 426 additions and 1230 deletions

View File

@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Edit3, Plus, Save } from "lucide-react";
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
import {
getServerStatusById,
@@ -13,25 +12,12 @@ import {
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Responsive, WidthProvider, type Layout } from "react-grid-layout";
import {
type Widget,
type WidgetType,
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets";
import {
CpuWidget,
MemoryWidget,
DiskWidget,
generateWidgetId,
getWidgetConfig,
getWidgetSize,
} from "./widgets";
import { AddWidgetDialog } from "./widgets/AddWidgetDialog";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const ResponsiveGridLayout = WidthProvider(Responsive);
import { CpuWidget, MemoryWidget, DiskWidget } from "./widgets";
interface ServerProps {
hostConfig?: any;
@@ -62,182 +48,39 @@ export function Server({
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const [isEditMode, setIsEditMode] = React.useState(false);
const [widgets, setWidgets] = React.useState<Widget[]>(
DEFAULT_STATS_CONFIG.widgets,
);
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
const [showAddWidgetDialog, setShowAddWidgetDialog] = React.useState(false);
const statsConfig = React.useMemo((): StatsConfig => {
const enabledWidgets = React.useMemo((): WidgetType[] => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
return DEFAULT_STATS_CONFIG.enabledWidgets;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return parsed?.widgets ? parsed : DEFAULT_STATS_CONFIG;
return parsed?.enabledWidgets || DEFAULT_STATS_CONFIG.enabledWidgets;
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG;
return DEFAULT_STATS_CONFIG.enabledWidgets;
}
}, [currentHostConfig?.statsConfig]);
React.useEffect(() => {
setWidgets(statsConfig.widgets);
}, [statsConfig]);
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
}, [hostConfig]);
const handleLayoutChange = (layout: Layout[]) => {
if (!isEditMode) return;
const updatedWidgets = widgets.map((widget) => {
const layoutItem = layout.find((item) => item.i === widget.id);
if (layoutItem) {
return {
...widget,
x: layoutItem.x,
y: layoutItem.y,
w: layoutItem.w,
h: layoutItem.h,
};
}
return widget;
});
setWidgets(updatedWidgets);
setHasUnsavedChanges(true);
};
const handleDeleteWidget = (
widgetId: string,
e: React.MouseEvent<HTMLButtonElement>,
) => {
e.stopPropagation();
e.preventDefault();
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
setHasUnsavedChanges(true);
};
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);
const newWidget: Widget = {
id: newId,
type: widgetType as any,
size: size,
x: 0,
y: maxY,
w: sizeConfig.w,
h: sizeConfig.h,
};
setWidgets((prev) => [...prev, newWidget]);
setHasUnsavedChanges(true);
toast.success(`${config.label} widget added`);
};
const handleSaveLayout = async () => {
if (!currentHostConfig?.id) {
toast.error(t("serverStats.failedToSaveLayout"));
return;
}
try {
const newConfig: StatsConfig = { widgets };
const { updateSSHHost } = await import("@/ui/main-axios.ts");
await updateSSHHost(currentHostConfig.id, {
...currentHostConfig,
statsConfig: JSON.stringify(newConfig),
} as any);
setHasUnsavedChanges(false);
setIsEditMode(false);
toast.success(t("serverStats.layoutSaved"));
window.dispatchEvent(new Event("ssh-hosts:changed"));
} catch (error) {
console.error("Failed to save layout:", error);
toast.error(t("serverStats.failedToSaveLayout"));
}
};
const renderWidget = (widget: Widget) => {
switch (widget.type) {
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
return (
<CpuWidget
metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode}
widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/>
);
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "memory":
return (
<MemoryWidget
metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode}
widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/>
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "disk":
return (
<DiskWidget
metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode}
widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/>
);
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
default:
return null;
@@ -387,269 +230,186 @@ export function Server({
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
return (
<>
<AddWidgetDialog
open={showAddWidgetDialog}
onOpenChange={setShowAddWidgetDialog}
onAddWidget={handleAddWidget}
existingWidgetTypes={widgets.map((w) => w.type)}
/>
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(
currentHostConfig.id,
);
setServerStatus(
res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
if (
error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 &&
error?.response?.data?.error === "TOTP_REQUIRED")
) {
toast.error(t("serverStats.totpUnavailable"));
setMetrics(null);
setShowStatsUI(false);
} else if (
error?.response?.status === 503 ||
error?.status === 503
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
error?.response?.status === 504 ||
error?.status === 504
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
error?.response?.status === 404 ||
error?.status === 404
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
} finally {
setIsRefreshing(false);
}
}
}}
title={t("serverStats.refreshStatusAndMetrics")}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{t("serverStats.refreshing")}
</div>
) : (
t("serverStats.refreshStatus")
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "file_manager",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.fileManager")}
</Button>
)}
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
</div>
<Separator className="p-0.25 w-full" />
{showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{/* Toolbar */}
<div className="flex items-center justify-between mb-4 gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(
res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
if (
error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 &&
error?.response?.data?.error === "TOTP_REQUIRED")
) {
toast.error(t("serverStats.totpUnavailable"));
setMetrics(null);
setShowStatsUI(false);
} else if (
error?.response?.status === 503 ||
error?.status === 503
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
error?.response?.status === 504 ||
error?.status === 504
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
error?.response?.status === 404 ||
error?.status === 404
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
} finally {
setIsRefreshing(false);
}
}
}}
title={t("serverStats.refreshStatusAndMetrics")}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
{!isEditMode ? (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditMode(true)}
className="flex items-center gap-2"
>
<Edit3 className="h-4 w-4" />
{t("serverStats.editLayout")}
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditMode(false);
setHasUnsavedChanges(false);
setWidgets(statsConfig.widgets);
}}
className="flex items-center gap-2"
>
{t("serverStats.cancelEdit")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowAddWidgetDialog(true)}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
{t("serverStats.addWidget")}
</Button>
<Button
size="sm"
onClick={handleSaveLayout}
disabled={!hasUnsavedChanges}
className="flex items-center gap-2 bg-blue-500 hover:bg-blue-600"
>
<Save className="h-4 w-4" />
{t("serverStats.saveLayout")}
</Button>
</>
)}
</div>
{hasUnsavedChanges && (
<span className="text-sm text-yellow-400">
{t("serverStats.unsavedChanges")}
</span>
)}
</div>
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{t("serverStats.refreshing")}
</div>
) : (
<ResponsiveGridLayout
className="layout"
layouts={{
lg: widgets.map((w) => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
})),
}}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
isDraggable={isEditMode}
isResizable={isEditMode}
onLayoutChange={handleLayoutChange}
draggableHandle={isEditMode ? ".drag-handle" : ".no-drag"}
>
{widgets.map((widget) => (
<div key={widget.id} className="relative">
{renderWidget(widget)}
</div>
))}
</ResponsiveGridLayout>
t("serverStats.refreshStatus")
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "file_manager",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.fileManager")}
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full" />
{showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="min-h-[200px]">
{renderWidget(widgetType)}
</div>
))}
</div>
)}
</div>
)}
{/* SSH Tunnels */}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
{/* SSH Tunnels */}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t("serverStats.feedbackMessage")}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t("serverStats.feedbackMessage")}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</>
</div>
);
}