diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index c7c6f976..db102f6c 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -358,6 +358,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location ~ ^/dashboard/preferences(/.*)?$ {
+ proxy_pass http://127.0.0.1:30006;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30008/;
proxy_http_version 1.1;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 792cc0f8..17c15a6a 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -347,6 +347,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location ~ ^/dashboard/preferences(/.*)?$ {
+ proxy_pass http://127.0.0.1:30006;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30008/;
proxy_http_version 1.1;
diff --git a/package.json b/package.json
index 1e8098b1..30642896 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
- "version": "1.10.0",
+ "version": "1.10.1",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts
index b4f087bf..d7700007 100644
--- a/src/backend/dashboard.ts
+++ b/src/backend/dashboard.ts
@@ -1,9 +1,14 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
-import { getDb } from "./database/db/index.js";
-import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
-import { eq, and, desc, or } from "drizzle-orm";
+import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
+import {
+ recentActivity,
+ sshData,
+ hostAccess,
+ dashboardPreferences,
+} from "./database/db/schema.js";
+import { eq, and, desc, or, sql } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
@@ -350,6 +355,166 @@ app.delete("/activity/reset", async (req, res) => {
}
});
+/**
+ * @openapi
+ * /dashboard/preferences:
+ * get:
+ * summary: Get dashboard layout preferences
+ * description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
+ * tags:
+ * - Dashboard
+ * responses:
+ * 200:
+ * description: Dashboard preferences retrieved
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * cards:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * enabled:
+ * type: boolean
+ * order:
+ * type: integer
+ * gridColumns:
+ * type: integer
+ * 401:
+ * description: Session expired
+ * 500:
+ * description: Failed to get preferences
+ */
+app.get("/dashboard/preferences", async (req, res) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId;
+
+ if (!SimpleDBOps.isUserDataUnlocked(userId)) {
+ return res.status(401).json({
+ error: "Session expired - please log in again",
+ code: "SESSION_EXPIRED",
+ });
+ }
+
+ const preferences = await getDb()
+ .select()
+ .from(dashboardPreferences)
+ .where(eq(dashboardPreferences.userId, userId));
+
+ if (preferences.length === 0) {
+ const defaultLayout = {
+ cards: [
+ { id: "server_overview", enabled: true, order: 1 },
+ { id: "recent_activity", enabled: true, order: 2 },
+ { id: "network_graph", enabled: false, order: 3 },
+ { id: "quick_actions", enabled: true, order: 4 },
+ { id: "server_stats", enabled: true, order: 5 },
+ ],
+ gridColumns: 2,
+ };
+ return res.json(defaultLayout);
+ }
+
+ const layout = JSON.parse(preferences[0].layout as string);
+ res.json(layout);
+ } catch (err) {
+ dashboardLogger.error("Failed to get dashboard preferences", err);
+ res.status(500).json({ error: "Failed to get dashboard preferences" });
+ }
+});
+
+/**
+ * @openapi
+ * /dashboard/preferences:
+ * post:
+ * summary: Save dashboard layout preferences
+ * description: Saves or updates the user's customized dashboard layout settings.
+ * tags:
+ * - Dashboard
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * cards:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * enabled:
+ * type: boolean
+ * order:
+ * type: integer
+ * gridColumns:
+ * type: integer
+ * responses:
+ * 200:
+ * description: Preferences saved successfully
+ * 400:
+ * description: Invalid request body
+ * 401:
+ * description: Session expired
+ * 500:
+ * description: Failed to save preferences
+ */
+app.post("/dashboard/preferences", async (req, res) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId;
+
+ if (!SimpleDBOps.isUserDataUnlocked(userId)) {
+ return res.status(401).json({
+ error: "Session expired - please log in again",
+ code: "SESSION_EXPIRED",
+ });
+ }
+
+ const { cards, gridColumns } = req.body;
+
+ if (!cards || !Array.isArray(cards) || typeof gridColumns !== "number") {
+ return res.status(400).json({
+ error:
+ "Invalid request body. Expected { cards: Array, gridColumns: number }",
+ });
+ }
+
+ const layout = JSON.stringify({ cards, gridColumns });
+
+ const existing = await getDb()
+ .select()
+ .from(dashboardPreferences)
+ .where(eq(dashboardPreferences.userId, userId));
+
+ if (existing.length > 0) {
+ await getDb()
+ .update(dashboardPreferences)
+ .set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
+ .where(eq(dashboardPreferences.userId, userId));
+ } else {
+ await getDb().insert(dashboardPreferences).values({ userId, layout });
+ }
+
+ await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
+
+ dashboardLogger.success("Dashboard preferences saved", {
+ operation: "save_dashboard_preferences",
+ userId,
+ });
+
+ res.json({ success: true, message: "Dashboard preferences saved" });
+ } catch (err) {
+ dashboardLogger.error("Failed to save dashboard preferences", err);
+ res.status(500).json({ error: "Failed to save dashboard preferences" });
+ }
+});
+
const PORT = 30006;
app.listen(PORT, async () => {
try {
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 0bb198ef..3d031dc3 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -703,6 +703,30 @@ const migrateSchema = () => {
}
}
+ try {
+ sqlite
+ .prepare("SELECT id FROM dashboard_preferences LIMIT 1")
+ .get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS dashboard_preferences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL UNIQUE,
+ layout TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create dashboard_preferences table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
try {
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
} catch {
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index 14860b92..5ab3153e 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -324,6 +324,21 @@ export const networkTopology = sqliteTable("network_topology", {
.default(sql`CURRENT_TIMESTAMP`),
});
+export const dashboardPreferences = sqliteTable("dashboard_preferences", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .unique()
+ .references(() => users.id, { onDelete: "cascade" }),
+ layout: text("layout").notNull(),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
diff --git a/src/locales/en.json b/src/locales/en.json
index ed97ae63..9d91d940 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2261,7 +2261,20 @@
"noServerData": "No server data available",
"cpu": "CPU",
"ram": "RAM",
- "notAvailable": "N/A"
+ "notAvailable": "N/A",
+ "customizeLayout": "Customize Dashboard",
+ "dashboardSettings": "Dashboard Settings",
+ "enableDisableCards": "Enable/Disable Cards",
+ "gridColumns": "Grid Columns",
+ "column": "Column",
+ "columns": "Columns",
+ "resetLayout": "Reset to Default",
+ "serverOverviewCard": "Server Overview",
+ "recentActivityCard": "Recent Activity",
+ "networkGraphCard": "Network Graph",
+ "quickActionsCard": "Quick Actions",
+ "serverStatsCard": "Server Stats",
+ "networkGraph": "Network Graph"
},
"rbac": {
"shareHost": "Share Host",
diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx
index 915a42b0..3c44bcdb 100644
--- a/src/ui/desktop/DesktopApp.tsx
+++ b/src/ui/desktop/DesktopApp.tsx
@@ -11,7 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
-import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
+import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { Toaster } from "@/components/ui/sonner.tsx";
import { toast } from "sonner";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
@@ -414,11 +414,10 @@ function AppContent() {
{showNetworkGraph && (
-
)}
diff --git a/src/ui/desktop/apps/NetworkGraphApp.tsx b/src/ui/desktop/apps/NetworkGraphApp.tsx
index e48419e6..5a7a07c9 100644
--- a/src/ui/desktop/apps/NetworkGraphApp.tsx
+++ b/src/ui/desktop/apps/NetworkGraphApp.tsx
@@ -1,10 +1,10 @@
-import NetworkGraphView from "@/ui/desktop/dashboard/network-graph/NetworkGraphView";
+import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import React from "react";
const NetworkGraphApp: React.FC = () => {
return (
-
-
+
+
);
};
diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx
index 7da19725..42b3e996 100644
--- a/src/ui/desktop/apps/dashboard/Dashboard.tsx
+++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx
@@ -1,7 +1,5 @@
import React, { useEffect, useState } from "react";
-import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
-import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
@@ -11,7 +9,6 @@ import {
getUptime,
getVersionInfo,
getSSHHosts,
- getTunnelStatuses,
getCredentials,
getRecentActivity,
resetRecentActivity,
@@ -21,29 +18,16 @@ import {
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
-import { Kbd, KbdGroup } from "@/components/ui/kbd";
-import {
- ChartLine,
- Clock,
- Database,
- FastForward,
- History,
- Key,
- Network,
- Server,
- UserPlus,
- Settings,
- User,
- Loader2,
- Terminal,
- FolderOpen,
- Activity,
- Container,
- ArrowDownUp,
-} from "lucide-react";
-import { Status } from "@/components/ui/shadcn-io/status";
-import { BsLightning } from "react-icons/bs";
+import { Kbd } from "@/components/ui/kbd";
import { useTranslation } from "react-i18next";
+import { Settings as SettingsIcon } from "lucide-react";
+import { ServerOverviewCard } from "@/ui/desktop/apps/dashboard/cards/ServerOverviewCard";
+import { RecentActivityCard } from "@/ui/desktop/apps/dashboard/cards/RecentActivityCard";
+import { QuickActionsCard } from "@/ui/desktop/apps/dashboard/cards/QuickActionsCard";
+import { ServerStatsCard } from "@/ui/desktop/apps/dashboard/cards/ServerStatsCard";
+import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
+import { useDashboardPreferences } from "@/ui/desktop/apps/dashboard/hooks/useDashboardPreferences";
+import { DashboardSettingsDialog } from "@/ui/desktop/apps/dashboard/components/DashboardSettingsDialog";
interface DashboardProps {
onSelectView: (view: string) => void;
@@ -94,9 +78,15 @@ export function Dashboard({
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
>([]);
const [serverStatsLoading, setServerStatsLoading] = useState
(true);
- const [showNetworkGraph, setShowNetworkGraph] = useState(false);
+ const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
+ const {
+ layout,
+ loading: preferencesLoading,
+ updateLayout,
+ resetLayout,
+ } = useDashboardPreferences();
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
@@ -436,8 +426,18 @@ export function Dashboard({
>
-
- {t("dashboard.title")}
+
+
+ {t("dashboard.title")}
+
+
@@ -496,426 +496,91 @@ export function Dashboard({
-
-
-
-
-
- {t("dashboard.serverOverview")}
-
-
-
-
-
-
- {t("dashboard.version")}
-
-
-
-
-
- {versionText}
-
-
-
-
-
-
-
-
-
-
- {t("dashboard.uptime")}
-
-
-
-
-
-
-
-
-
-
- {t("dashboard.database")}
-
-
-
-
-
- {dbHealth === "healthy"
- ? t("dashboard.healthy")
- : t("dashboard.error")}
-
-
-
-
-
-
-
-
-
- {t("dashboard.totalServers")}
-
-
-
- {totalServers}
-
-
-
-
-
-
- {t("dashboard.totalTunnels")}
-
-
-
- {totalTunnels}
-
-
-
-
-
-
-
-
- {t("dashboard.totalCredentials")}
-
-
-
- {totalCredentials}
-
-
-
-
-
-
-
-
-
- {showNetworkGraph ? (
- <>
-
- {t("dashboard.networkGraph", {
- defaultValue: "Network Graph",
- })}
- >
- ) : (
- <>
-
- {t("dashboard.recentActivity")}
- >
- )}
-
-
-
-
-
-
-
-
- {recentActivityLoading ? (
-
-
- {t("dashboard.loadingRecentActivity")}
-
- ) : recentActivity.length === 0 ? (
-
- {t("dashboard.noRecentActivity")}
-
- ) : (
- recentActivity
- .filter((item, index, array) => {
- if (index === 0) return true;
-
- const prevItem = array[index - 1];
- return !(
- item.hostId === prevItem.hostId &&
- item.type === prevItem.type
- );
- })
- .map((item) => (
-
- ))
- )}
-
- {showNetworkGraph ? (
-
- ) : (
-
- {recentActivityLoading ? (
-
-
- {t("dashboard.loadingRecentActivity")}
-
- ) : recentActivity.length === 0 ? (
-
- {t("dashboard.noRecentActivity")}
-
- ) : (
- recentActivity.map((item) => (
-
- ))
- )}
-
- )}
-
-
-
-
-
-
-
-
- {t("dashboard.quickActions")}
-
-
-
-
- {isAdmin && (
-
- )}
-
-
-
+ );
+ } else if (card.id === "quick_actions") {
+ return (
+
+ );
+ } else if (card.id === "server_stats") {
+ return (
+
+ );
+ }
+ return null;
+ })}
-
-
-
-
- {t("dashboard.serverStats")}
-
-
- {serverStatsLoading ? (
-
-
- {t("dashboard.loadingServerStats")}
-
- ) : serverStats.length === 0 ? (
-
- {t("dashboard.noServerData")}
-
- ) : (
- serverStats.map((server) => (
-
- ))
- )}
-
-
-
-
+ )}
)}
+
+ {layout && (
+
+ )}
>
);
}
diff --git a/src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx b/src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
new file mode 100644
index 00000000..8fcfe714
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
@@ -0,0 +1,1261 @@
+import React, {
+ useEffect,
+ useState,
+ useRef,
+ useCallback,
+ useMemo,
+} from "react";
+import CytoscapeComponent from "react-cytoscapejs";
+import cytoscape from "cytoscape";
+import {
+ getSSHHosts,
+ getNetworkTopology,
+ saveNetworkTopology,
+ type SSHHostWithStatus,
+} from "@/ui/main-axios";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Plus,
+ Trash2,
+ Move3D,
+ ZoomIn,
+ ZoomOut,
+ RotateCw,
+ Loader2,
+ AlertCircle,
+ Download,
+ Upload,
+ Link2,
+ FolderPlus,
+ Edit,
+ FolderInput,
+ FolderMinus,
+ Settings2,
+ Terminal,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
+
+interface HostMap {
+ [key: string]: SSHHostWithStatus;
+}
+
+interface ContextMenuState {
+ visible: boolean;
+ x: number;
+ y: number;
+ targetId: string;
+ type: "node" | "group" | "edge" | null;
+}
+
+interface NetworkGraphCardProps {
+ isTopbarOpen?: boolean;
+ rightSidebarOpen?: boolean;
+ rightSidebarWidth?: number;
+}
+
+export function NetworkGraphCard({}: NetworkGraphCardProps): React.ReactElement {
+ const { t } = useTranslation();
+ const { addTab } = useTabs();
+
+ const [elements, setElements] = useState
([]);
+ const [hosts, setHosts] = useState([]);
+ const [hostMap, setHostMap] = useState({});
+ const hostMapRef = useRef({});
+
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
+
+ const [showAddNodeDialog, setShowAddNodeDialog] = useState(false);
+ const [showAddEdgeDialog, setShowAddEdgeDialog] = useState(false);
+ const [showAddGroupDialog, setShowAddGroupDialog] = useState(false);
+ const [showEditGroupDialog, setShowEditGroupDialog] = useState(false);
+ const [showNodeDetail, setShowNodeDetail] = useState(false);
+ const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false);
+
+ const [selectedHostForAddNode, setSelectedHostForAddNode] =
+ useState("");
+ const [selectedGroupForAddNode, setSelectedGroupForAddNode] =
+ useState("ROOT");
+ const [newGroupName, setNewGroupName] = useState("");
+ const [newGroupColor, setNewGroupColor] = useState("#3b82f6");
+ const [editingGroupId, setEditingGroupId] = useState(null);
+ const [selectedGroupForMove, setSelectedGroupForMove] =
+ useState("ROOT");
+ const [selectedHostForEdge, setSelectedHostForEdge] = useState("");
+ const [targetHostForEdge, setTargetHostForEdge] = useState("");
+ const [selectedNodeForDetail, setSelectedNodeForDetail] =
+ useState(null);
+
+ const [contextMenu, setContextMenu] = useState({
+ visible: false,
+ x: 0,
+ y: 0,
+ targetId: "",
+ type: null,
+ });
+
+ const cyRef = useRef(null);
+ const statusCheckIntervalRef = useRef(null);
+ const saveTimeoutRef = useRef(null);
+ const contextMenuRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ hostMapRef.current = hostMap;
+ }, [hostMap]);
+
+ useEffect(() => {
+ if (!cyRef.current) return;
+
+ const canvas = document.createElement("canvas");
+ const container = cyRef.current.container();
+ if (!container) return;
+
+ canvas.style.position = "absolute";
+ canvas.style.top = "0";
+ canvas.style.left = "0";
+ canvas.style.pointerEvents = "none";
+ canvas.style.zIndex = "999";
+ container.appendChild(canvas);
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ let animationFrame: number;
+ let startTime = Date.now();
+
+ const animate = () => {
+ if (!cyRef.current || !ctx) return;
+
+ const canvasWidth = cyRef.current.width();
+ const canvasHeight = cyRef.current.height();
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+
+ const elapsed = Date.now() - startTime;
+ const cycle = (elapsed % 2000) / 2000;
+ const zoom = cyRef.current.zoom();
+
+ const baseRadius = 4;
+ const pulseAmount = 3;
+ const pulseRadius =
+ (baseRadius + Math.sin(cycle * Math.PI * 2) * pulseAmount) * zoom;
+ const pulseOpacity = 0.6 - Math.sin(cycle * Math.PI * 2) * 0.4;
+
+ cyRef.current.nodes('[status="online"]').forEach((node) => {
+ if (node.isParent()) return;
+
+ const pos = node.renderedPosition();
+ const nodeWidth = 180 * zoom;
+ const nodeHeight = 90 * zoom;
+
+ const dotX = pos.x + nodeWidth / 2 - 10 * zoom;
+ const dotY = pos.y - nodeHeight / 2 + 10 * zoom;
+
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, pulseRadius, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(16, 185, 129, ${pulseOpacity})`;
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, baseRadius * zoom, 0, Math.PI * 2);
+ ctx.fillStyle = "#10b981";
+ ctx.fill();
+ });
+
+ cyRef.current.nodes('[status="offline"]').forEach((node) => {
+ if (node.isParent()) return;
+
+ const pos = node.renderedPosition();
+ const nodeWidth = 180 * zoom;
+ const nodeHeight = 90 * zoom;
+
+ const dotX = pos.x + nodeWidth / 2 - 10 * zoom;
+ const dotY = pos.y - nodeHeight / 2 + 10 * zoom;
+
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, 4 * zoom, 0, Math.PI * 2);
+ ctx.fillStyle = "#ef4444";
+ ctx.fill();
+ });
+
+ animationFrame = requestAnimationFrame(animate);
+ };
+
+ animate();
+
+ return () => {
+ if (animationFrame) cancelAnimationFrame(animationFrame);
+ if (container && canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };
+ }, [elements]);
+
+ useEffect(() => {
+ loadData();
+ const interval = setInterval(updateHostStatuses, 30000);
+ statusCheckIntervalRef.current = interval;
+
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ contextMenuRef.current &&
+ !contextMenuRef.current.contains(e.target as Node)
+ ) {
+ setContextMenu((prev) =>
+ prev.visible ? { ...prev, visible: false } : prev,
+ );
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside, true);
+
+ return () => {
+ if (statusCheckIntervalRef.current)
+ clearInterval(statusCheckIntervalRef.current);
+ document.removeEventListener("mousedown", handleClickOutside, true);
+ };
+ }, []);
+
+ const loadData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const hostsData = await getSSHHosts();
+ const hostsArray = Array.isArray(hostsData) ? hostsData : [];
+ setHosts(hostsArray);
+
+ const newHostMap: HostMap = {};
+ hostsArray.forEach((host) => {
+ newHostMap[String(host.id)] = host;
+ });
+ setHostMap(newHostMap);
+
+ let nodes: any[] = [];
+ let edges: any[] = [];
+
+ try {
+ const topologyData = await getNetworkTopology();
+ if (
+ topologyData &&
+ topologyData.nodes &&
+ Array.isArray(topologyData.nodes)
+ ) {
+ nodes = topologyData.nodes.map((node: any) => {
+ const host = newHostMap[node.data.id];
+ return {
+ data: {
+ id: node.data.id,
+ label: host?.name || node.data.label || "Unknown",
+ ip: host ? `${host.ip}:${host.port}` : node.data.ip || "",
+ status: host?.status || "unknown",
+ tags: host?.tags || [],
+ parent: node.data.parent,
+ color: node.data.color,
+ },
+ position: node.position || { x: 0, y: 0 },
+ };
+ });
+ edges = topologyData.edges || [];
+ }
+ } catch (topologyError) {
+ console.warn("Starting with empty topology");
+ }
+
+ setElements([...nodes, ...edges]);
+ } catch (err) {
+ console.error("Failed to load topology:", err);
+ setError("Failed to load data");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const updateHostStatuses = useCallback(async () => {
+ if (!cyRef.current) return;
+ try {
+ const updatedHosts = await getSSHHosts();
+ const updatedHostMap: HostMap = {};
+ updatedHosts.forEach((host) => {
+ updatedHostMap[String(host.id)] = host;
+ });
+
+ cyRef.current.nodes().forEach((node) => {
+ if (node.isParent()) return;
+ const hostId = node.data("id");
+ const updatedHost = updatedHostMap[hostId];
+ if (updatedHost) {
+ node.data("status", updatedHost.status);
+ node.data("tags", updatedHost.tags || []);
+ }
+ });
+ setHostMap(updatedHostMap);
+ } catch (err) {
+ console.error("Status update failed:", err);
+ }
+ }, []);
+
+ const debouncedSave = useCallback(() => {
+ if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = setTimeout(() => {
+ saveCurrentLayout();
+ }, 1000);
+ }, []);
+
+ const saveCurrentLayout = async () => {
+ if (!cyRef.current) return;
+ try {
+ const nodes = cyRef.current.nodes().map((node) => ({
+ data: {
+ id: node.data("id"),
+ label: node.data("label"),
+ ip: node.data("ip"),
+ status: node.data("status"),
+ tags: node.data("tags") || [],
+ parent: node.data("parent"),
+ color: node.data("color"),
+ },
+ position: node.position(),
+ }));
+
+ const edges = cyRef.current.edges().map((edge) => ({
+ data: {
+ id: edge.data("id"),
+ source: edge.data("source"),
+ target: edge.data("target"),
+ },
+ }));
+
+ await saveNetworkTopology({ nodes, edges });
+ } catch (err) {
+ console.error("Save failed:", err);
+ }
+ };
+
+ useEffect(() => {
+ if (!cyRef.current || loading || elements.length === 0) return;
+ const hasPositions = elements.some(
+ (el: any) => el.position && (el.position.x !== 0 || el.position.y !== 0),
+ );
+
+ if (!hasPositions) {
+ cyRef.current
+ .layout({
+ name: "cose",
+ animate: false,
+ randomize: true,
+ componentSpacing: 100,
+ nodeOverlap: 20,
+ })
+ .run();
+ } else {
+ cyRef.current.fit();
+ }
+ }, [loading]);
+
+ const handleNodeInit = useCallback(
+ (cy: cytoscape.Core) => {
+ cyRef.current = cy;
+ cy.style()
+ .selector("node")
+ .style({
+ label: "",
+ width: "180px",
+ height: "90px",
+ shape: "round-rectangle",
+ "border-width": "0px",
+ "background-opacity": 0,
+ "background-image": function (ele) {
+ const host = ele.data();
+ const name = host.label || "";
+ const ip = host.ip || "";
+ const tags = host.tags || [];
+ const isOnline = host.status === "online";
+ const isOffline = host.status === "offline";
+ const statusColor = isOnline
+ ? "#10b981"
+ : isOffline
+ ? "#ef4444"
+ : "#64748b";
+
+ const tagsHtml = tags
+ .map(
+ (t) => `
+ ${t}`,
+ )
+ .join("");
+
+ const svg = `
+
+ `;
+ return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
+ },
+ "background-fit": "contain",
+ })
+ .selector("node:parent")
+ .style({
+ "background-image": "none",
+ "background-color": (ele) => ele.data("color") || "#1e3a8a",
+ "background-opacity": 0.05,
+ "border-color": (ele) => ele.data("color") || "#3b82f6",
+ "border-width": "2px",
+ "border-style": "dashed",
+ label: "data(label)",
+ "text-valign": "top",
+ "text-halign": "center",
+ "text-margin-y": -20,
+ color: "#94a3b8",
+ "font-size": "16px",
+ "font-weight": "bold",
+ shape: "round-rectangle",
+ padding: "10px",
+ })
+ .selector("edge")
+ .style({
+ width: "2px",
+ "line-color": "#373739",
+ "curve-style": "round-taxi",
+ "source-endpoint": "outside-to-node",
+ "target-endpoint": "outside-to-node",
+ "control-point-step-size": 10,
+ "control-point-distances": [40, -40],
+ "control-point-weights": [0.2, 0.8],
+ "target-arrow-shape": "none",
+ })
+ .selector("edge:selected")
+ .style({
+ "line-color": "#3b82f6",
+ width: "3px",
+ })
+ .selector("node:selected")
+ .style({
+ "overlay-color": "#3b82f6",
+ "overlay-opacity": 0.05,
+ "overlay-padding": "5px",
+ });
+
+ cy.on("tap", "node", (evt) => {
+ const node = evt.target;
+ setContextMenu((prev) =>
+ prev.visible ? { ...prev, visible: false } : prev,
+ );
+ setSelectedEdgeId(null);
+ setSelectedNodeId(node.id());
+ });
+
+ cy.on("tap", "edge", (evt) => {
+ evt.stopPropagation();
+ setSelectedEdgeId(evt.target.id());
+ setSelectedNodeId(null);
+ });
+
+ cy.on("tap", (evt) => {
+ if (evt.target === cy) {
+ setContextMenu((prev) =>
+ prev.visible ? { ...prev, visible: false } : prev,
+ );
+ setSelectedNodeId(null);
+ setSelectedEdgeId(null);
+ }
+ });
+
+ cy.on("cxttap", "node", (evt) => {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const node = evt.target;
+
+ const x = evt.originalEvent.clientX;
+ const y = evt.originalEvent.clientY;
+
+ setContextMenu({
+ visible: true,
+ x,
+ y,
+ targetId: node.id(),
+ type: node.isParent() ? "group" : "node",
+ });
+ });
+
+ cy.on("zoom pan", () => {
+ setContextMenu((prev) =>
+ prev.visible ? { ...prev, visible: false } : prev,
+ );
+ });
+
+ cy.on("free", "node", () => debouncedSave());
+
+ cy.on("boxselect", "node", () => {
+ const selected = cy.$("node:selected");
+ if (selected.length === 1) setSelectedNodeId(selected[0].id());
+ });
+ },
+ [debouncedSave],
+ );
+
+ const handleContextAction = (action: string) => {
+ setContextMenu((prev) => ({ ...prev, visible: false }));
+ const targetId = contextMenu.targetId;
+ if (!cyRef.current) return;
+
+ if (action === "details") {
+ const host = hostMap[targetId];
+ if (host) {
+ setSelectedNodeForDetail(host);
+ setShowNodeDetail(true);
+ }
+ } else if (action === "connect") {
+ const host = hostMap[targetId];
+ if (host) {
+ const title = host.name?.trim()
+ ? host.name
+ : `${host.username}@${host.ip}:${host.port}`;
+ addTab({ type: "terminal", title, hostConfig: host });
+ }
+ } else if (action === "move") {
+ setSelectedNodeId(targetId);
+ const node = cyRef.current.$id(targetId);
+ const parentId = node.data("parent");
+ setSelectedGroupForMove(parentId || "ROOT");
+ setShowMoveNodeDialog(true);
+ } else if (action === "removeFromGroup") {
+ const node = cyRef.current.$id(targetId);
+ node.move({ parent: null });
+ debouncedSave();
+ } else if (action === "editGroup") {
+ const node = cyRef.current.$id(targetId);
+ setEditingGroupId(targetId);
+ setNewGroupName(node.data("label"));
+ setNewGroupColor(node.data("color") || "#3b82f6");
+ setShowEditGroupDialog(true);
+ } else if (action === "addHostToGroup") {
+ setSelectedGroupForAddNode(targetId);
+ setSelectedHostForAddNode("");
+ setShowAddNodeDialog(true);
+ } else if (action === "delete") {
+ cyRef.current.$id(targetId).remove();
+ debouncedSave();
+ }
+ };
+
+ const handleAddNode = () => {
+ setSelectedHostForAddNode("");
+ setSelectedGroupForAddNode("ROOT");
+ setShowAddNodeDialog(true);
+ };
+
+ const handleConfirmAddNode = async () => {
+ if (!cyRef.current || !selectedHostForAddNode) return;
+ try {
+ if (cyRef.current.$id(selectedHostForAddNode).length > 0) {
+ setError("Host is already in the topology");
+ return;
+ }
+ const host = hostMap[selectedHostForAddNode];
+ const parent =
+ selectedGroupForAddNode === "ROOT"
+ ? undefined
+ : selectedGroupForAddNode;
+
+ const newNode = {
+ data: {
+ id: selectedHostForAddNode,
+ label: host.name || `${host.ip}`,
+ ip: `${host.ip}:${host.port}`,
+ status: host.status,
+ tags: host.tags || [],
+ parent: parent,
+ },
+ position: { x: 100 + Math.random() * 50, y: 100 + Math.random() * 50 },
+ };
+ cyRef.current.add(newNode);
+ await saveCurrentLayout();
+ setShowAddNodeDialog(false);
+ } catch (err) {
+ setError("Failed to add node");
+ }
+ };
+
+ const handleAddGroup = async () => {
+ if (!cyRef.current || !newGroupName) return;
+ const groupId = `group-${Date.now()}`;
+ cyRef.current.add({
+ data: { id: groupId, label: newGroupName, color: newGroupColor },
+ });
+ await saveCurrentLayout();
+ setShowAddGroupDialog(false);
+ setNewGroupName("");
+ };
+
+ const handleUpdateGroup = async () => {
+ if (!cyRef.current || !editingGroupId || !newGroupName) return;
+ const group = cyRef.current.$id(editingGroupId);
+ group.data("label", newGroupName);
+ group.data("color", newGroupColor);
+ await saveCurrentLayout();
+ setShowEditGroupDialog(false);
+ setEditingGroupId(null);
+ };
+
+ const handleMoveNodeToGroup = async () => {
+ if (!cyRef.current || !selectedNodeId) return;
+ const node = cyRef.current.$id(selectedNodeId);
+ const parent =
+ selectedGroupForMove === "ROOT" ? null : selectedGroupForMove;
+ node.move({ parent: parent });
+ await saveCurrentLayout();
+ setShowMoveNodeDialog(false);
+ };
+
+ const handleAddEdge = async () => {
+ if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return;
+ if (selectedHostForEdge === targetHostForEdge)
+ return setError("Source and target must be different");
+
+ const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`;
+ if (cyRef.current.$id(edgeId).length > 0)
+ return setError("Connection exists");
+
+ cyRef.current.add({
+ data: {
+ id: edgeId,
+ source: selectedHostForEdge,
+ target: targetHostForEdge,
+ },
+ });
+ await saveCurrentLayout();
+ setShowAddEdgeDialog(false);
+ };
+
+ const handleRemoveSelected = () => {
+ if (!cyRef.current) return;
+
+ if (selectedNodeId) {
+ cyRef.current.$id(selectedNodeId).remove();
+ setSelectedNodeId(null);
+ } else if (selectedEdgeId) {
+ cyRef.current.$id(selectedEdgeId).remove();
+ setSelectedEdgeId(null);
+ }
+
+ debouncedSave();
+ };
+
+ const availableGroups = useMemo(() => {
+ return elements
+ .filter(
+ (el) => !el.data.source && !el.data.target && !el.data.ip && el.data.id,
+ )
+ .map((el) => ({ id: el.data.id, label: el.data.label }));
+ }, [elements]);
+
+ const availableNodesForConnection = useMemo(() => {
+ return elements
+ .filter((el) => !el.data.source && !el.data.target)
+ .map((el) => ({
+ id: el.data.id,
+ label: el.data.label,
+ }));
+ }, [elements]);
+
+ const availableHostsForAdd = useMemo(() => {
+ if (!cyRef.current) return hosts;
+ const existingIds = new Set(elements.map((e) => e.data.id));
+ return hosts.filter((h) => !existingIds.has(String(h.id)));
+ }, [hosts, elements]);
+
+ return (
+
+
+
+ {t("dashboard.networkGraph")}
+
+
+ {error && (
+
+
+
{error}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ try {
+ const json = JSON.parse(evt.target?.result as string);
+ saveNetworkTopology({
+ nodes: json.nodes,
+ edges: json.edges,
+ }).then(() => loadData());
+ } catch (err) {
+ setError("Invalid File");
+ }
+ };
+ reader.readAsText(file);
+ }}
+ className="hidden"
+ />
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {contextMenu.visible && (
+
+ {contextMenu.type === "node" && (
+ <>
+
+
+
+ {cyRef.current?.$id(contextMenu.targetId).parent().length ? (
+
+ ) : null}
+ >
+ )}
+
+ {contextMenu.type === "group" && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx b/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
new file mode 100644
index 00000000..1c545ac3
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
@@ -0,0 +1,141 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { FastForward, Server, Key, Settings, User } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface QuickActionsCardProps {
+ isAdmin: boolean;
+ onAddHost: () => void;
+ onAddCredential: () => void;
+ onOpenAdminSettings: () => void;
+ onOpenUserProfile: () => void;
+}
+
+export function QuickActionsCard({
+ isAdmin,
+ onAddHost,
+ onAddCredential,
+ onOpenAdminSettings,
+ onOpenUserProfile,
+}: QuickActionsCardProps): React.ReactElement {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t("dashboard.quickActions")}
+
+
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx b/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
new file mode 100644
index 00000000..131ac860
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
@@ -0,0 +1,97 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Clock,
+ Loader2,
+ Terminal,
+ FolderOpen,
+ Server,
+ ArrowDownUp,
+ Container,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { type RecentActivityItem } from "@/ui/main-axios";
+
+interface RecentActivityCardProps {
+ activities: RecentActivityItem[];
+ loading: boolean;
+ onReset: () => void;
+ onActivityClick: (item: RecentActivityItem) => void;
+}
+
+export function RecentActivityCard({
+ activities,
+ loading,
+ onReset,
+ onActivityClick,
+}: RecentActivityCardProps): React.ReactElement {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t("dashboard.recentActivity")}
+
+
+
+
+ {loading ? (
+
+
+ {t("dashboard.loadingRecentActivity")}
+
+ ) : activities.length === 0 ? (
+
+ {t("dashboard.noRecentActivity")}
+
+ ) : (
+ activities
+ .filter((item, index, array) => {
+ if (index === 0) return true;
+
+ const prevItem = array[index - 1];
+ return !(
+ item.hostId === prevItem.hostId && item.type === prevItem.type
+ );
+ })
+ .map((item) => (
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx b/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
new file mode 100644
index 00000000..c8e1523f
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
@@ -0,0 +1,142 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Server,
+ History,
+ Clock,
+ Database,
+ Key,
+ ArrowDownUp,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
+
+interface ServerOverviewCardProps {
+ loggedIn: boolean;
+ versionText: string;
+ versionStatus: "up_to_date" | "requires_update";
+ uptime: string;
+ dbHealth: "healthy" | "error";
+ totalServers: number;
+ totalTunnels: number;
+ totalCredentials: number;
+}
+
+export function ServerOverviewCard({
+ loggedIn,
+ versionText,
+ versionStatus,
+ uptime,
+ dbHealth,
+ totalServers,
+ totalTunnels,
+ totalCredentials,
+}: ServerOverviewCardProps): React.ReactElement {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t("dashboard.serverOverview")}
+
+
+
+
+
+
+ {t("dashboard.version")}
+
+
+
+
+
+ {versionText}
+
+
+
+
+
+
+
+
+
+
+ {t("dashboard.uptime")}
+
+
+
+
+
+
+
+
+
+
+ {t("dashboard.database")}
+
+
+
+
+
+ {dbHealth === "healthy"
+ ? t("dashboard.healthy")
+ : t("dashboard.error")}
+
+
+
+
+
+
+
+
+
+ {t("dashboard.totalServers")}
+
+
+
+ {totalServers}
+
+
+
+
+
+
+ {t("dashboard.totalTunnels")}
+
+
+
+ {totalTunnels}
+
+
+
+
+
+
+
+
+ {t("dashboard.totalCredentials")}
+
+
+
+ {totalCredentials}
+
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx b/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
new file mode 100644
index 00000000..caa10934
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { ChartLine, Loader2, Server } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface ServerStat {
+ id: number;
+ name: string;
+ cpu: number | null;
+ ram: number | null;
+}
+
+interface ServerStatsCardProps {
+ serverStats: ServerStat[];
+ loading: boolean;
+ onServerClick: (serverId: number, serverName: string) => void;
+}
+
+export function ServerStatsCard({
+ serverStats,
+ loading,
+ onServerClick,
+}: ServerStatsCardProps): React.ReactElement {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t("dashboard.serverStats")}
+
+
+ {loading ? (
+
+
+ {t("dashboard.loadingServerStats")}
+
+ ) : serverStats.length === 0 ? (
+
+ {t("dashboard.noServerData")}
+
+ ) : (
+ serverStats.map((server) => (
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx b/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx
new file mode 100644
index 00000000..e546eff4
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx
@@ -0,0 +1,164 @@
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useTranslation } from "react-i18next";
+import type { DashboardLayout } from "@/ui/main-axios";
+
+interface DashboardSettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentLayout: DashboardLayout;
+ onSave: (layout: DashboardLayout) => void;
+ onReset: () => void;
+}
+
+export function DashboardSettingsDialog({
+ open,
+ onOpenChange,
+ currentLayout,
+ onSave,
+ onReset,
+}: DashboardSettingsDialogProps): React.ReactElement {
+ const { t } = useTranslation();
+ const [layout, setLayout] = useState(currentLayout);
+
+ useEffect(() => {
+ setLayout(currentLayout);
+ }, [currentLayout, open]);
+
+ const handleCardToggle = (cardId: string, enabled: boolean) => {
+ setLayout((prev) => ({
+ ...prev,
+ cards: prev.cards.map((card) =>
+ card.id === cardId ? { ...card, enabled } : card,
+ ),
+ }));
+ };
+
+ const handleGridColumnsChange = (value: string) => {
+ setLayout((prev) => ({
+ ...prev,
+ gridColumns: parseInt(value, 10),
+ }));
+ };
+
+ const handleSave = () => {
+ onSave(layout);
+ onOpenChange(false);
+ };
+
+ const handleReset = () => {
+ onReset();
+ onOpenChange(false);
+ };
+
+ const cardLabels: Record = {
+ server_overview: t("dashboard.serverOverviewCard"),
+ recent_activity: t("dashboard.recentActivityCard"),
+ network_graph: t("dashboard.networkGraphCard"),
+ quick_actions: t("dashboard.quickActionsCard"),
+ server_stats: t("dashboard.serverStatsCard"),
+ };
+
+ return (
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts b/src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts
new file mode 100644
index 00000000..507b4eb6
--- /dev/null
+++ b/src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts
@@ -0,0 +1,76 @@
+import { useState, useEffect, useCallback } from "react";
+import {
+ getDashboardPreferences,
+ saveDashboardPreferences,
+ type DashboardLayout,
+} from "@/ui/main-axios";
+
+const DEFAULT_LAYOUT: DashboardLayout = {
+ cards: [
+ { id: "server_overview", enabled: true, order: 1 },
+ { id: "recent_activity", enabled: true, order: 2 },
+ { id: "network_graph", enabled: false, order: 3 },
+ { id: "quick_actions", enabled: true, order: 4 },
+ { id: "server_stats", enabled: true, order: 5 },
+ ],
+ gridColumns: 2,
+};
+
+export function useDashboardPreferences() {
+ const [layout, setLayout] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saveTimeout, setSaveTimeout] = useState(null);
+
+ useEffect(() => {
+ const fetchPreferences = async () => {
+ try {
+ const preferences = await getDashboardPreferences();
+ setLayout(preferences);
+ } catch (error) {
+ console.error("Failed to load dashboard preferences:", error);
+ setLayout(DEFAULT_LAYOUT);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPreferences();
+ }, []);
+
+ const updateLayout = useCallback(
+ (newLayout: DashboardLayout) => {
+ setLayout(newLayout);
+
+ if (saveTimeout) {
+ clearTimeout(saveTimeout);
+ }
+
+ const timeout = setTimeout(async () => {
+ try {
+ await saveDashboardPreferences(newLayout);
+ } catch (error) {
+ console.error("Failed to save dashboard preferences:", error);
+ }
+ }, 1000);
+
+ setSaveTimeout(timeout);
+ },
+ [saveTimeout],
+ );
+
+ const resetLayout = useCallback(async () => {
+ setLayout(DEFAULT_LAYOUT);
+ try {
+ await saveDashboardPreferences(DEFAULT_LAYOUT);
+ } catch (error) {
+ console.error("Failed to reset dashboard preferences:", error);
+ }
+ }, []);
+
+ return {
+ layout,
+ loading,
+ updateLayout,
+ resetLayout,
+ };
+}
diff --git a/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx b/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx
deleted file mode 100644
index dc76a4cb..00000000
--- a/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx
+++ /dev/null
@@ -1,1072 +0,0 @@
-import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
-import CytoscapeComponent from 'react-cytoscapejs';
-import cytoscape from 'cytoscape';
-import { getSSHHosts, getNetworkTopology, saveNetworkTopology, type NetworkTopologyData, type SSHHostWithStatus } from '@/ui/main-axios';
-import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import {
- Plus, Trash2, Move3D, ZoomIn, ZoomOut, RotateCw, Loader2, AlertCircle,
- Download, Upload, Link2, FolderPlus, Edit, FolderInput, FolderMinus, Settings2, ExternalLink, Terminal
-} from 'lucide-react';
-import { useTranslation } from 'react-i18next';
-import { useTabs } from '@/ui/desktop/navigation/tabs/TabContext';
-import { useSidebar } from '@/components/ui/sidebar';
-import { Separator } from '@/components/ui/separator';
-
-// --- Helper for edge routing ---
-const getEndpoints = (edge: cytoscape.EdgeSingular): { sourceEndpoint: string; targetEndpoint: string } => {
- const sourcePos = edge.source().position();
- const targetPos = edge.target().position();
- const dx = targetPos.x - sourcePos.x;
- const dy = targetPos.y - sourcePos.y;
-
- let sourceEndpoint: string;
- let targetEndpoint: string;
-
- if (Math.abs(dx) > Math.abs(dy)) {
- sourceEndpoint = dx > 0 ? 'right' : 'left';
- targetEndpoint = dx > 0 ? 'left' : 'right';
- } else {
- sourceEndpoint = dy > 0 ? 'bottom' : 'top';
- targetEndpoint = dy > 0 ? 'top' : 'bottom';
- }
- return { sourceEndpoint, targetEndpoint };
-};
-
-// --- Types ---
-interface HostMap {
- [key: string]: SSHHostWithStatus;
-}
-
-interface ContextMenuState {
- visible: boolean;
- x: number;
- y: number;
- targetId: string;
- type: 'node' | 'group' | 'edge' | null;
-}
-
-interface NetworkGraphViewProps {
- isTopbarOpen?: boolean;
- rightSidebarOpen?: boolean;
- rightSidebarWidth?: number;
- isStandalone?: boolean;
-}
-
-const NetworkGraphView: React.FC = ({
- isTopbarOpen = true,
- rightSidebarOpen = false,
- rightSidebarWidth = 400,
- isStandalone = false
-}) => {
- const { t } = useTranslation();
- const { addTab } = useTabs();
-
- let sidebarState: "expanded" | "collapsed" = "expanded";
- try {
- const sidebar = useSidebar();
- sidebarState = sidebar.state;
- } catch (error) {
- // Not in SidebarProvider context (e.g., during authentication)
- }
-
- // --- State ---
- const [elements, setElements] = useState([]);
- const [hosts, setHosts] = useState([]);
- const [hostMap, setHostMap] = useState({});
-
- // Refs
- const hostMapRef = useRef({});
-
- // UI State
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [selectedNodeId, setSelectedNodeId] = useState(null);
- const [selectedEdgeId, setSelectedEdgeId] = useState(null);
-
- // Dialog State
- const [showAddNodeDialog, setShowAddNodeDialog] = useState(false);
- const [showAddEdgeDialog, setShowAddEdgeDialog] = useState(false);
- const [showAddGroupDialog, setShowAddGroupDialog] = useState(false);
- const [showEditGroupDialog, setShowEditGroupDialog] = useState(false);
- const [showNodeDetail, setShowNodeDetail] = useState(false);
- const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false);
-
- // Form State
- const [selectedHostForAddNode, setSelectedHostForAddNode] = useState('');
- const [selectedGroupForAddNode, setSelectedGroupForAddNode] = useState('ROOT');
- const [newGroupName, setNewGroupName] = useState('');
- const [newGroupColor, setNewGroupColor] = useState('#3b82f6'); // Default Blue
- const [editingGroupId, setEditingGroupId] = useState(null);
- const [selectedGroupForMove, setSelectedGroupForMove] = useState('ROOT');
- const [selectedHostForEdge, setSelectedHostForEdge] = useState('');
- const [targetHostForEdge, setTargetHostForEdge] = useState('');
- const [selectedNodeForDetail, setSelectedNodeForDetail] = useState(null);
-
- // Context Menu State
- const [contextMenu, setContextMenu] = useState({
- visible: false, x: 0, y: 0, targetId: '', type: null
- });
-
- // System Refs
- const cyRef = useRef(null);
- const statusCheckIntervalRef = useRef(null);
- const saveTimeoutRef = useRef(null);
- const contextMenuRef = useRef(null);
- const fileInputRef = useRef(null);
-
- // Sync refs
- useEffect(() => { hostMapRef.current = hostMap; }, [hostMap]);
-
- // --- Pulsing Dot Overlay for Online Nodes Only ---
- useEffect(() => {
- if (!cyRef.current) return;
-
- const canvas = document.createElement('canvas');
- const container = cyRef.current.container();
- if (!container) return;
-
- canvas.style.position = 'absolute';
- canvas.style.top = '0';
- canvas.style.left = '0';
- canvas.style.pointerEvents = 'none';
- canvas.style.zIndex = '999';
- container.appendChild(canvas);
-
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- let animationFrame: number;
- let startTime = Date.now();
-
- const animate = () => {
- if (!cyRef.current || !ctx) return;
-
- const canvasWidth = cyRef.current.width();
- const canvasHeight = cyRef.current.height();
- canvas.width = canvasWidth;
- canvas.height = canvasHeight;
-
- ctx.clearRect(0, 0, canvasWidth, canvasHeight);
-
- const elapsed = Date.now() - startTime;
- const cycle = (elapsed % 2000) / 2000;
- const zoom = cyRef.current.zoom();
-
- // Scale pulse radius with zoom
- const baseRadius = 4;
- const pulseAmount = 3;
- const pulseRadius = (baseRadius + Math.sin(cycle * Math.PI * 2) * pulseAmount) * zoom;
- const pulseOpacity = 0.6 - Math.sin(cycle * Math.PI * 2) * 0.4;
-
- // Draw pulsing green dot for ONLINE nodes only
- cyRef.current.nodes('[status="online"]').forEach(node => {
- if (node.isParent()) return;
-
- // Use renderedPosition which accounts for zoom and pan
- const pos = node.renderedPosition();
- // Node dimensions at current zoom
- const nodeWidth = 180 * zoom;
- const nodeHeight = 90 * zoom;
-
- // Position dot at top-right corner (same relative position as SVG)
- const dotX = pos.x + (nodeWidth / 2) - (10 * zoom);
- const dotY = pos.y - (nodeHeight / 2) + (10 * zoom);
-
- // Draw pulsing ring
- ctx.beginPath();
- ctx.arc(dotX, dotY, pulseRadius, 0, Math.PI * 2);
- ctx.fillStyle = `rgba(16, 185, 129, ${pulseOpacity})`;
- ctx.fill();
-
- // Draw solid center dot
- ctx.beginPath();
- ctx.arc(dotX, dotY, baseRadius * zoom, 0, Math.PI * 2);
- ctx.fillStyle = '#10b981';
- ctx.fill();
- });
-
- // Draw static red dot for OFFLINE nodes (no pulsing)
- cyRef.current.nodes('[status="offline"]').forEach(node => {
- if (node.isParent()) return;
-
- const pos = node.renderedPosition();
- const nodeWidth = 180 * zoom;
- const nodeHeight = 90 * zoom;
-
- const dotX = pos.x + (nodeWidth / 2) - (10 * zoom);
- const dotY = pos.y - (nodeHeight / 2) + (10 * zoom);
-
- // Draw solid red dot (no animation)
- ctx.beginPath();
- ctx.arc(dotX, dotY, 4 * zoom, 0, Math.PI * 2);
- ctx.fillStyle = '#ef4444';
- ctx.fill();
- });
-
- animationFrame = requestAnimationFrame(animate);
- };
-
- animate();
-
- return () => {
- if (animationFrame) cancelAnimationFrame(animationFrame);
- if (container && canvas.parentNode === container) {
- container.removeChild(canvas);
- }
- };
- }, [elements]);
-
- // --- Initialization ---
-
- useEffect(() => {
- loadData();
- const interval = setInterval(updateHostStatuses, 30000);
- statusCheckIntervalRef.current = interval;
-
- const handleClickOutside = (e: MouseEvent) => {
- if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
- setContextMenu(prev => prev.visible ? { ...prev, visible: false } : prev);
- }
- };
- document.addEventListener('mousedown', handleClickOutside, true);
-
- return () => {
- if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
- document.removeEventListener('mousedown', handleClickOutside, true);
- };
- }, []);
-
- const loadData = async () => {
- try {
- setLoading(true);
- setError(null);
-
- const hostsData = await getSSHHosts();
- const hostsArray = Array.isArray(hostsData) ? hostsData : [];
- setHosts(hostsArray);
-
- const newHostMap: HostMap = {};
- hostsArray.forEach(host => { newHostMap[String(host.id)] = host; });
- setHostMap(newHostMap);
-
- let nodes: any[] = [];
- let edges: any[] = [];
-
- try {
- const topologyData = await getNetworkTopology();
- if (topologyData && topologyData.nodes && Array.isArray(topologyData.nodes)) {
- nodes = topologyData.nodes.map((node: any) => {
- const host = newHostMap[node.data.id];
- return {
- data: {
- id: node.data.id,
- label: host?.name || node.data.label || 'Unknown',
- ip: host ? `${host.ip}:${host.port}` : (node.data.ip || ''),
- status: host?.status || 'unknown',
- tags: host?.tags || [],
- parent: node.data.parent,
- color: node.data.color
- },
- position: node.position || { x: 0, y: 0 }
- };
- });
- edges = topologyData.edges || [];
- }
- } catch (topologyError) {
- console.warn('Starting with empty topology');
- }
-
- setElements([...nodes, ...edges]);
- } catch (err) {
- console.error('Failed to load topology:', err);
- setError('Failed to load data');
- } finally {
- setLoading(false);
- }
- };
-
- const updateHostStatuses = useCallback(async () => {
- if (!cyRef.current) return;
- try {
- const updatedHosts = await getSSHHosts();
- const updatedHostMap: HostMap = {};
- updatedHosts.forEach(host => { updatedHostMap[String(host.id)] = host; });
-
- cyRef.current.nodes().forEach(node => {
- if (node.isParent()) return;
- const hostId = node.data('id');
- const updatedHost = updatedHostMap[hostId];
- if (updatedHost) {
- node.data('status', updatedHost.status);
- node.data('tags', updatedHost.tags || []);
- }
- });
- setHostMap(updatedHostMap);
- } catch (err) {
- console.error('Status update failed:', err);
- }
- }, []);
-
- const debouncedSave = useCallback(() => {
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
- saveTimeoutRef.current = setTimeout(() => {
- saveCurrentLayout();
- }, 1000);
- }, []);
-
- const saveCurrentLayout = async () => {
- if (!cyRef.current) return;
- try {
- const nodes = cyRef.current.nodes().map(node => ({
- data: {
- id: node.data('id'),
- label: node.data('label'),
- ip: node.data('ip'),
- status: node.data('status'),
- tags: node.data('tags') || [],
- parent: node.data('parent'),
- color: node.data('color')
- },
- position: node.position()
- }));
-
- const edges = cyRef.current.edges().map(edge => ({
- data: {
- id: edge.data('id'),
- source: edge.data('source'),
- target: edge.data('target')
- }
- }));
-
- await saveNetworkTopology({ nodes, edges });
- } catch (err) {
- console.error('Save failed:', err);
- }
- };
-
- // --- Initial Layout ---
- useEffect(() => {
- if (!cyRef.current || loading || elements.length === 0) return;
- const hasPositions = elements.some((el: any) => el.position && (el.position.x !== 0 || el.position.y !== 0));
-
- if (!hasPositions) {
- cyRef.current.layout({
- name: 'cose',
- animate: false,
- randomize: true,
- componentSpacing: 100,
- nodeOverlap: 20
- }).run();
- } else {
- cyRef.current.fit();
- }
- }, [loading]);
-
- // --- Cytoscape Config ---
-
- const handleNodeInit = useCallback((cy: cytoscape.Core) => {
- cyRef.current = cy;
- cy.style()
-
- /* ===========================
- * NODE STYLE (Hosts)
- * ===========================
- */
- .selector('node')
- .style({
- 'label': '',
- 'width': '180px',
- 'height': '90px',
- 'shape': 'round-rectangle',
- 'border-width': '0px',
- 'background-opacity': 0,
-
- 'background-image': function(ele) {
- const host = ele.data();
- const name = host.label || '';
- const ip = host.ip || '';
- const tags = host.tags || [];
- const isOnline = host.status === 'online';
- const isOffline = host.status === 'offline';
- const statusColor =
- isOnline ? '#10b981' :
- isOffline ? '#ef4444' : '#64748b';
-
- const tagsHtml = tags.map(t => `
- ${t}`).join('');
-
- // Changed rx from 8 to 6 to match Dashboard's rounded-md (0.375rem = 6px)
- // Status indicator dot drawn on canvas overlay (see useEffect for pulsing animation)
- const svg = `
-
- `;
- return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
- },
-
- 'background-fit': 'contain'
- })
-
- /* ===========================
- * PARENT GROUP STYLE
- * ===========================
- */
- .selector('node:parent')
- .style({
- 'background-image': 'none',
- 'background-color': ele => ele.data('color') || '#1e3a8a',
- 'background-opacity': 0.05,
- 'border-color': ele => ele.data('color') || '#3b82f6',
- 'border-width': '2px',
- 'border-style': 'dashed',
- 'label': 'data(label)',
- 'text-valign': 'top',
- 'text-halign': 'center',
- 'text-margin-y': -20,
- 'color': '#94a3b8',
- 'font-size': '16px',
- 'font-weight': 'bold',
- 'shape': 'round-rectangle',
- 'padding': '10px'
- })
-
- /* ===========================
- * EDGE STYLE (Improved Bezier)
- * ===========================
- */
- .selector('edge')
- .style({
- 'width': '2px',
- 'line-color': '#373739',
-
- // Keep curves but make them smoother and cleaner
- 'curve-style': 'round-taxi',
-
- // Ensure edges connect at the border, not the center
- 'source-endpoint': 'outside-to-node',
- 'target-endpoint': 'outside-to-node',
-
- // Smoother curvature
- 'control-point-step-size': 10,
- 'control-point-distances': [40, -40],
- 'control-point-weights': [0.2, 0.8],
-
- // No arrowheads for now
- 'target-arrow-shape': 'none'
- })
-
- /* ===========================
- * INTERACTION STYLES
- * ===========================
- */
- .selector('edge:selected')
- .style({
- 'line-color': '#3b82f6',
- 'width': '3px'
- })
-
- .selector('node:selected')
- .style({
- 'overlay-color': '#3b82f6',
- 'overlay-opacity': 0.05,
- 'overlay-padding': '5px'
- });
- // --- EVENTS ---
-
- cy.on('tap', 'node', (evt) => {
- const node = evt.target;
- setContextMenu(prev => prev.visible ? { ...prev, visible: false } : prev);
- setSelectedEdgeId(null);
- setSelectedNodeId(node.id());
- // Dialog only opens from right-click context menu, not left-click
- });
-
- cy.on('tap', 'edge', (evt) => {
- evt.stopPropagation();
- setSelectedEdgeId(evt.target.id());
- setSelectedNodeId(null);
- });
-
- cy.on('tap', (evt) => {
- if (evt.target === cy) {
- setContextMenu(prev => prev.visible ? { ...prev, visible: false } : prev);
- setSelectedNodeId(null);
- setSelectedEdgeId(null);
- }
- });
-
- // Right Click -> Context Menu
- cy.on('cxttap', 'node', (evt) => {
- evt.preventDefault();
- evt.stopPropagation();
- const node = evt.target;
-
- const x = evt.originalEvent.clientX;
- const y = evt.originalEvent.clientY;
-
- setContextMenu({
- visible: true,
- x,
- y,
- targetId: node.id(),
- type: node.isParent() ? 'group' : 'node'
- });
- });
-
- cy.on('zoom pan', () => {
- setContextMenu(prev => prev.visible ? { ...prev, visible: false } : prev);
- });
-
- cy.on('free', 'node', () => debouncedSave());
-
- cy.on('boxselect', 'node', () => {
- const selected = cy.$('node:selected');
- if (selected.length === 1) setSelectedNodeId(selected[0].id());
- });
-
- }, [debouncedSave]);
-
- // --- Handlers ---
-
- const handleContextAction = (action: string) => {
- setContextMenu(prev => ({ ...prev, visible: false }));
- const targetId = contextMenu.targetId;
- if (!cyRef.current) return;
-
- if (action === 'details') {
- const host = hostMap[targetId];
- if (host) {
- setSelectedNodeForDetail(host);
- setShowNodeDetail(true);
- }
- } else if (action === 'connect') {
- const host = hostMap[targetId];
- if (host) {
- const title = host.name?.trim()
- ? host.name
- : `${host.username}@${host.ip}:${host.port}`;
- addTab({ type: 'terminal', title, hostConfig: host });
- }
-
- } else if (action === 'move') {
- setSelectedNodeId(targetId);
- const node = cyRef.current.$id(targetId);
- const parentId = node.data('parent');
- setSelectedGroupForMove(parentId || 'ROOT');
- setShowMoveNodeDialog(true);
- } else if (action === 'removeFromGroup') {
- const node = cyRef.current.$id(targetId);
- node.move({ parent: null });
- debouncedSave();
- } else if (action === 'editGroup') {
- const node = cyRef.current.$id(targetId);
- setEditingGroupId(targetId);
- setNewGroupName(node.data('label'));
- setNewGroupColor(node.data('color') || '#3b82f6');
- setShowEditGroupDialog(true);
- } else if (action === 'addHostToGroup') {
- setSelectedGroupForAddNode(targetId);
- setSelectedHostForAddNode('');
- setShowAddNodeDialog(true);
- } else if (action === 'delete') {
- cyRef.current.$id(targetId).remove();
- debouncedSave();
- }
- };
-
- const handleAddNode = () => {
- setSelectedHostForAddNode('');
- setSelectedGroupForAddNode('ROOT');
- setShowAddNodeDialog(true);
- };
-
- const handleConfirmAddNode = async () => {
- if (!cyRef.current || !selectedHostForAddNode) return;
- try {
- if (cyRef.current.$id(selectedHostForAddNode).length > 0) {
- setError('Host is already in the topology');
- return;
- }
- const host = hostMap[selectedHostForAddNode];
- const parent = selectedGroupForAddNode === 'ROOT' ? undefined : selectedGroupForAddNode;
-
- const newNode = {
- data: {
- id: selectedHostForAddNode,
- label: host.name || `${host.ip}`,
- ip: `${host.ip}:${host.port}`,
- status: host.status,
- tags: host.tags || [],
- parent: parent
- },
- position: { x: 100 + Math.random() * 50, y: 100 + Math.random() * 50 }
- };
- cyRef.current.add(newNode);
- await saveCurrentLayout();
- setShowAddNodeDialog(false);
- } catch (err) { setError('Failed to add node'); }
- };
-
- const handleAddGroup = async () => {
- if (!cyRef.current || !newGroupName) return;
- const groupId = `group-${Date.now()}`;
- cyRef.current.add({
- data: { id: groupId, label: newGroupName, color: newGroupColor }
- });
- await saveCurrentLayout();
- setShowAddGroupDialog(false);
- setNewGroupName('');
- };
-
- const handleUpdateGroup = async () => {
- if (!cyRef.current || !editingGroupId || !newGroupName) return;
- const group = cyRef.current.$id(editingGroupId);
- group.data('label', newGroupName);
- group.data('color', newGroupColor);
- await saveCurrentLayout();
- setShowEditGroupDialog(false);
- setEditingGroupId(null);
- };
-
- const handleMoveNodeToGroup = async () => {
- if (!cyRef.current || !selectedNodeId) return;
- const node = cyRef.current.$id(selectedNodeId);
- const parent = selectedGroupForMove === 'ROOT' ? null : selectedGroupForMove;
- node.move({ parent: parent });
- await saveCurrentLayout();
- setShowMoveNodeDialog(false);
- };
-
- const handleAddEdge = async () => {
- if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return;
- if (selectedHostForEdge === targetHostForEdge) return setError('Source and target must be different');
-
- const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`;
- if (cyRef.current.$id(edgeId).length > 0) return setError('Connection exists');
-
- cyRef.current.add({
- data: { id: edgeId, source: selectedHostForEdge, target: targetHostForEdge }
- });
- await saveCurrentLayout();
- setShowAddEdgeDialog(false);
- };
-
- const handleRemoveSelected = () => {
- if (!cyRef.current) return;
-
- if (selectedNodeId) {
- cyRef.current.$id(selectedNodeId).remove();
- setSelectedNodeId(null);
- } else if (selectedEdgeId) {
- cyRef.current.$id(selectedEdgeId).remove();
- setSelectedEdgeId(null);
- }
-
- debouncedSave();
- };
-
- // --- Helper Memos ---
-
- // Logic to detect groups directly from Elements state
- const availableGroups = useMemo(() => {
- // A group is a node with ID but no IP (since hosts have IPs) and is not an edge
- return elements.filter(el =>
- !el.data.source && !el.data.target && !el.data.ip && el.data.id
- ).map(el => ({ id: el.data.id, label: el.data.label }));
- }, [elements]);
-
- const availableNodesForConnection = useMemo(() => {
- return elements.filter(el => (!el.data.source && !el.data.target)).map(el => ({
- id: el.data.id,
- label: el.data.label
- }));
- }, [elements]);
-
- const availableHostsForAdd = useMemo(() => {
- if (!cyRef.current) return hosts;
- const existingIds = new Set(elements.map(e => e.data.id));
- return hosts.filter(h => !existingIds.has(String(h.id)));
- }, [hosts, elements]);
-
- // --- Render ---
-
- // Calculate margins for standalone mode (like Admin Settings and User Profile)
- const topMarginPx = isStandalone ? (isTopbarOpen ? 74 : 26) : 0;
- const leftMarginPx = isStandalone ? (sidebarState === "collapsed" ? 26 : 8) : 0;
- const bottomMarginPx = isStandalone ? 8 : 0;
- const wrapperStyle: React.CSSProperties = isStandalone ? {
- marginLeft: leftMarginPx,
- marginRight: rightSidebarOpen
- ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
- : 17,
- marginTop: topMarginPx,
- marginBottom: bottomMarginPx,
- height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
- transition:
- "margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
- } : {};
-
- const content = (
-
- {error && (
-
-
-
{error}
-
-
- )}
-
- {/* --- Toolbar --- */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- const file = e.target.files?.[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = (evt) => {
- try {
- const json = JSON.parse(evt.target?.result as string);
- saveNetworkTopology({nodes: json.nodes, edges: json.edges}).then(() => loadData());
- } catch(err) { setError("Invalid File"); }
- };
- reader.readAsText(file);
- }} className="hidden" />
-
-
-
-
-
-
-
-
- {/* --- Graph Area --- */}
-
- {loading && (
-
-
-
- )}
-
- {/* Context Menu - Fixed Position with High Z-Index */}
- {contextMenu.visible && (
-
- {contextMenu.type === 'node' && (
- <>
-
-
-
- {cyRef.current?.$id(contextMenu.targetId).parent().length ? (
-
- ) : null}
- >
- )}
-
- {contextMenu.type === 'group' && (
- <>
-
-
- >
- )}
-
-
-
-
- )}
-
-
-
-
- {/* --- Dialogs --- */}
-
-
-
-
-
-
-
-
-
-
-
- );
-
- // Wrap in standalone container if needed
- if (isStandalone) {
- return (
-
-
-
-
Network Graph
-
-
-
- {content}
-
-
-
- );
- }
-
- return content;
-};
-
-export { NetworkGraphView };
-export default NetworkGraphView;
diff --git a/src/ui/desktop/dashboard/network-graph/index.ts b/src/ui/desktop/dashboard/network-graph/index.ts
deleted file mode 100644
index eada22aa..00000000
--- a/src/ui/desktop/dashboard/network-graph/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as NetworkGraphView } from './NetworkGraphView';
diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx
index 775aef16..a10b5e9c 100644
--- a/src/ui/desktop/navigation/AppView.tsx
+++ b/src/ui/desktop/navigation/AppView.tsx
@@ -4,7 +4,7 @@ import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-sta
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
-import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx";
+import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
ResizablePanelGroup,
@@ -356,11 +356,10 @@ export function AppView({
embedded
/>
) : t.type === "network_graph" ? (
-
) : t.type === "tunnel" ? (
;
+ gridColumns: number;
+}
+
+export async function getDashboardPreferences(): Promise {
+ const response = await dashboardAxios.get("/dashboard/preferences");
+ return response.data;
+}
+
+export async function saveDashboardPreferences(
+ layout: DashboardLayout,
+): Promise<{ success: boolean }> {
+ const response = await dashboardAxios.post("/dashboard/preferences", layout);
+ return response.data;
+}