From c7872770a1d33e51b0f03cbdac26d0cfeb5b71c1 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 16 Jan 2026 03:08:10 -0600 Subject: [PATCH] feat: begin dashboard overhaul by splitting into cards and adding customization --- docker/nginx-https.conf | 9 + docker/nginx.conf | 9 + package.json | 2 +- src/backend/dashboard.ts | 171 ++- src/backend/database/db/index.ts | 24 + src/backend/database/db/schema.ts | 15 + src/locales/en.json | 15 +- src/ui/desktop/DesktopApp.tsx | 5 +- src/ui/desktop/apps/NetworkGraphApp.tsx | 6 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 541 ++----- .../apps/dashboard/cards/NetworkGraphCard.tsx | 1261 +++++++++++++++++ .../apps/dashboard/cards/QuickActionsCard.tsx | 141 ++ .../dashboard/cards/RecentActivityCard.tsx | 97 ++ .../dashboard/cards/ServerOverviewCard.tsx | 142 ++ .../apps/dashboard/cards/ServerStatsCard.tsx | 80 ++ .../components/DashboardSettingsDialog.tsx | 164 +++ .../hooks/useDashboardPreferences.ts | 76 + .../network-graph/NetworkGraphView.tsx | 1072 -------------- .../desktop/dashboard/network-graph/index.ts | 1 - src/ui/desktop/navigation/AppView.tsx | 5 +- src/ui/main-axios.ts | 17 + 21 files changed, 2328 insertions(+), 1525 deletions(-) create mode 100644 src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx create mode 100644 src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx create mode 100644 src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx create mode 100644 src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx create mode 100644 src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx create mode 100644 src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx create mode 100644 src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts delete mode 100644 src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx delete mode 100644 src/ui/desktop/dashboard/network-graph/index.ts 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")} -

-
- -
-

- {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 = ` + + + + + + + + +
+
${name}
+
${ip}
+
+ ${tagsHtml} +
+
+
+
+ `; + 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" && ( + <> + + + + )} + +
+ +
+ )} + + +
+ + + + + Add Host + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + { + if (!open) { + setShowAddGroupDialog(false); + setShowEditGroupDialog(false); + } + }} + > + + + + {showEditGroupDialog ? "Edit Group" : "Create Group"} + + +
+
+ + setNewGroupName(e.target.value)} + placeholder="e.g. Cluster A" + className="border-2 border-edge" + /> +
+
+ +
+ setNewGroupColor(e.target.value)} + className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent" + /> + + {newGroupColor} + +
+
+
+ + + + +
+
+ + + + + Move to Group + +
+
+ + +
+
+ + + + +
+
+ + + + + Add Connection + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + + + + Host Details + + {selectedNodeForDetail && ( +
+
+ Name: + {selectedNodeForDetail.name} + IP: + {selectedNodeForDetail.ip} + Status: + + {selectedNodeForDetail.status} + + ID: + + {selectedNodeForDetail.id} + +
+ {selectedNodeForDetail.tags && + selectedNodeForDetail.tags.length > 0 && ( +
+ {selectedNodeForDetail.tags.map((t) => ( + + {t} + + ))} +
+ )} +
+ )} + + + +
+
+
+
+ ); +} 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")} +

+
+ +
+

{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 ( + + + + {t("dashboard.dashboardSettings")} + + {t("dashboard.customizeLayout")} + + + +
+
+ +
+ {layout.cards.map((card) => ( +
+ + handleCardToggle(card.id, checked === true) + } + /> + +
+ ))} +
+
+ +
+ + +
+
+ + + + + + +
+
+ ); +} 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 = ` - - - - - - - - -
-
${name}
-
${ip}
-
- ${tagsHtml} -
-
-
-
- `; - 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 --- */} - - - - Add Host -
-
- - -
-
- - -
-
- - - - -
-
- - { - if(!open) { setShowAddGroupDialog(false); setShowEditGroupDialog(false); } - }}> - - - {showEditGroupDialog ? 'Edit Group' : 'Create Group'} - -
-
- - setNewGroupName(e.target.value)} placeholder="e.g. Cluster A" className="bg-dark-bg-input border-dark-border" /> -
-
- -
- setNewGroupColor(e.target.value)} - className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent" - /> - {newGroupColor} -
-
-
- - - - -
-
- - - - Move to Group -
-
- - -
-
- - - - -
-
- - - - Add Connection -
-
- - -
-
- - -
-
- - - - -
-
- - - - Host Details - {selectedNodeForDetail && ( -
-
- Name: {selectedNodeForDetail.name} - IP: {selectedNodeForDetail.ip} - Status: - - {selectedNodeForDetail.status} - - ID: {selectedNodeForDetail.id} -
- {selectedNodeForDetail.tags && selectedNodeForDetail.tags.length > 0 && ( -
- {selectedNodeForDetail.tags.map(t => ( - {t} - ))} -
- )} -
- )} - - - -
-
-
- ); - - // 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; +}