feat: refactor server stats widgets into modular structure
Created dedicated widgets directory with individual components: - CpuWidget, MemoryWidget, DiskWidget as separate components - Widget registry for centralized widget configuration - AddWidgetDialog for user-friendly widget selection - Updated Server.tsx to use modular widget system Benefits: - Better code organization and maintainability - Easier to add new widget types in the future - Centralized widget metadata and configuration - User can now add widgets via dialog interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1161,6 +1161,7 @@
|
|||||||
"available": "Available",
|
"available": "Available",
|
||||||
"editLayout": "Edit Layout",
|
"editLayout": "Edit Layout",
|
||||||
"cancelEdit": "Cancel",
|
"cancelEdit": "Cancel",
|
||||||
|
"addWidget": "Add Widget",
|
||||||
"saveLayout": "Save Layout",
|
"saveLayout": "Save Layout",
|
||||||
"unsavedChanges": "Unsaved changes",
|
"unsavedChanges": "Unsaved changes",
|
||||||
"layoutSaved": "Layout saved successfully",
|
"layoutSaved": "Layout saved successfully",
|
||||||
|
|||||||
@@ -1141,6 +1141,7 @@
|
|||||||
"available": "可用",
|
"available": "可用",
|
||||||
"editLayout": "编辑布局",
|
"editLayout": "编辑布局",
|
||||||
"cancelEdit": "取消",
|
"cancelEdit": "取消",
|
||||||
|
"addWidget": "添加小组件",
|
||||||
"saveLayout": "保存布局",
|
"saveLayout": "保存布局",
|
||||||
"unsavedChanges": "有未保存的更改",
|
"unsavedChanges": "有未保存的更改",
|
||||||
"layoutSaved": "布局保存成功",
|
"layoutSaved": "布局保存成功",
|
||||||
|
|||||||
@@ -3,16 +3,7 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
|
|||||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Progress } from "@/components/ui/progress.tsx";
|
import { Edit3, Plus, Save } from "lucide-react";
|
||||||
import {
|
|
||||||
Cpu,
|
|
||||||
HardDrive,
|
|
||||||
MemoryStick,
|
|
||||||
Edit3,
|
|
||||||
Plus,
|
|
||||||
Save,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||||
import {
|
import {
|
||||||
getServerStatusById,
|
getServerStatusById,
|
||||||
@@ -27,8 +18,15 @@ import {
|
|||||||
type Widget,
|
type Widget,
|
||||||
type StatsConfig,
|
type StatsConfig,
|
||||||
DEFAULT_STATS_CONFIG,
|
DEFAULT_STATS_CONFIG,
|
||||||
WIDGET_TYPE_CONFIG,
|
|
||||||
} from "@/types/stats-widgets";
|
} 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-grid-layout/css/styles.css";
|
||||||
import "react-resizable/css/styles.css";
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
@@ -65,6 +63,7 @@ export function Server({
|
|||||||
DEFAULT_STATS_CONFIG.widgets,
|
DEFAULT_STATS_CONFIG.widgets,
|
||||||
);
|
);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
|
||||||
|
const [showAddWidgetDialog, setShowAddWidgetDialog] = React.useState(false);
|
||||||
|
|
||||||
const statsConfig = React.useMemo((): StatsConfig => {
|
const statsConfig = React.useMemo((): StatsConfig => {
|
||||||
if (!currentHostConfig?.statsConfig) {
|
if (!currentHostConfig?.statsConfig) {
|
||||||
@@ -121,6 +120,28 @@ export function Server({
|
|||||||
setHasUnsavedChanges(true);
|
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 () => {
|
const handleSaveLayout = async () => {
|
||||||
if (!currentHostConfig?.id) {
|
if (!currentHostConfig?.id) {
|
||||||
toast.error(t("serverStats.failedToSaveLayout"));
|
toast.error(t("serverStats.failedToSaveLayout"));
|
||||||
@@ -146,188 +167,35 @@ export function Server({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderWidget = (widget: Widget) => {
|
const renderWidget = (widget: Widget) => {
|
||||||
const config = WIDGET_TYPE_CONFIG[widget.type];
|
|
||||||
|
|
||||||
switch (widget.type) {
|
switch (widget.type) {
|
||||||
case "cpu":
|
case "cpu":
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
<CpuWidget
|
||||||
{isEditMode && (
|
metrics={metrics}
|
||||||
<button
|
isEditMode={isEditMode}
|
||||||
onClick={(e) => handleDeleteWidget(widget.id, e)}
|
widgetId={widget.id}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onDelete={handleDeleteWidget}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
/>
|
||||||
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
|
||||||
>
|
|
||||||
<Cpu className="h-5 w-5 text-blue-400" />
|
|
||||||
<h3 className="font-semibold text-lg text-white">
|
|
||||||
{config.label}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
{(() => {
|
|
||||||
const pct = metrics?.cpu?.percent;
|
|
||||||
const cores = metrics?.cpu?.cores;
|
|
||||||
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
|
|
||||||
const coresText =
|
|
||||||
typeof cores === "number"
|
|
||||||
? t("serverStats.cpuCores", { count: cores })
|
|
||||||
: t("serverStats.naCpus");
|
|
||||||
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Progress
|
|
||||||
value={
|
|
||||||
typeof metrics?.cpu?.percent === "number"
|
|
||||||
? metrics!.cpu!.percent!
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{metrics?.cpu?.load
|
|
||||||
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
|
||||||
: "Load: N/A"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case "memory":
|
case "memory":
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
<MemoryWidget
|
||||||
{isEditMode && (
|
metrics={metrics}
|
||||||
<button
|
isEditMode={isEditMode}
|
||||||
onClick={(e) => handleDeleteWidget(widget.id, e)}
|
widgetId={widget.id}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onDelete={handleDeleteWidget}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
/>
|
||||||
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
|
||||||
>
|
|
||||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
|
||||||
<h3 className="font-semibold text-lg text-white">
|
|
||||||
{config.label}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
{(() => {
|
|
||||||
const pct = metrics?.memory?.percent;
|
|
||||||
const used = metrics?.memory?.usedGiB;
|
|
||||||
const total = metrics?.memory?.totalGiB;
|
|
||||||
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
|
|
||||||
const usedText =
|
|
||||||
typeof used === "number"
|
|
||||||
? `${used.toFixed(1)} GiB`
|
|
||||||
: "N/A";
|
|
||||||
const totalText =
|
|
||||||
typeof total === "number"
|
|
||||||
? `${total.toFixed(1)} GiB`
|
|
||||||
: "N/A";
|
|
||||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Progress
|
|
||||||
value={
|
|
||||||
typeof metrics?.memory?.percent === "number"
|
|
||||||
? metrics!.memory!.percent!
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{(() => {
|
|
||||||
const used = metrics?.memory?.usedGiB;
|
|
||||||
const total = metrics?.memory?.totalGiB;
|
|
||||||
const free =
|
|
||||||
typeof used === "number" && typeof total === "number"
|
|
||||||
? (total - used).toFixed(1)
|
|
||||||
: "N/A";
|
|
||||||
return `Free: ${free} GiB`;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case "disk":
|
case "disk":
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
<DiskWidget
|
||||||
{isEditMode && (
|
metrics={metrics}
|
||||||
<button
|
isEditMode={isEditMode}
|
||||||
onClick={(e) => handleDeleteWidget(widget.id, e)}
|
widgetId={widget.id}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onDelete={handleDeleteWidget}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
/>
|
||||||
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
|
||||||
>
|
|
||||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
|
||||||
<h3 className="font-semibold text-lg text-white">
|
|
||||||
{config.label}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
{(() => {
|
|
||||||
const pct = metrics?.disk?.percent;
|
|
||||||
const used = metrics?.disk?.usedHuman;
|
|
||||||
const total = metrics?.disk?.totalHuman;
|
|
||||||
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
|
|
||||||
const usedText = used ?? "N/A";
|
|
||||||
const totalText = total ?? "N/A";
|
|
||||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Progress
|
|
||||||
value={
|
|
||||||
typeof metrics?.disk?.percent === "number"
|
|
||||||
? metrics!.disk!.percent!
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{(() => {
|
|
||||||
const available = metrics?.disk?.availableHuman;
|
|
||||||
return available
|
|
||||||
? `Available: ${available}`
|
|
||||||
: "Available: N/A";
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -473,250 +341,269 @@ export function Server({
|
|||||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
<>
|
||||||
<div className="h-full w-full flex flex-col">
|
<AddWidgetDialog
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
open={showAddWidgetDialog}
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
onOpenChange={setShowAddWidgetDialog}
|
||||||
<div className="min-w-0">
|
onAddWidget={handleAddWidget}
|
||||||
<h1 className="font-bold text-lg truncate">
|
existingWidgetTypes={widgets.map((w) => w.type)}
|
||||||
{currentHostConfig?.folder} / {title}
|
/>
|
||||||
</h1>
|
<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>
|
||||||
<Status
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-semibold"
|
disabled={isRefreshing}
|
||||||
disabled={isFileManagerAlreadyOpen}
|
onClick={async () => {
|
||||||
title={
|
if (currentHostConfig?.id) {
|
||||||
isFileManagerAlreadyOpen
|
try {
|
||||||
? t("serverStats.fileManagerAlreadyOpen")
|
setIsRefreshing(true);
|
||||||
: t("serverStats.openFileManager")
|
const res = await getServerStatusById(
|
||||||
}
|
currentHostConfig.id,
|
||||||
onClick={() => {
|
);
|
||||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
setServerStatus(
|
||||||
const titleBase =
|
res?.status === "online" ? "online" : "offline",
|
||||||
currentHostConfig?.name &&
|
);
|
||||||
currentHostConfig.name.trim() !== ""
|
const data = await getServerMetricsById(
|
||||||
? currentHostConfig.name.trim()
|
currentHostConfig.id,
|
||||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
);
|
||||||
addTab({
|
setMetrics(data);
|
||||||
type: "file_manager",
|
setShowStatsUI(true);
|
||||||
title: titleBase,
|
} catch (error: any) {
|
||||||
hostConfig: currentHostConfig,
|
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")}
|
||||||
>
|
>
|
||||||
{t("nav.fileManager")}
|
{isRefreshing ? (
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
{t("serverStats.refreshing")}
|
||||||
</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">
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center justify-between mb-4 gap-2">
|
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
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">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between mb-4 gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isEditMode ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => setIsEditMode(true)}
|
||||||
setIsEditMode(false);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
setWidgets(statsConfig.widgets);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{t("serverStats.cancelEdit")}
|
<Edit3 className="h-4 w-4" />
|
||||||
|
{t("serverStats.editLayout")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
size="sm"
|
<>
|
||||||
onClick={handleSaveLayout}
|
<Button
|
||||||
disabled={!hasUnsavedChanges}
|
variant="outline"
|
||||||
className="flex items-center gap-2 bg-blue-500 hover:bg-blue-600"
|
size="sm"
|
||||||
>
|
onClick={() => {
|
||||||
<Save className="h-4 w-4" />
|
setIsEditMode(false);
|
||||||
{t("serverStats.saveLayout")}
|
setHasUnsavedChanges(false);
|
||||||
</Button>
|
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>
|
</div>
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<span className="text-sm text-yellow-400">
|
{isLoadingMetrics && !metrics ? (
|
||||||
{t("serverStats.unsavedChanges")}
|
<div className="flex items-center justify-center py-8">
|
||||||
</span>
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</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">
|
{/* SSH Tunnels */}
|
||||||
{t("serverStats.feedbackMessage")}{" "}
|
{currentHostConfig?.tunnelConnections &&
|
||||||
<a
|
currentHostConfig.tunnelConnections.length > 0 && (
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
<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">
|
||||||
target="_blank"
|
<Tunnel
|
||||||
rel="noopener noreferrer"
|
filterHostKey={
|
||||||
className="text-blue-500 hover:underline"
|
currentHostConfig?.name &&
|
||||||
>
|
currentHostConfig.name.trim() !== ""
|
||||||
GitHub
|
? currentHostConfig.name
|
||||||
</a>
|
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||||
!
|
}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx
Normal file
72
src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<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 to add to your dashboard
|
||||||
|
</p>
|
||||||
|
<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);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx
Normal file
74
src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx
Normal file
@@ -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<HTMLButtonElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CpuWidget({
|
||||||
|
metrics,
|
||||||
|
isEditMode,
|
||||||
|
widgetId,
|
||||||
|
onDelete,
|
||||||
|
}: CpuWidgetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => onDelete(widgetId, e)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
||||||
|
>
|
||||||
|
<Cpu className="h-5 w-5 text-blue-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">CPU Usage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.cpu?.percent;
|
||||||
|
const cores = metrics?.cpu?.cores;
|
||||||
|
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
|
||||||
|
const coresText =
|
||||||
|
typeof cores === "number"
|
||||||
|
? t("serverStats.cpuCores", { count: cores })
|
||||||
|
: t("serverStats.naCpus");
|
||||||
|
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
typeof metrics?.cpu?.percent === "number"
|
||||||
|
? metrics!.cpu!.percent!
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{metrics?.cpu?.load
|
||||||
|
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||||
|
: "Load: N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx
Normal file
74
src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx
Normal file
@@ -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<HTMLButtonElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiskWidget({
|
||||||
|
metrics,
|
||||||
|
isEditMode,
|
||||||
|
widgetId,
|
||||||
|
onDelete,
|
||||||
|
}: DiskWidgetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => onDelete(widgetId, e)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
||||||
|
>
|
||||||
|
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">Disk Usage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.disk?.percent;
|
||||||
|
const used = metrics?.disk?.usedHuman;
|
||||||
|
const total = metrics?.disk?.totalHuman;
|
||||||
|
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
|
||||||
|
const usedText = used ?? "N/A";
|
||||||
|
const totalText = total ?? "N/A";
|
||||||
|
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
typeof metrics?.disk?.percent === "number"
|
||||||
|
? metrics!.disk!.percent!
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const available = metrics?.disk?.availableHuman;
|
||||||
|
return available ? `Available: ${available}` : "Available: N/A";
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx
Normal file
81
src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx
Normal file
@@ -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<HTMLButtonElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryWidget({
|
||||||
|
metrics,
|
||||||
|
isEditMode,
|
||||||
|
widgetId,
|
||||||
|
onDelete,
|
||||||
|
}: MemoryWidgetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => onDelete(widgetId, e)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="absolute top-2 right-2 z-[9999] w-7 h-7 bg-red-500/90 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
||||||
|
>
|
||||||
|
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">Memory Usage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.memory?.percent;
|
||||||
|
const used = metrics?.memory?.usedGiB;
|
||||||
|
const total = metrics?.memory?.totalGiB;
|
||||||
|
const pctText = typeof pct === "number" ? `${pct}%` : "N/A";
|
||||||
|
const usedText =
|
||||||
|
typeof used === "number" ? `${used.toFixed(1)} GiB` : "N/A";
|
||||||
|
const totalText =
|
||||||
|
typeof total === "number" ? `${total.toFixed(1)} GiB` : "N/A";
|
||||||
|
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
typeof metrics?.memory?.percent === "number"
|
||||||
|
? metrics!.memory!.percent!
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const used = metrics?.memory?.usedGiB;
|
||||||
|
const total = metrics?.memory?.totalGiB;
|
||||||
|
const free =
|
||||||
|
typeof used === "number" && typeof total === "number"
|
||||||
|
? (total - used).toFixed(1)
|
||||||
|
: "N/A";
|
||||||
|
return `Free: ${free} GiB`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/ui/Desktop/Apps/Server/widgets/index.ts
Normal file
10
src/ui/Desktop/Apps/Server/widgets/index.ts
Normal file
@@ -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";
|
||||||
76
src/ui/Desktop/Apps/Server/widgets/registry.ts
Normal file
76
src/ui/Desktop/Apps/Server/widgets/registry.ts
Normal file
@@ -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<WidgetType, WidgetRegistryItem> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user