feat: add draggable server stats dashboard with customizable widgets

This commit is contained in:
ZacharyZcR
2025-10-09 08:59:45 +08:00
parent 7ddcb42f7f
commit 74f537ea0b
10 changed files with 576 additions and 4167 deletions

View File

@@ -210,6 +210,16 @@ export function HostManagerEditor({
.default([]),
enableFileManager: z.boolean().default(true),
defaultPath: z.string().optional(),
statsConfig: z
.object({
enabled: z.boolean().default(true),
displayItems: z.object({
cpu: z.boolean().default(true),
memory: z.boolean().default(true),
disk: z.boolean().default(true),
}),
})
.optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
@@ -292,6 +302,14 @@ export function HostManagerEditor({
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
statsConfig: {
enabled: true,
displayItems: {
cpu: true,
memory: true,
disk: true,
},
},
},
});
@@ -348,6 +366,14 @@ export function HostManagerEditor({
enableFileManager: Boolean(cleanedHost.enableFileManager),
defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [],
statsConfig: cleanedHost.statsConfig
? typeof cleanedHost.statsConfig === "string"
? JSON.parse(cleanedHost.statsConfig)
: cleanedHost.statsConfig
: {
enabled: true,
displayItems: { cpu: true, memory: true, disk: true },
},
};
if (defaultAuthType === "password") {
@@ -383,6 +409,10 @@ export function HostManagerEditor({
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
statsConfig: {
enabled: true,
displayItems: { cpu: true, memory: true, disk: true },
},
};
form.reset(defaultFormData);
@@ -421,6 +451,12 @@ export function HostManagerEditor({
enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [],
statsConfig: JSON.stringify(
data.statsConfig || {
enabled: true,
displayItems: { cpu: true, memory: true, disk: true },
},
),
};
submitData.credentialId = null;

View File

@@ -4,7 +4,15 @@ 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 } from "lucide-react";
import {
Cpu,
HardDrive,
MemoryStick,
Edit3,
Plus,
Save,
X,
} from "lucide-react";
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
import {
getServerStatusById,
@@ -14,6 +22,17 @@ 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 StatsConfig,
DEFAULT_STATS_CONFIG,
WIDGET_TYPE_CONFIG,
} from "@/types/stats-widgets";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const ResponsiveGridLayout = WidthProvider(Responsive);
interface ServerProps {
hostConfig?: any;
@@ -41,11 +60,259 @@ 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 statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return parsed?.widgets ? parsed : DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
}, [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) => {
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
setHasUnsavedChanges(true);
};
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);
toast.success(t("serverStats.layoutSaved"));
window.dispatchEvent(new Event("ssh-hosts:changed"));
} catch (error) {
toast.error(t("serverStats.failedToSaveLayout"));
}
};
const renderWidget = (widget: Widget) => {
const config = WIDGET_TYPE_CONFIG[widget.type];
switch (widget.type) {
case "cpu":
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={() => handleDeleteWidget(widget.id)}
className="absolute top-2 right-2 z-10 w-6 h-6 bg-red-500/80 hover:bg-red-500 text-white rounded-full flex items-center justify-center"
>
<X className="h-4 w-4" />
</button>
)}
<div className="flex items-center gap-2 mb-3">
<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":
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={() => handleDeleteWidget(widget.id)}
className="absolute top-2 right-2 z-10 w-6 h-6 bg-red-500/80 hover:bg-red-500 text-white rounded-full flex items-center justify-center"
>
<X className="h-4 w-4" />
</button>
)}
<div className="flex items-center gap-2 mb-3">
<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":
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={() => handleDeleteWidget(widget.id)}
className="absolute top-2 right-2 z-10 w-6 h-6 bg-red-500/80 hover:bg-red-500 text-white rounded-full flex items-center justify-center"
>
<X className="h-4 w-4" />
</button>
)}
<div className="flex items-center gap-2 mb-3">
<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:
return null;
}
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
@@ -123,8 +390,11 @@ export function Server({
if (!cancelled) {
setMetrics(null);
setShowStatsUI(false);
if (error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
if (
error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 &&
error?.response?.data?.error === "TOTP_REQUIRED")
) {
toast.error(t("serverStats.totpUnavailable"));
} else {
toast.error(t("serverStats.failedToFetchMetrics"));
@@ -215,20 +485,32 @@ export function Server({
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
if (error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
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) {
} else if (
error?.response?.status === 503 ||
error?.status === 503
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 504 || error?.status === 504) {
} else if (
error?.response?.status === 504 ||
error?.status === 504
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 404 || error?.status === 404) {
} else if (
error?.response?.status === 404 ||
error?.status === 404
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
@@ -286,6 +568,52 @@ export function Server({
{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>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditMode(false);
setHasUnsavedChanges(false);
setWidgets(statsConfig.widgets);
}}
className="flex items-center gap-2"
>
{t("serverStats.cancelEdit")}
</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">
@@ -310,155 +638,31 @@ export function Server({
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */}
<div className="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">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
<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 ? undefined : ".no-drag"}
>
{widgets.map((widget) => (
<div key={widget.id} className="relative">
{renderWidget(widget)}
</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>
{/* Memory Stats */}
<div className="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">
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</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>
{/* Disk Stats */}
<div className="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">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</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>
</div>
))}
</ResponsiveGridLayout>
)}
</div>
)}