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

68
package-lock.json generated
View File

@@ -35,7 +35,6 @@
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/react-grid-layout": "^1.3.5",
"@types/speakeasy": "^2.0.10",
"@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/react-codemirror": "^4.24.1",
@@ -69,7 +68,6 @@
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-grid-layout": "^1.5.2",
"react-h5-audio-player": "^3.10.1",
"react-hook-form": "^7.60.0",
"react-i18next": "^15.7.3",
@@ -3895,15 +3893,6 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"dev": true,
@@ -7302,12 +7291,6 @@
"version": "3.1.3",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"dev": true,
@@ -11588,38 +11571,6 @@
"react": "^19.1.1"
}
},
"node_modules/react-draggable": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz",
"integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-h5-audio-player": {
"version": "3.10.1",
"license": "MIT",
@@ -11828,19 +11779,6 @@
}
}
},
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.0.3"
},
"peerDependencies": {
"react": ">= 16.3"
}
},
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"license": "MIT",
@@ -12185,12 +12123,6 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"dev": true,

View File

@@ -56,7 +56,6 @@
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/react-grid-layout": "^1.3.5",
"@types/speakeasy": "^2.0.10",
"@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/react-codemirror": "^4.24.1",
@@ -90,7 +89,6 @@
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-grid-layout": "^1.5.2",
"react-h5-audio-player": "^3.10.1",
"react-hook-form": "^7.60.0",
"react-i18next": "^15.7.3",

View File

