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