diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 8e2ac93d..dd031389 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -1161,6 +1161,7 @@
"available": "Available",
"editLayout": "Edit Layout",
"cancelEdit": "Cancel",
+ "addWidget": "Add Widget",
"saveLayout": "Save Layout",
"unsavedChanges": "Unsaved changes",
"layoutSaved": "Layout saved successfully",
diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json
index b9a26bf5..87cf6b0f 100644
--- a/src/locales/zh/translation.json
+++ b/src/locales/zh/translation.json
@@ -1141,6 +1141,7 @@
"available": "可用",
"editLayout": "编辑布局",
"cancelEdit": "取消",
+ "addWidget": "添加小组件",
"saveLayout": "保存布局",
"unsavedChanges": "有未保存的更改",
"layoutSaved": "布局保存成功",
diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx
index ea7281c0..0bdb3d23 100644
--- a/src/ui/Desktop/Apps/Server/Server.tsx
+++ b/src/ui/Desktop/Apps/Server/Server.tsx
@@ -3,16 +3,7 @@ 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 { Progress } from "@/components/ui/progress.tsx";
-import {
- Cpu,
- HardDrive,
- MemoryStick,
- Edit3,
- Plus,
- Save,
- X,
-} from "lucide-react";
+import { Edit3, Plus, Save } from "lucide-react";
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
import {
getServerStatusById,
@@ -27,8 +18,15 @@ import {
type Widget,
type StatsConfig,
DEFAULT_STATS_CONFIG,
- WIDGET_TYPE_CONFIG,
} from "@/types/stats-widgets";
+import {
+ CpuWidget,
+ MemoryWidget,
+ DiskWidget,
+ generateWidgetId,
+ getWidgetConfig,
+} from "./widgets";
+import { AddWidgetDialog } from "./widgets/AddWidgetDialog";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
@@ -65,6 +63,7 @@ export function Server({
DEFAULT_STATS_CONFIG.widgets,
);
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
+ const [showAddWidgetDialog, setShowAddWidgetDialog] = React.useState(false);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
@@ -121,6 +120,28 @@ export function Server({
setHasUnsavedChanges(true);
};
+ const handleAddWidget = (widgetType: string) => {
+ const existingIds = widgets.map((w) => w.id);
+ const newId = generateWidgetId(widgetType as any, existingIds);
+ const config = getWidgetConfig(widgetType as any);
+
+ // 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,
+ x: 0,
+ y: maxY,
+ w: config.defaultSize.w,
+ h: config.defaultSize.h,
+ };
+
+ setWidgets((prev) => [...prev, newWidget]);
+ setHasUnsavedChanges(true);
+ toast.success(`${config.label} widget added`);
+ };
+
const handleSaveLayout = async () => {
if (!currentHostConfig?.id) {
toast.error(t("serverStats.failedToSaveLayout"));
@@ -146,188 +167,35 @@ export function Server({
};
const renderWidget = (widget: Widget) => {
- const config = WIDGET_TYPE_CONFIG[widget.type];
-
switch (widget.type) {
case "cpu":
return (
-
- {isEditMode && (
-
- )}
-
-
-
- {config.label}
-
-
-
-
-
- {(() => {
- 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}`;
- })()}
-
-
-
-
- {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"}
-
-
-
+
);
case "memory":
return (
-
- {isEditMode && (
-
- )}
-
-
-
- {config.label}
-
-
-
-
-
- {(() => {
- 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})`;
- })()}
-
-
-
-
- {(() => {
- 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`;
- })()}
-
-
-
+
);
case "disk":
return (
-
- {isEditMode && (
-
- )}
-
-
-
- {config.label}
-
-
-
-
-
- {(() => {
- 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})`;
- })()}
-
-
-
-
- {(() => {
- const available = metrics?.disk?.availableHuman;
- return available
- ? `Available: ${available}`
- : "Available: N/A";
- })()}
-
-
-
+
);
default:
@@ -473,250 +341,269 @@ export function Server({
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
return (
-
-
-
-
-
-
- {currentHostConfig?.folder} / {title}
-
+ <>
+
w.type)}
+ />
+
+
+
+
+
+
+ {currentHostConfig?.folder} / {title}
+
+
+
+
+
-
-
-
-
-
-
- {currentHostConfig?.enableFileManager && (
+
- )}
-
-
-
-
- {showStatsUI && (
-
- {/* Toolbar */}
-
-
- {!isEditMode ? (
-
+ {isRefreshing ? (
+
+
+ {t("serverStats.refreshing")}
+
) : (
- <>
+ t("serverStats.refreshStatus")
+ )}
+
+ {currentHostConfig?.enableFileManager && (
+
+ )}
+
+
+
+
+ {showStatsUI && (
+
+ {/* Toolbar */}
+
+
+ {!isEditMode ? (
-
- >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ {hasUnsavedChanges && (
+
+ {t("serverStats.unsavedChanges")}
+
)}
- {hasUnsavedChanges && (
-
- {t("serverStats.unsavedChanges")}
-
+
+ {isLoadingMetrics && !metrics ? (
+
+
+
+
+ {t("serverStats.loadingMetrics")}
+
+
+
+ ) : !metrics && serverStatus === "offline" ? (
+
+
+
+
+ {t("serverStats.serverOffline")}
+
+
+ {t("serverStats.cannotFetchMetrics")}
+
+
+
+ ) : (
+
({
+ 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) => (
+
+ {renderWidget(widget)}
+
+ ))}
+
)}
-
- {isLoadingMetrics && !metrics ? (
-
-
-
-
- {t("serverStats.loadingMetrics")}
-
-
-
- ) : !metrics && serverStatus === "offline" ? (
-
-
-
-
- {t("serverStats.serverOffline")}
-
-
- {t("serverStats.cannotFetchMetrics")}
-
-
-
- ) : (
-
({
- 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) => (
-
- {renderWidget(widget)}
-
- ))}
-
- )}
-
- )}
-
- {/* SSH Tunnels */}
- {currentHostConfig?.tunnelConnections &&
- currentHostConfig.tunnelConnections.length > 0 && (
-
-
-
)}
-
- {t("serverStats.feedbackMessage")}{" "}
-
- GitHub
-
- !
-
+ {/* SSH Tunnels */}
+ {currentHostConfig?.tunnelConnections &&
+ currentHostConfig.tunnelConnections.length > 0 && (
+
+
+
+ )}
+
+
+ {t("serverStats.feedbackMessage")}{" "}
+
+ GitHub
+
+ !
+
+
-
+ >
);
}
diff --git a/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx b/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx
new file mode 100644
index 00000000..1001fec8
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { getAvailableWidgets, type WidgetRegistryItem } from "./registry";
+import { Plus, X } from "lucide-react";
+
+interface AddWidgetDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onAddWidget: (widgetType: string) => void;
+ existingWidgetTypes: string[];
+}
+
+export function AddWidgetDialog({
+ open,
+ onOpenChange,
+ onAddWidget,
+ existingWidgetTypes,
+}: AddWidgetDialogProps) {
+ const availableWidgets = getAvailableWidgets();
+
+ if (!open) return null;
+
+ return (
+
+
onOpenChange(false)}
+ />
+
+
+
Add Widget
+
+
+
+ Choose a widget to add to your dashboard
+
+
+ {availableWidgets.map((widget: WidgetRegistryItem) => {
+ const Icon = widget.icon;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx
new file mode 100644
index 00000000..7d8b48f0
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { Cpu, X } from "lucide-react";
+import { Progress } from "@/components/ui/progress.tsx";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface CpuWidgetProps {
+ metrics: ServerMetrics | null;
+ isEditMode: boolean;
+ widgetId: string;
+ onDelete: (widgetId: string, e: React.MouseEvent
) => void;
+}
+
+export function CpuWidget({
+ metrics,
+ isEditMode,
+ widgetId,
+ onDelete,
+}: CpuWidgetProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {isEditMode && (
+
+ )}
+
+
+
CPU Usage
+
+
+
+
+ {(() => {
+ 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}`;
+ })()}
+
+
+
+
+ {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"}
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx
new file mode 100644
index 00000000..6c360762
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { HardDrive, X } from "lucide-react";
+import { Progress } from "@/components/ui/progress.tsx";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface DiskWidgetProps {
+ metrics: ServerMetrics | null;
+ isEditMode: boolean;
+ widgetId: string;
+ onDelete: (widgetId: string, e: React.MouseEvent) => void;
+}
+
+export function DiskWidget({
+ metrics,
+ isEditMode,
+ widgetId,
+ onDelete,
+}: DiskWidgetProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {isEditMode && (
+
+ )}
+
+
+
Disk Usage
+
+
+
+
+ {(() => {
+ 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})`;
+ })()}
+
+
+
+
+ {(() => {
+ const available = metrics?.disk?.availableHuman;
+ return available ? `Available: ${available}` : "Available: N/A";
+ })()}
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx
new file mode 100644
index 00000000..b00e98de
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import { MemoryStick, X } from "lucide-react";
+import { Progress } from "@/components/ui/progress.tsx";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface MemoryWidgetProps {
+ metrics: ServerMetrics | null;
+ isEditMode: boolean;
+ widgetId: string;
+ onDelete: (widgetId: string, e: React.MouseEvent) => void;
+}
+
+export function MemoryWidget({
+ metrics,
+ isEditMode,
+ widgetId,
+ onDelete,
+}: MemoryWidgetProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {isEditMode && (
+
+ )}
+
+
+
Memory Usage
+
+
+
+
+ {(() => {
+ 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})`;
+ })()}
+
+
+
+
+ {(() => {
+ 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`;
+ })()}
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/index.ts b/src/ui/Desktop/Apps/Server/widgets/index.ts
new file mode 100644
index 00000000..55e897ff
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/index.ts
@@ -0,0 +1,10 @@
+export { CpuWidget } from "./CpuWidget";
+export { MemoryWidget } from "./MemoryWidget";
+export { DiskWidget } from "./DiskWidget";
+export {
+ WIDGET_REGISTRY,
+ getAvailableWidgets,
+ getWidgetConfig,
+ generateWidgetId,
+ type WidgetRegistryItem,
+} from "./registry";
diff --git a/src/ui/Desktop/Apps/Server/widgets/registry.ts b/src/ui/Desktop/Apps/Server/widgets/registry.ts
new file mode 100644
index 00000000..3f41e990
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/registry.ts
@@ -0,0 +1,76 @@
+import { Cpu, MemoryStick, HardDrive, type LucideIcon } from "lucide-react";
+import type { WidgetType } from "@/types/stats-widgets";
+
+export interface WidgetRegistryItem {
+ type: WidgetType;
+ label: string;
+ description: string;
+ icon: LucideIcon;
+ iconColor: string;
+ defaultSize: { w: number; h: number };
+ minSize: { w: number; h: number };
+ maxSize: { w: number; h: number };
+}
+
+export const WIDGET_REGISTRY: Record = {
+ cpu: {
+ type: "cpu",
+ label: "CPU Usage",
+ description: "Monitor CPU utilization and load average",
+ icon: Cpu,
+ iconColor: "text-blue-400",
+ defaultSize: { w: 4, h: 2 },
+ 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",
+ defaultSize: { w: 4, h: 2 },
+ 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",
+ defaultSize: { w: 4, h: 2 },
+ 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];
+}
+
+/**
+ * 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;
+}