@@ -269,7 +269,7 @@ router.post(
: null,
enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
};
if (effectiveAuthType === "password") {
@@ -324,7 +324,9 @@ router.post(
? JSON.parse(createdHost.tunnelConnections)
: [],
enableFileManager: !!createdHost.enableFileManager,
statsConfig: createdHost.statsConfig || undefined,
statsConfig: createdHost.statsConfig
? JSON.parse(createdHost.statsConfig)
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -454,7 +456,7 @@ router.put(
: null,
enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
};
if (effectiveAuthType === "password") {
@@ -527,7 +529,9 @@ router.put(
? JSON.parse(updatedHost.tunnelConnections)
: [],
enableFileManager: !!updatedHost.enableFileManager,
statsConfig: updatedHost.statsConfig || undefined,
statsConfig: updatedHost.statsConfig
? JSON.parse(updatedHost.statsConfig)
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -596,7 +600,9 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
? JSON.parse(row.tunnelConnections)
: [],
enableFileManager: !!row.enableFileManager,
statsConfig: row.statsConfig || undefined,
statsConfig: row.statsConfig
? JSON.parse(row.statsConfig)
: undefined,
};
return (await resolveHostCredentials(baseHost)) || baseHost;
@@ -661,7 +667,9 @@ router.get(
? JSON.parse(host.tunnelConnections)
: [],
enableFileManager: !!host.enableFileManager,
statsConfig: host.statsConfig || undefined,
statsConfig: host.statsConfig
? JSON.parse(host.statsConfig)
: undefined,
};
res.json((await resolveHostCredentials(result)) || result);
@@ -1431,6 +1439,9 @@ router.post(
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

View File

@@ -730,7 +730,10 @@
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move host to folder"
"failedToMoveToFolder": "Failed to move host to folder",
"statistics": "Statistics",
"enabledWidgets": "Enabled Widgets",
"enabledWidgetsDesc": "Select which statistics widgets to display for this host"
},
"terminal": {
"title": "Terminal",
@@ -1143,6 +1146,7 @@
"loadAverageNA": "Avg: N/A",
"cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage",
"diskUsage": "Disk Usage",
"rootStorageSpace": "Root Storage Space",
"of": "of",
"feedbackMessage": "Have ideas for what should come next for server management? Share them on",

View File

@@ -752,7 +752,10 @@
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
"failedToRenameFolder": "重命名文件夹失败",
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
"failedToMoveToFolder": "移动主机到文件夹失败"
"failedToMoveToFolder": "移动主机到文件夹失败",
"statistics": "统计",
"enabledWidgets": "已启用组件",
"enabledWidgetsDesc": "选择要为此主机显示的统计组件"
},
"terminal": {
"title": "终端",
@@ -1124,6 +1127,7 @@
"loadAverageNA": "平均: N/A",
"cpuUsage": "CPU 使用率",
"memoryUsage": "内存使用率",
"diskUsage": "磁盘使用率",
"rootStorageSpace": "根目录存储空间",
"of": "的",
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",

View File

@@ -1,53 +1,9 @@
export type WidgetType =
| "cpu" // CPU 使用率
| "memory" // 内存使用率
| "disk"; // 磁盘使用率
// 预留未来功能
// | 'network' // 网络统计
// | 'processes' // 进程数
// | 'uptime'; // 运行时间
export type WidgetSize = "small" | "medium" | "large";
export interface Widget {
id: string; // 唯一 ID"cpu-1", "memory-2"
type: WidgetType; // 卡片类型
size: WidgetSize; // 尺寸small/medium/large
x: number; // 网格X坐标 (0-11)
y: number; // 网格Y坐标
w: number; // 宽度(网格单位 1-12
h: number; // 高度(网格单位)
}
export type WidgetType = "cpu" | "memory" | "disk";
export interface StatsConfig {
widgets: Widget[];
enabledWidgets: WidgetType[];
}
export const DEFAULT_STATS_CONFIG: StatsConfig = {
widgets: [
{ id: "cpu-1", type: "cpu", size: "medium", x: 0, y: 0, w: 4, h: 2 },
{ id: "memory-1", type: "memory", size: "medium", x: 4, y: 0, w: 4, h: 2 },
{ id: "disk-1", type: "disk", size: "medium", x: 8, y: 0, w: 4, h: 2 },
],
enabledWidgets: ["cpu", "memory", "disk"],
};
export const WIDGET_TYPE_CONFIG = {
cpu: {
label: "CPU Usage",
defaultSize: { w: 4, h: 2 },
minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 },
},
memory: {
label: "Memory Usage",
defaultSize: { w: 4, h: 2 },
minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 },
},
disk: {
label: "Disk Usage",
defaultSize: { w: 4, h: 2 },
minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 },
},
} as const;

View File

@@ -38,6 +38,9 @@ import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSele
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import type { StatsConfig, WidgetType } from "@/types/stats-widgets";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { Checkbox } from "@/components/ui/checkbox.tsx";
interface SSHHost {
id: number;
@@ -58,6 +61,7 @@ interface SSHHost {
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
statsConfig?: StatsConfig;
createdAt: string;
updatedAt: string;
credentialId?: number;
@@ -210,6 +214,13 @@ export function HostManagerEditor({
.default([]),
enableFileManager: z.boolean().default(true),
defaultPath: z.string().optional(),
statsConfig: z
.object({
enabledWidgets: z
.array(z.enum(["cpu", "memory", "disk"]))
.default(["cpu", "memory", "disk"]),
})
.default({ enabledWidgets: ["cpu", "memory", "disk"] }),
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
@@ -292,6 +303,7 @@ export function HostManagerEditor({
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
},
});
@@ -348,6 +360,7 @@ export function HostManagerEditor({
enableFileManager: Boolean(cleanedHost.enableFileManager),
defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [],
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
};
if (defaultAuthType === "password") {
@@ -383,6 +396,7 @@ export function HostManagerEditor({
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
};
form.reset(defaultFormData);
@@ -421,6 +435,7 @@ export function HostManagerEditor({
enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
};
submitData.credentialId = null;
@@ -669,6 +684,9 @@ export function HostManagerEditor({
<TabsTrigger value="file_manager">
{t("hosts.fileManager")}
</TabsTrigger>
<TabsTrigger value="statistics">
{t("hosts.statistics")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">
@@ -1533,6 +1551,48 @@ export function HostManagerEditor({
</div>
)}
</TabsContent>
<TabsContent value="statistics">
<FormField
control={form.control}
name="statsConfig.enabledWidgets"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enabledWidgets")}</FormLabel>
<FormDescription>
{t("hosts.enabledWidgetsDesc")}
</FormDescription>
<div className="space-y-3 mt-3">
{(["cpu", "memory", "disk"] as const).map((widget) => (
<div
key={widget}
className="flex items-center space-x-2"
>
<Checkbox
checked={field.value?.includes(widget)}
onCheckedChange={(checked) => {
const currentWidgets = field.value || [];
if (checked) {
field.onChange([...currentWidgets, widget]);
} else {
field.onChange(
currentWidgets.filter((w) => w !== widget),
);
}
}}
/>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{widget === "cpu" && t("serverStats.cpuUsage")}
{widget === "memory" &&
t("serverStats.memoryUsage")}
{widget === "disk" && t("serverStats.diskUsage")}
</label>
</div>
))}
</div>
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">

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>
);
}

View File

@@ -1,98 +0,0 @@
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, size: WidgetSize) => void;
existingWidgetTypes: string[];
}
export function AddWidgetDialog({
open,
onOpenChange,
onAddWidget,
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;
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div
className="absolute inset-0 bg-black/50"
onClick={() => onOpenChange(false)}
/>
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-[500px] w-full mx-4 relative z-10">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Widget</h3>
<button
onClick={() => onOpenChange(false)}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
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;
return (
<button
key={widget.type}
onClick={() => {
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"
>
<div
className={`flex-shrink-0 w-10 h-10 rounded-lg bg-dark-bg-darker flex items-center justify-center ${widget.iconColor}`}
>
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-white mb-1 group-hover:text-blue-400 transition-colors">
{widget.label}
</h4>
<p className="text-sm text-gray-400">{widget.description}</p>
</div>
<Plus className="flex-shrink-0 h-5 w-5 text-gray-500 group-hover:text-blue-400 transition-colors" />
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,8 @@
import React from "react";
import { Cpu, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx";
import { Cpu } from "lucide-react";
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";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
LineChart,
@@ -19,32 +17,11 @@ const {
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) {
export function CpuWidget({ metrics, metricsHistory }: 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) => ({
@@ -55,141 +32,65 @@ export function CpuWidget({
return (
<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) => 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 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
>
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">CPU Usage</h3>
</div>
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-blue-400">
<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-sm text-gray-400 mt-2">
<div className="text-xs text-gray-400">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
)}
{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 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>
)}
{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 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>
);
}

View File

@@ -1,10 +1,8 @@
import React from "react";
import { HardDrive, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx";
import { HardDrive } from "lucide-react";
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";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
RechartsPrimitive;
@@ -12,32 +10,11 @@ const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean;
widgetId: string;
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) {
export function DiskWidget({ metrics, metricsHistory }: 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;
@@ -52,44 +29,51 @@ export function DiskWidget({
return (
<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) => 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 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
>
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">Disk Usage</h3>
</div>
{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">
<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;
@@ -99,34 +83,6 @@ export function DiskWidget({
return "N/A";
})()}
</div>
</div>
)}
{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;
@@ -134,67 +90,7 @@ export function DiskWidget({
})()}
</div>
</div>
)}
{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,10 +1,8 @@
import React from "react";
import { MemoryStick, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx";
import { MemoryStick } from "lucide-react";
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";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
AreaChart,
@@ -19,32 +17,11 @@ const {
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) {
export function MemoryWidget({ metrics, metricsHistory }: 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) => ({
@@ -55,44 +32,19 @@ export function MemoryWidget({
return (
<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) => 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 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
>
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">Memory Usage</h3>
</div>
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-green-400">
<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-sm text-gray-400 mt-2">
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
@@ -103,131 +55,62 @@ export function MemoryWidget({
})()}
</div>
</div>
)}
{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 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>
)}
{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 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>
);
}

View File

@@ -1,11 +1,3 @@
export { CpuWidget } from "./CpuWidget";
export { MemoryWidget } from "./MemoryWidget";
export { DiskWidget } from "./DiskWidget";
export {
WIDGET_REGISTRY,
getAvailableWidgets,
getWidgetConfig,
getWidgetSize,
generateWidgetId,
type WidgetRegistryItem,
} from "./registry";

View File

@@ -1,103 +0,0 @@
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from "lucide-react";
import type { WidgetType, WidgetSize } from "@/types/stats-widgets";
export interface WidgetSizeConfig {
w: number;
h: number;
}
export interface WidgetRegistryItem {
type: WidgetType;
label: string;
description: string;
icon: LucideIcon;
iconColor: string;
sizes: Record<WidgetSize, WidgetSizeConfig>;
minSize: { w: number; h: number };
maxSize: { w: number; h: number };
}
export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
cpu: {
type: "cpu",
label: "CPU Usage",
description: "Monitor CPU utilization and load average",
icon: Cpu,
iconColor: "text-blue-400",
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 },
},
memory: {
type: "memory",
label: "Memory Usage",
description: "Track RAM usage and availability",
icon: MemoryStick,
iconColor: "text-green-400",
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 },
},
disk: {
type: "disk",
label: "Disk Usage",
description: "View disk space consumption",
icon: HardDrive,
iconColor: "text-orange-400",
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 },
},
};
/**
* Get list of all available widgets
*/
export function getAvailableWidgets(): WidgetRegistryItem[] {
return Object.values(WIDGET_REGISTRY);
}
/**
* Get widget configuration by type
*/
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
*/
export function generateWidgetId(
type: WidgetType,
existingIds: string[],
): string {
let counter = 1;
let id = `${type}-${counter}`;
while (existingIds.includes(id)) {
counter++;
id = `${type}-${counter}`;
}
return id;
}