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

@@ -166,6 +166,7 @@ async function initializeCompleteDatabase(): Promise<void> {
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
stats_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
@@ -373,6 +374,7 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

@@ -65,6 +65,7 @@ export const sshData = sqliteTable("ssh_data", {
.notNull()
.default(true),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),

View File

@@ -1239,13 +1239,6 @@ async function resolveHostCredentials(host: any): Promise<any> {
}
}
const result = { ...host };
if (host.key_password !== undefined) {
if (result.keyPassword === undefined) {
result.keyPassword = host.key_password;
}
delete result.key_password;
}
const result = { ...host };
if (host.key_password !== undefined) {
if (result.keyPassword === undefined) {

View File

@@ -706,7 +706,24 @@
"terminal": "Terminal",
"tunnel": "Tunnel",
"fileManager": "File Manager",
"serverStats": "Server Stats",
"hostViewer": "Host Viewer",
"enableServerStats": "Enable Server Stats",
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
"displayItems": "Display Items",
"displayItemsDesc": "Choose which metrics to display on the server stats page",
"enableCpu": "CPU Usage",
"enableMemory": "Memory Usage",
"enableDisk": "Disk Usage",
"enableNetwork": "Network Statistics (Coming Soon)",
"enableProcesses": "Process Count (Coming Soon)",
"enableUptime": "Uptime (Coming Soon)",
"enableHostname": "Hostname (Coming Soon)",
"enableOs": "Operating System (Coming Soon)",
"customCommands": "Custom Commands (Coming Soon)",
"customCommandsDesc": "Define custom shutdown and reboot commands for this server",
"shutdownCommand": "Shutdown Command",
"rebootCommand": "Reboot Command",
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".",
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
"failedToRemoveFromFolder": "Failed to remove host from folder",
@@ -1141,7 +1158,13 @@
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load",
"free": "Free",
"available": "Available"
"available": "Available",
"editLayout": "Edit Layout",
"cancelEdit": "Cancel",
"saveLayout": "Save Layout",
"unsavedChanges": "Unsaved changes",
"layoutSaved": "Layout saved successfully",
"failedToSaveLayout": "Failed to save layout"
},
"auth": {
"loginTitle": "Login to Termix",

View File

@@ -728,6 +728,24 @@
"terminal": "终端",
"tunnel": "隧道",
"fileManager": "文件管理器",
"serverStats": "服务器统计",
"hostViewer": "主机查看器",
"enableServerStats": "启用服务器统计",
"enableServerStatsDesc": "启用/禁用此主机的服务器统计信息收集",
"displayItems": "显示项目",
"displayItemsDesc": "选择在服务器统计页面上显示哪些指标",
"enableCpu": "CPU使用率",
"enableMemory": "内存使用率",
"enableDisk": "磁盘使用率",
"enableNetwork": "网络统计(即将推出)",
"enableProcesses": "进程数(即将推出)",
"enableUptime": "运行时间(即将推出)",
"enableHostname": "主机名(即将推出)",
"enableOs": "操作系统(即将推出)",
"customCommands": "自定义命令(即将推出)",
"customCommandsDesc": "为此服务器定义自定义关机和重启命令",
"shutdownCommand": "关机命令",
"rebootCommand": "重启命令",
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
@@ -1118,7 +1136,15 @@
"cannotFetchMetrics": "无法从离线服务器获取指标",
"totpRequired": "需要 TOTP 认证",
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
"load": "负载"
"load": "负载",
"free": "空闲",
"available": "可用",
"editLayout": "编辑布局",
"cancelEdit": "取消",
"saveLayout": "保存布局",
"unsavedChanges": "有未保存的更改",
"layoutSaved": "布局保存成功",
"failedToSaveLayout": "保存布局失败"
},
"auth": {
"loginTitle": "登录 Termix",

View File

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

View File

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