refactor: simplify server stats widget system
Replaced complex drag-and-drop grid layout with simple checkbox-based configuration and static responsive grid display. - Removed react-grid-layout dependency and 6 related packages - Simplified StatsConfig from complex Widget objects to simple array - Added Statistics tab in HostManagerEditor for checkbox selection - Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout - Simplified widget components by removing edit mode and size selection - Deleted unused AddWidgetDialog and registry files - Fixed statsConfig serialization in backend routes Net result: -787 lines of code, cleaner architecture.
This commit is contained in:
@@ -38,6 +38,9 @@ import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSele
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { StatsConfig, WidgetType } from "@/types/stats-widgets";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -58,6 +61,7 @@ interface SSHHost {
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
statsConfig?: StatsConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
credentialId?: number;
|
||||
@@ -210,6 +214,13 @@ export function HostManagerEditor({
|
||||
.default([]),
|
||||
enableFileManager: z.boolean().default(true),
|
||||
defaultPath: z.string().optional(),
|
||||
statsConfig: z
|
||||
.object({
|
||||
enabledWidgets: z
|
||||
.array(z.enum(["cpu", "memory", "disk"]))
|
||||
.default(["cpu", "memory", "disk"]),
|
||||
})
|
||||
.default({ enabledWidgets: ["cpu", "memory", "disk"] }),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "password") {
|
||||
@@ -292,6 +303,7 @@ export function HostManagerEditor({
|
||||
enableFileManager: true,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -348,6 +360,7 @@ export function HostManagerEditor({
|
||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||
defaultPath: cleanedHost.defaultPath || "/",
|
||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
||||
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
};
|
||||
|
||||
if (defaultAuthType === "password") {
|
||||
@@ -383,6 +396,7 @@ export function HostManagerEditor({
|
||||
enableFileManager: true,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
};
|
||||
|
||||
form.reset(defaultFormData);
|
||||
@@ -421,6 +435,7 @@ export function HostManagerEditor({
|
||||
enableFileManager: Boolean(data.enableFileManager),
|
||||
defaultPath: data.defaultPath || "/",
|
||||
tunnelConnections: data.tunnelConnections || [],
|
||||
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
};
|
||||
|
||||
submitData.credentialId = null;
|
||||
@@ -669,6 +684,9 @@ export function HostManagerEditor({
|
||||
<TabsTrigger value="file_manager">
|
||||
{t("hosts.fileManager")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="statistics">
|
||||
{t("hosts.statistics")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">
|
||||
@@ -1533,6 +1551,48 @@ export function HostManagerEditor({
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="statistics">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.enabledWidgets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enabledWidgets")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.enabledWidgetsDesc")}
|
||||
</FormDescription>
|
||||
<div className="space-y-3 mt-3">
|
||||
{(["cpu", "memory", "disk"] as const).map((widget) => (
|
||||
<div
|
||||
key={widget}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(widget)}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentWidgets = field.value || [];
|
||||
if (checked) {
|
||||
field.onChange([...currentWidgets, widget]);
|
||||
} else {
|
||||
field.onChange(
|
||||
currentWidgets.filter((w) => w !== widget),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{widget === "cpu" && t("serverStats.cpuUsage")}
|
||||
{widget === "memory" &&
|
||||
t("serverStats.memoryUsage")}
|
||||
{widget === "disk" && t("serverStats.diskUsage")}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Edit3, Plus, Save } from "lucide-react";
|
||||
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
@@ -13,25 +12,12 @@ 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 WidgetType,
|
||||
type StatsConfig,
|
||||
DEFAULT_STATS_CONFIG,
|
||||
} from "@/types/stats-widgets";
|
||||
import {
|
||||
CpuWidget,
|
||||
MemoryWidget,
|
||||
DiskWidget,
|
||||
generateWidgetId,
|
||||
getWidgetConfig,
|
||||
getWidgetSize,
|
||||
} from "./widgets";
|
||||
import { AddWidgetDialog } from "./widgets/AddWidgetDialog";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
import { CpuWidget, MemoryWidget, DiskWidget } from "./widgets";
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
@@ -62,182 +48,39 @@ 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 [showAddWidgetDialog, setShowAddWidgetDialog] = React.useState(false);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
const enabledWidgets = React.useMemo((): WidgetType[] => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
return DEFAULT_STATS_CONFIG.enabledWidgets;
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof currentHostConfig.statsConfig === "string"
|
||||
? JSON.parse(currentHostConfig.statsConfig)
|
||||
: currentHostConfig.statsConfig;
|
||||
return parsed?.widgets ? parsed : DEFAULT_STATS_CONFIG;
|
||||
return parsed?.enabledWidgets || DEFAULT_STATS_CONFIG.enabledWidgets;
|
||||
} catch (error) {
|
||||
console.error("Failed to parse statsConfig:", error);
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
return DEFAULT_STATS_CONFIG.enabledWidgets;
|
||||
}
|
||||
}, [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,
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleChangeWidgetSize = (
|
||||
widgetId: string,
|
||||
newSize: any,
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setWidgets((prev) =>
|
||||
prev.map((widget) => {
|
||||
if (widget.id === widgetId) {
|
||||
const sizeConfig = getWidgetSize(widget.type, newSize);
|
||||
return {
|
||||
...widget,
|
||||
size: newSize,
|
||||
w: sizeConfig.w,
|
||||
h: sizeConfig.h,
|
||||
};
|
||||
}
|
||||
return widget;
|
||||
}),
|
||||
);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleAddWidget = (widgetType: string, size: any) => {
|
||||
const existingIds = widgets.map((w) => w.id);
|
||||
const newId = generateWidgetId(widgetType as any, existingIds);
|
||||
const config = getWidgetConfig(widgetType as any);
|
||||
const sizeConfig = getWidgetSize(widgetType as any, size);
|
||||
|
||||
// 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,
|
||||
size: size,
|
||||
x: 0,
|
||||
y: maxY,
|
||||
w: sizeConfig.w,
|
||||
h: sizeConfig.h,
|
||||
};
|
||||
|
||||
setWidgets((prev) => [...prev, newWidget]);
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success(`${config.label} widget added`);
|
||||
};
|
||||
|
||||
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);
|
||||
setIsEditMode(false);
|
||||
toast.success(t("serverStats.layoutSaved"));
|
||||
window.dispatchEvent(new Event("ssh-hosts:changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to save layout:", error);
|
||||
toast.error(t("serverStats.failedToSaveLayout"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderWidget = (widget: Widget) => {
|
||||
switch (widget.type) {
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
case "cpu":
|
||||
return (
|
||||
<CpuWidget
|
||||
metrics={metrics}
|
||||
metricsHistory={metricsHistory}
|
||||
isEditMode={isEditMode}
|
||||
widgetId={widget.id}
|
||||
widgetSize={widget.size}
|
||||
onDelete={handleDeleteWidget}
|
||||
onChangeSize={handleChangeWidgetSize}
|
||||
/>
|
||||
);
|
||||
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
|
||||
|
||||
case "memory":
|
||||
return (
|
||||
<MemoryWidget
|
||||
metrics={metrics}
|
||||
metricsHistory={metricsHistory}
|
||||
isEditMode={isEditMode}
|
||||
widgetId={widget.id}
|
||||
widgetSize={widget.size}
|
||||
onDelete={handleDeleteWidget}
|
||||
onChangeSize={handleChangeWidgetSize}
|
||||
/>
|
||||
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "disk":
|
||||
return (
|
||||
<DiskWidget
|
||||
metrics={metrics}
|
||||
metricsHistory={metricsHistory}
|
||||
isEditMode={isEditMode}
|
||||
widgetId={widget.id}
|
||||
widgetSize={widget.size}
|
||||
onDelete={handleDeleteWidget}
|
||||
onChangeSize={handleChangeWidgetSize}
|
||||
/>
|
||||
);
|
||||
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -387,269 +230,186 @@ export function Server({
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddWidgetDialog
|
||||
open={showAddWidgetDialog}
|
||||
onOpenChange={setShowAddWidgetDialog}
|
||||
onAddWidget={handleAddWidget}
|
||||
existingWidgetTypes={widgets.map((w) => w.type)}
|
||||
/>
|
||||
<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 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
|
||||
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 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>
|
||||
<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 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">
|
||||
{!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
|
||||
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>
|
||||
|
||||
{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 className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</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>
|
||||
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">
|
||||
{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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="min-h-[200px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from "react";
|
||||
import { getAvailableWidgets, type WidgetRegistryItem } from "./registry";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import type { WidgetSize } from "@/types/stats-widgets";
|
||||
|
||||
interface AddWidgetDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAddWidget: (widgetType: string, size: WidgetSize) => void;
|
||||
existingWidgetTypes: string[];
|
||||
}
|
||||
|
||||
export function AddWidgetDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAddWidget,
|
||||
existingWidgetTypes,
|
||||
}: AddWidgetDialogProps) {
|
||||
const availableWidgets = getAvailableWidgets();
|
||||
const [selectedSize, setSelectedSize] = React.useState<WidgetSize>("medium");
|
||||
|
||||
const sizeLabels: Record<WidgetSize, string> = {
|
||||
small: "Small",
|
||||
medium: "Medium",
|
||||
large: "Large",
|
||||
};
|
||||
|
||||
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 and size to add to your dashboard
|
||||
</p>
|
||||
|
||||
{/* Size selector */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(["small", "medium", "large"] as WidgetSize[]).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg border transition-all ${
|
||||
selectedSize === size
|
||||
? "bg-blue-500 border-blue-500 text-white"
|
||||
: "bg-dark-bg-darker border-dark-border text-gray-400 hover:border-blue-500/50 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{sizeLabels[size]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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, selectedSize);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from "react";
|
||||
import { Cpu, X, Maximize2 } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import type { WidgetSize } from "@/types/stats-widgets";
|
||||
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
|
||||
const {
|
||||
LineChart,
|
||||
@@ -19,32 +17,11 @@ const {
|
||||
interface CpuWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
isEditMode: boolean;
|
||||
widgetId: string;
|
||||
widgetSize: WidgetSize;
|
||||
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onChangeSize: (
|
||||
widgetId: string,
|
||||
newSize: WidgetSize,
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function CpuWidget({
|
||||
metrics,
|
||||
metricsHistory,
|
||||
isEditMode,
|
||||
widgetId,
|
||||
widgetSize,
|
||||
onDelete,
|
||||
onChangeSize,
|
||||
}: CpuWidgetProps) {
|
||||
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sizeOrder: WidgetSize[] = ["small", "medium", "large"];
|
||||
const nextSize =
|
||||
sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length];
|
||||
|
||||
// Prepare chart data
|
||||
const chartData = React.useMemo(() => {
|
||||
return metricsHistory.map((m, index) => ({
|
||||
@@ -55,141 +32,65 @@ export function CpuWidget({
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
{isEditMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
||||
type="button"
|
||||
title={`Change to ${nextSize}`}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<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 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">CPU Usage</h3>
|
||||
</div>
|
||||
|
||||
{widgetSize === "small" && (
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="text-4xl font-bold text-blue-400">
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{typeof metrics?.cpu?.percent === "number"
|
||||
? `${metrics.cpu.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{typeof metrics?.cpu?.cores === "number"
|
||||
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
|
||||
: t("serverStats.naCpus")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgetSize === "medium" && (
|
||||
<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 className="text-xs text-gray-500 flex-shrink-0">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{widgetSize === "large" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{typeof metrics?.cpu?.percent === "number"
|
||||
? `${metrics.cpu.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{typeof metrics?.cpu?.cores === "number"
|
||||
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
|
||||
: t("serverStats.naCpus")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex-shrink-0">
|
||||
{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 className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1f2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, "CPU"]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={300}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1f2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, "CPU"]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={300}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from "react";
|
||||
import { HardDrive, X, Maximize2 } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import type { WidgetSize } from "@/types/stats-widgets";
|
||||
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
|
||||
const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
|
||||
RechartsPrimitive;
|
||||
@@ -12,32 +10,11 @@ const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
|
||||
interface DiskWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
isEditMode: boolean;
|
||||
widgetId: string;
|
||||
widgetSize: WidgetSize;
|
||||
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonButton>) => void;
|
||||
onChangeSize: (
|
||||
widgetId: string,
|
||||
newSize: WidgetSize,
|
||||
e: React.MouseEvent<HTMLButtonButton>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DiskWidget({
|
||||
metrics,
|
||||
metricsHistory,
|
||||
isEditMode,
|
||||
widgetId,
|
||||
widgetSize,
|
||||
onDelete,
|
||||
onChangeSize,
|
||||
}: DiskWidgetProps) {
|
||||
export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sizeOrder: WidgetSize[] = ["small", "medium", "large"];
|
||||
const nextSize =
|
||||
sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length];
|
||||
|
||||
// Prepare radial chart data
|
||||
const radialData = React.useMemo(() => {
|
||||
const percent = metrics?.disk?.percent || 0;
|
||||
@@ -52,44 +29,51 @@ export function DiskWidget({
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
{isEditMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
||||
type="button"
|
||||
title={`Change to ${nextSize}`}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<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 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">Disk Usage</h3>
|
||||
</div>
|
||||
|
||||
{widgetSize === "small" && (
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="text-4xl font-bold text-orange-400">
|
||||
{typeof metrics?.disk?.percent === "number"
|
||||
? `${metrics.disk.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="90%"
|
||||
data={radialData}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
angleAxisId={0}
|
||||
tick={false}
|
||||
/>
|
||||
<RadialBar
|
||||
background
|
||||
dataKey="value"
|
||||
cornerRadius={10}
|
||||
fill="#fb923c"
|
||||
/>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-2xl font-bold fill-orange-400"
|
||||
>
|
||||
{typeof metrics?.disk?.percent === "number"
|
||||
? `${metrics.disk.percent}%`
|
||||
: "N/A"}
|
||||
</text>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-shrink-0 space-y-1 text-center pb-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
@@ -99,34 +83,6 @@ export function DiskWidget({
|
||||
return "N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgetSize === "medium" && (
|
||||
<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;
|
||||
@@ -134,67 +90,7 @@ export function DiskWidget({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgetSize === "large" && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="90%"
|
||||
data={radialData}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
angleAxisId={0}
|
||||
tick={false}
|
||||
/>
|
||||
<RadialBar
|
||||
background
|
||||
dataKey="value"
|
||||
cornerRadius={10}
|
||||
fill="#fb923c"
|
||||
/>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-2xl font-bold fill-orange-400"
|
||||
>
|
||||
{typeof metrics?.disk?.percent === "number"
|
||||
? `${metrics.disk.percent}%`
|
||||
: "N/A"}
|
||||
</text>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-shrink-0 space-y-1 text-center pb-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
if (used && total) {
|
||||
return `${used} / ${total}`;
|
||||
}
|
||||
return "N/A";
|
||||
})()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from "react";
|
||||
import { MemoryStick, X, Maximize2 } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import type { WidgetSize } from "@/types/stats-widgets";
|
||||
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
|
||||
const {
|
||||
AreaChart,
|
||||
@@ -19,32 +17,11 @@ const {
|
||||
interface MemoryWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
isEditMode: boolean;
|
||||
widgetId: string;
|
||||
widgetSize: WidgetSize;
|
||||
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onChangeSize: (
|
||||
widgetId: string,
|
||||
newSize: WidgetSize,
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function MemoryWidget({
|
||||
metrics,
|
||||
metricsHistory,
|
||||
isEditMode,
|
||||
widgetId,
|
||||
widgetSize,
|
||||
onDelete,
|
||||
onChangeSize,
|
||||
}: MemoryWidgetProps) {
|
||||
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sizeOrder: WidgetSize[] = ["small", "medium", "large"];
|
||||
const nextSize =
|
||||
sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length];
|
||||
|
||||
// Prepare chart data
|
||||
const chartData = React.useMemo(() => {
|
||||
return metricsHistory.map((m, index) => ({
|
||||
@@ -55,44 +32,19 @@ export function MemoryWidget({
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
{isEditMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
|
||||
type="button"
|
||||
title={`Change to ${nextSize}`}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<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 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">Memory Usage</h3>
|
||||
</div>
|
||||
|
||||
{widgetSize === "small" && (
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="text-4xl font-bold text-green-400">
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{typeof metrics?.memory?.percent === "number"
|
||||
? `${metrics.memory.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
@@ -103,131 +55,62 @@ export function MemoryWidget({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgetSize === "medium" && (
|
||||
<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 className="text-xs text-gray-500 flex-shrink-0">
|
||||
{(() => {
|
||||
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>
|
||||
)}
|
||||
|
||||
{widgetSize === "large" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{typeof metrics?.memory?.percent === "number"
|
||||
? `${metrics.memory.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
if (typeof used === "number" && typeof total === "number") {
|
||||
return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`;
|
||||
}
|
||||
return "N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex-shrink-0">
|
||||
{(() => {
|
||||
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 className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="memoryGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1f2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
"Memory",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#34d399"
|
||||
strokeWidth={2}
|
||||
fill="url(#memoryGradient)"
|
||||
animationDuration={300}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1f2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
"Memory",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#34d399"
|
||||
strokeWidth={2}
|
||||
fill="url(#memoryGradient)"
|
||||
animationDuration={300}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
export { CpuWidget } from "./CpuWidget";
|
||||
export { MemoryWidget } from "./MemoryWidget";
|
||||
export { DiskWidget } from "./DiskWidget";
|
||||
export {
|
||||
WIDGET_REGISTRY,
|
||||
getAvailableWidgets,
|
||||
getWidgetConfig,
|
||||
getWidgetSize,
|
||||
generateWidgetId,
|
||||
type WidgetRegistryItem,
|
||||
} from "./registry";
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from "lucide-react";
|
||||
import type { WidgetType, WidgetSize } from "@/types/stats-widgets";
|
||||
|
||||
export interface WidgetSizeConfig {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface WidgetRegistryItem {
|
||||
type: WidgetType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
sizes: Record<WidgetSize, WidgetSizeConfig>;
|
||||
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",
|
||||
sizes: {
|
||||
small: { w: 3, h: 2 }, // 紧凑:大号百分比+核心数
|
||||
medium: { w: 4, h: 2 }, // 标准:进度条+load average
|
||||
large: { w: 7, h: 3 }, // 图表:折线图需要宽度展示趋势
|
||||
},
|
||||
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",
|
||||
sizes: {
|
||||
small: { w: 3, h: 2 }, // 紧凑:百分比+用量
|
||||
medium: { w: 4, h: 2 }, // 标准:进度条+详细信息
|
||||
large: { w: 6, h: 3 }, // 图表:面积图展示
|
||||
},
|
||||
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",
|
||||
sizes: {
|
||||
small: { w: 3, h: 2 }, // 紧凑:百分比+用量
|
||||
medium: { w: 4, h: 2 }, // 标准:进度条+可用空间
|
||||
large: { w: 4, h: 4 }, // 图表:径向图(方形,不需要太宽)
|
||||
},
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get widget size configuration
|
||||
*/
|
||||
export function getWidgetSize(
|
||||
type: WidgetType,
|
||||
size: WidgetSize,
|
||||
): WidgetSizeConfig {
|
||||
return WIDGET_REGISTRY[type].sizes[size];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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