feat: add draggable server stats dashboard with customizable widgets
This commit is contained in:
4076
package-lock.json
generated
4076
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@
|
|||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||||
"@uiw/react-codemirror": "^4.24.1",
|
"@uiw/react-codemirror": "^4.24.1",
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-grid-layout": "^1.5.2",
|
||||||
"react-h5-audio-player": "^3.10.1",
|
"react-h5-audio-player": "^3.10.1",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||||
default_path TEXT,
|
default_path TEXT,
|
||||||
|
stats_config TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
@@ -373,6 +374,7 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||||
|
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
defaultPath: text("default_path"),
|
defaultPath: text("default_path"),
|
||||||
|
statsConfig: text("stats_config"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -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 };
|
const result = { ...host };
|
||||||
if (host.key_password !== undefined) {
|
if (host.key_password !== undefined) {
|
||||||
if (result.keyPassword === undefined) {
|
if (result.keyPassword === undefined) {
|
||||||
|
|||||||
@@ -706,7 +706,24 @@
|
|||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnel": "Tunnel",
|
"tunnel": "Tunnel",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
|
"serverStats": "Server Stats",
|
||||||
"hostViewer": "Host Viewer",
|
"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\".",
|
"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",
|
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
|
||||||
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||||
@@ -1141,7 +1158,13 @@
|
|||||||
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
|
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
|
||||||
"load": "Load",
|
"load": "Load",
|
||||||
"free": "Free",
|
"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": {
|
"auth": {
|
||||||
"loginTitle": "Login to Termix",
|
"loginTitle": "Login to Termix",
|
||||||
|
|||||||
@@ -728,6 +728,24 @@
|
|||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"tunnel": "隧道",
|
"tunnel": "隧道",
|
||||||
"fileManager": "文件管理器",
|
"fileManager": "文件管理器",
|
||||||
|
"serverStats": "服务器统计",
|
||||||
|
"hostViewer": "主机查看器",
|
||||||
|
"enableServerStats": "启用服务器统计",
|
||||||
|
"enableServerStatsDesc": "启用/禁用此主机的服务器统计信息收集",
|
||||||
|
"displayItems": "显示项目",
|
||||||
|
"displayItemsDesc": "选择在服务器统计页面上显示哪些指标",
|
||||||
|
"enableCpu": "CPU使用率",
|
||||||
|
"enableMemory": "内存使用率",
|
||||||
|
"enableDisk": "磁盘使用率",
|
||||||
|
"enableNetwork": "网络统计(即将推出)",
|
||||||
|
"enableProcesses": "进程数(即将推出)",
|
||||||
|
"enableUptime": "运行时间(即将推出)",
|
||||||
|
"enableHostname": "主机名(即将推出)",
|
||||||
|
"enableOs": "操作系统(即将推出)",
|
||||||
|
"customCommands": "自定义命令(即将推出)",
|
||||||
|
"customCommandsDesc": "为此服务器定义自定义关机和重启命令",
|
||||||
|
"shutdownCommand": "关机命令",
|
||||||
|
"rebootCommand": "重启命令",
|
||||||
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
||||||
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
||||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||||
@@ -1118,7 +1136,15 @@
|
|||||||
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
||||||
"totpRequired": "需要 TOTP 认证",
|
"totpRequired": "需要 TOTP 认证",
|
||||||
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
|
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
|
||||||
"load": "负载"
|
"load": "负载",
|
||||||
|
"free": "空闲",
|
||||||
|
"available": "可用",
|
||||||
|
"editLayout": "编辑布局",
|
||||||
|
"cancelEdit": "取消",
|
||||||
|
"saveLayout": "保存布局",
|
||||||
|
"unsavedChanges": "有未保存的更改",
|
||||||
|
"layoutSaved": "布局保存成功",
|
||||||
|
"failedToSaveLayout": "保存布局失败"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"loginTitle": "登录 Termix",
|
"loginTitle": "登录 Termix",
|
||||||
|
|||||||
50
src/types/stats-widgets.ts
Normal file
50
src/types/stats-widgets.ts
Normal 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;
|
||||||
@@ -210,6 +210,16 @@ export function HostManagerEditor({
|
|||||||
.default([]),
|
.default([]),
|
||||||
enableFileManager: z.boolean().default(true),
|
enableFileManager: z.boolean().default(true),
|
||||||
defaultPath: z.string().optional(),
|
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) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.authType === "password") {
|
if (data.authType === "password") {
|
||||||
@@ -292,6 +302,14 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: true,
|
enableFileManager: true,
|
||||||
defaultPath: "/",
|
defaultPath: "/",
|
||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
|
statsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
displayItems: {
|
||||||
|
cpu: true,
|
||||||
|
memory: true,
|
||||||
|
disk: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,6 +366,14 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||||
defaultPath: cleanedHost.defaultPath || "/",
|
defaultPath: cleanedHost.defaultPath || "/",
|
||||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
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") {
|
if (defaultAuthType === "password") {
|
||||||
@@ -383,6 +409,10 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: true,
|
enableFileManager: true,
|
||||||
defaultPath: "/",
|
defaultPath: "/",
|
||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
|
statsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
displayItems: { cpu: true, memory: true, disk: true },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
form.reset(defaultFormData);
|
form.reset(defaultFormData);
|
||||||
@@ -421,6 +451,12 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: Boolean(data.enableFileManager),
|
enableFileManager: Boolean(data.enableFileManager),
|
||||||
defaultPath: data.defaultPath || "/",
|
defaultPath: data.defaultPath || "/",
|
||||||
tunnelConnections: data.tunnelConnections || [],
|
tunnelConnections: data.tunnelConnections || [],
|
||||||
|
statsConfig: JSON.stringify(
|
||||||
|
data.statsConfig || {
|
||||||
|
enabled: true,
|
||||||
|
displayItems: { cpu: true, memory: true, disk: true },
|
||||||
|
},
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
submitData.credentialId = null;
|
submitData.credentialId = null;
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ 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 { 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 { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||||
import {
|
import {
|
||||||
getServerStatusById,
|
getServerStatusById,
|
||||||
@@ -14,6 +22,17 @@ import {
|
|||||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
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 {
|
interface ServerProps {
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
@@ -41,11 +60,259 @@ export function Server({
|
|||||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
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(() => {
|
React.useEffect(() => {
|
||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
}, [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(() => {
|
React.useEffect(() => {
|
||||||
const fetchLatestHostConfig = async () => {
|
const fetchLatestHostConfig = async () => {
|
||||||
if (hostConfig?.id) {
|
if (hostConfig?.id) {
|
||||||
@@ -123,8 +390,11 @@ export function Server({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
if (error?.code === "TOTP_REQUIRED" ||
|
if (
|
||||||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
|
error?.code === "TOTP_REQUIRED" ||
|
||||||
|
(error?.response?.status === 403 &&
|
||||||
|
error?.response?.data?.error === "TOTP_REQUIRED")
|
||||||
|
) {
|
||||||
toast.error(t("serverStats.totpUnavailable"));
|
toast.error(t("serverStats.totpUnavailable"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||||
@@ -215,20 +485,32 @@ export function Server({
|
|||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
setShowStatsUI(true);
|
setShowStatsUI(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.code === "TOTP_REQUIRED" ||
|
if (
|
||||||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
|
error?.code === "TOTP_REQUIRED" ||
|
||||||
|
(error?.response?.status === 403 &&
|
||||||
|
error?.response?.data?.error === "TOTP_REQUIRED")
|
||||||
|
) {
|
||||||
toast.error(t("serverStats.totpUnavailable"));
|
toast.error(t("serverStats.totpUnavailable"));
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
} else if (error?.response?.status === 503 || error?.status === 503) {
|
} else if (
|
||||||
|
error?.response?.status === 503 ||
|
||||||
|
error?.status === 503
|
||||||
|
) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
} else if (error?.response?.status === 504 || error?.status === 504) {
|
} else if (
|
||||||
|
error?.response?.status === 504 ||
|
||||||
|
error?.status === 504
|
||||||
|
) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
} else if (error?.response?.status === 404 || error?.status === 404) {
|
} else if (
|
||||||
|
error?.response?.status === 404 ||
|
||||||
|
error?.status === 404
|
||||||
|
) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
@@ -286,6 +568,52 @@ export function Server({
|
|||||||
|
|
||||||
{showStatsUI && (
|
{showStatsUI && (
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
<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 ? (
|
{isLoadingMetrics && !metrics ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -310,155 +638,31 @@ export function Server({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
<ResponsiveGridLayout
|
||||||
{/* CPU Stats */}
|
className="layout"
|
||||||
<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">
|
layouts={{
|
||||||
<div className="flex items-center gap-2 mb-3">
|
lg: widgets.map((w) => ({
|
||||||
<Cpu className="h-5 w-5 text-blue-400" />
|
i: w.id,
|
||||||
<h3 className="font-semibold text-lg text-white">
|
x: w.x,
|
||||||
{t("serverStats.cpuUsage")}
|
y: w.y,
|
||||||
</h3>
|
w: w.w,
|
||||||
</div>
|
h: w.h,
|
||||||
|
})),
|
||||||
<div className="space-y-2">
|
}}
|
||||||
<div className="flex justify-between items-center">
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
<span className="text-sm text-gray-300">
|
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||||
{(() => {
|
rowHeight={100}
|
||||||
const pct = metrics?.cpu?.percent;
|
isDraggable={isEditMode}
|
||||||
const cores = metrics?.cpu?.cores;
|
isResizable={isEditMode}
|
||||||
const pctText =
|
onLayoutChange={handleLayoutChange}
|
||||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
draggableHandle={isEditMode ? undefined : ".no-drag"}
|
||||||
const coresText =
|
>
|
||||||
typeof cores === "number"
|
{widgets.map((widget) => (
|
||||||
? t("serverStats.cpuCores", { count: cores })
|
<div key={widget.id} className="relative">
|
||||||
: t("serverStats.naCpus");
|
{renderWidget(widget)}
|
||||||
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>
|
</div>
|
||||||
|
))}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user