From 84ca8080f0029fc1864b318e996c6a6d345858bc Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:33:15 -0600 Subject: [PATCH 1/4] Add termix.rb Cask file --- {homebrew => Casks}/termix.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {homebrew => Casks}/termix.rb (100%) diff --git a/homebrew/termix.rb b/Casks/termix.rb similarity index 100% rename from homebrew/termix.rb rename to Casks/termix.rb From 403800f42bc6a74f9ab584daabf7b6beda4dee0a Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:36:24 -0600 Subject: [PATCH 2/4] Update Termix to version 1.9.0 with new checksum --- Casks/termix.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Casks/termix.rb b/Casks/termix.rb index 9522fa73..6f6d0d86 100644 --- a/Casks/termix.rb +++ b/Casks/termix.rb @@ -1,8 +1,8 @@ cask "termix" do - version "VERSION_PLACEHOLDER" - sha256 "CHECKSUM_PLACEHOLDER" + version "1.9.0" + sha256 "8fedd242b3cae1ebfd0c391a36f1c246a26ecac258b02478ee8dea2f33cd6d96" - url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_#{version}_dmg.dmg" + url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg" name "Termix" desc "Web-based server management platform with SSH terminal, tunneling, and file editing" homepage "https://github.com/Termix-SSH/Termix" From f0647dc7c1b693f551b087e9e8d42bdc1a3eec20 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:38:58 -0600 Subject: [PATCH 3/4] Update README to remove 'coming soon' notes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0e9c10f0..80fc69e9 100644 --- a/README.md +++ b/README.md @@ -80,16 +80,16 @@ Supported Devices: - Windows (x64/ia32) - Portable - MSI Installer - - Chocolatey Package Manager (coming soon) + - Chocolatey Package Manager - Linux (x64/ia32) - Portable - AppImage - Deb - - Flatpak (coming soon) + - Flatpak - macOS (x64/ia32 on v12.0+) - - Apple App Store (coming soon) + - Apple App Store - DMG - - Homebrew (coming soon) + - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - ISO From 8106999d1e8f31de63d1bbaf8c41d175ab847e4f Mon Sep 17 00:00:00 2001 From: Steven Josefs Date: Sun, 7 Dec 2025 20:50:03 +0100 Subject: [PATCH 4/4] Feature request network graph --- README.md | 60 +- docker/nginx-https.conf | 9 + docker/nginx.conf | 9 + package.json | 3 + src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 24 + src/backend/database/db/schema.ts | 14 + .../database/routes/network-topology.ts | 88 ++ src/main.tsx | 25 +- src/ui/desktop/DesktopApp.tsx | 45 +- src/ui/desktop/apps/HostManagerApp.tsx | 12 + src/ui/desktop/apps/NetworkGraphApp.tsx | 12 + src/ui/desktop/apps/dashboard/Dashboard.tsx | 120 ++- .../network-graph/NetworkGraphView.tsx | 922 ++++++++++++++++++ .../desktop/dashboard/network-graph/index.ts | 1 + src/ui/desktop/navigation/TopNavbar.tsx | 9 +- src/ui/desktop/navigation/tabs/Tab.tsx | 38 + .../desktop/navigation/tabs/TabDropdown.tsx | 5 + src/ui/main-axios.ts | 124 ++- 19 files changed, 1435 insertions(+), 87 deletions(-) create mode 100644 src/backend/database/routes/network-topology.ts create mode 100644 src/ui/desktop/apps/HostManagerApp.tsx create mode 100644 src/ui/desktop/apps/NetworkGraphApp.tsx create mode 100644 src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx create mode 100644 src/ui/desktop/dashboard/network-graph/index.ts diff --git a/README.md b/README.md index 80fc69e9..0128358a 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,63 @@ free and self-hosted alternative to Termius available for all platforms. - **Command History** - Auto-complete and view previously ran SSH commands - **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard - **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc. +- **Network Graph View** - Visualize your SSH hosts and their connections in an interactive graph. Drag nodes, pan, zoom, and manage your network topology. Export/import topology as JSON for backup and sharing. # Planned Features See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +# Network Graph View + +The Network Graph View is a powerful visualization tool available on the Dashboard that helps you manage and understand your SSH network topology. + +## Features + +- **Interactive Graph Visualization** - Visualize your SSH hosts and connections using Cytoscape.js with a force-directed layout algorithm +- **Host Management** - Add or remove hosts from the topology. All your SSH hosts are available for selection +- **Connection Management** - Create bidirectional connections (edges) between hosts to represent your network topology +- **Node Interactions** - + - Click nodes to view host details (name, address, status, tags) + - Drag nodes to reposition them manually + - Pan and zoom the graph for better visibility +- **Layout Options** - + - Automatic force-directed layout with a button to reset + - Manual node positioning with automatic position persistence +- **Status Indicators** - Nodes display color-coded status: + - **Green** - Host is online and reachable + - **Red** - Host is offline or unreachable + - **Gray** - Status unknown +- **Export/Import** - + - Export your topology as a JSON file for backup or sharing + - Import previously saved topology files +- **Graph Controls** - + - Add Host to Topology + - Add Connection between hosts + - Remove Node or Connection + - Auto-layout to reorganize graph + - Zoom In/Out for detail work + - Fit to Screen to see entire topology + +## How to Use + +1. Navigate to the Dashboard in Termix +2. Click the "Network Graph View" toggle to activate the graph view +3. Use the "+" button to add hosts from your SSH Host Manager +4. Use the connection button to add relationships between hosts +5. Drag nodes to position them as needed +6. Click any node to view detailed information about that host +7. Use Export to download your topology as JSON +8. Use Import to restore a previously saved topology + +## Limitations & Notes + +- The graph view only displays hosts that you've explicitly added to the topology. It does not automatically include all SSH hosts +- Connections are directional (from source to target host) +- Manual node positions are saved automatically when you drag nodes +- The graph updates host status every 30 seconds based on your SSH Host Manager data +- For best performance on mobile devices, limit the topology to 20-30 nodes +- The topology data is stored securely in your encrypted database alongside other SSH configurations + # Installation Supported Devices: @@ -80,16 +132,16 @@ Supported Devices: - Windows (x64/ia32) - Portable - MSI Installer - - Chocolatey Package Manager + - Chocolatey Package Manager (coming soon) - Linux (x64/ia32) - Portable - AppImage - Deb - - Flatpak + - Flatpak (coming soon) - macOS (x64/ia32 on v12.0+) - - Apple App Store + - Apple App Store (coming soon) - DMG - - Homebrew + - Homebrew (coming soon) - iOS/iPadOS (v15.1+) - Apple App Store - ISO diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 5e6126bf..2ffd549b 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -258,6 +258,15 @@ http { proxy_buffering off; } + location ~ ^/network-topology(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + 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 /health { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index db5546f0..8b07d773 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -255,6 +255,15 @@ http { proxy_buffering off; } + location ~ ^/network-topology(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + 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 /health { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/package.json b/package.json index a26dd5f8..7726990a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@tailwindcss/vite": "^4.1.14", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.9", + "@types/cytoscape": "^3.21.9", "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", @@ -73,6 +74,7 @@ "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "cytoscape": "^3.33.1", "dotenv": "^17.2.0", "drizzle-orm": "^0.44.3", "express": "^5.1.0", @@ -88,6 +90,7 @@ "node-fetch": "^3.3.2", "qrcode": "^1.5.4", "react": "^19.1.0", + "react-cytoscapejs": "^2.0.0", "react-dom": "^19.1.0", "react-h5-audio-player": "^3.10.1", "react-hook-form": "^7.60.0", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 1eca73d9..78d31055 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; import terminalRoutes from "./routes/terminal.js"; +import networkTopologyRoutes from "./routes/network-topology.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); app.use("/terminal", terminalRoutes); +app.use("/network-topology", networkTopologyRoutes); app.use( ( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index f9e1017f..cb2fc423 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -551,6 +551,30 @@ const migrateSchema = () => { } } + try { + sqlite + .prepare("SELECT id FROM network_topology LIMIT 1") + .get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS network_topology ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + topology TEXT, + 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 network_topology table", { + operation: "schema_migration", + error: createError, + }); + } + } + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 074b4103..989fb35b 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -276,3 +276,17 @@ export const commandHistory = sqliteTable("command_history", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const networkTopology = sqliteTable("network_topology", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + topology: text("topology"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/network-topology.ts b/src/backend/database/routes/network-topology.ts new file mode 100644 index 00000000..2522a259 --- /dev/null +++ b/src/backend/database/routes/network-topology.ts @@ -0,0 +1,88 @@ +import express from "express"; +import { eq } from "drizzle-orm"; +import { getDb } from "../db/index.js"; +import { networkTopology } from "../db/schema.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +router.get( + "/", + authenticateJWT, + async (req: express.Request, res: express.Response) => { + try { + const userId = (req as AuthenticatedRequest).userId; + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + + const db = getDb(); + const result = await db + .select() + .from(networkTopology) + .where(eq(networkTopology.userId, userId)); + + if (result.length > 0) { + const topologyStr = result[0].topology; + const topology = topologyStr ? JSON.parse(topologyStr) : null; + return res.json(topology); + } else { + return res.json(null); + } + } catch (error) { + console.error("Error fetching network topology:", error); + return res.status(500).json({ error: "Failed to fetch network topology", details: (error as Error).message }); + } + }, +); + +router.post( + "/", + authenticateJWT, + async (req: express.Request, res: express.Response) => { + try { + const userId = (req as AuthenticatedRequest).userId; + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + + const { topology } = req.body; + if (!topology) { + return res.status(400).json({ error: "Topology data is required" }); + } + + const db = getDb(); + + // Ensure topology is a string + const topologyStr = typeof topology === 'string' ? topology : JSON.stringify(topology); + + const existing = await db + .select() + .from(networkTopology) + .where(eq(networkTopology.userId, userId)); + + if (existing.length > 0) { + // Update existing record + await db + .update(networkTopology) + .set({ topology: topologyStr }) + .where(eq(networkTopology.userId, userId)); + } else { + // Insert new record + await db + .insert(networkTopology) + .values({ userId, topology: topologyStr }); + } + + return res.json({ success: true }); + } catch (error) { + console.error("Error saving network topology:", error); + return res.status(500).json({ error: "Failed to save network topology", details: (error as Error).message }); + } + }, +); + +export default router; diff --git a/src/main.tsx b/src/main.tsx index 53ff210e..21bf4543 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,22 @@ import { ThemeProvider } from "@/components/theme-provider"; import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx"; import "./i18n/i18n"; import { isElectron } from "./ui/main-axios.ts"; +import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx"; +import NetworkGraphApp from "./ui/desktop/apps/NetworkGraphApp.tsx"; + +const FullscreenApp: React.FC = () => { + const searchParams = new URLSearchParams(window.location.search); + const view = searchParams.get('view'); + + switch (view) { + case 'host-manager': + return ; + case 'network-graph': + return ; + default: + return ; + } +}; function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth); @@ -61,8 +77,15 @@ function RootApp() { const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera || ""; const isTermixMobile = /Termix-Mobile/.test(userAgent); + + const searchParams = new URLSearchParams(window.location.search); + const isFullscreen = searchParams.has('view'); const renderApp = () => { + if (isFullscreen) { + return ; + } + if (isElectron()) { return ; } @@ -94,7 +117,7 @@ function RootApp() { }} />
- {isElectron() && showVersionCheck ? ( + {isElectron() && showVersionCheck && !isFullscreen ? ( setShowVersionCheck(false)} isAuthenticated={false} diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index fb015997..56bd39ca 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; +import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph"; import { Toaster } from "@/components/ui/sonner.tsx"; import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { getUserInfo } from "@/ui/main-axios.ts"; @@ -28,7 +29,7 @@ function AppContent() { const [transitionPhase, setTransitionPhase] = useState< "idle" | "fadeOut" | "fadeIn" >("idle"); - const { currentTab, tabs } = useTabs(); + const { currentTab, tabs, addTab } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(400); @@ -60,6 +61,35 @@ function AppContent() { }; }, []); + useEffect(() => { + const path = window.location.pathname; + const match = path.match(/^\/hosts\/([a-zA-Z0-9_-]+)\/terminal$/); + if (match) { + const hostId = match[1]; + + const openTerminalForHost = async () => { + try { + const { getSSHHostById } = await import("@/ui/main-axios.ts"); + const host = await getSSHHostById(parseInt(hostId, 10)); + if (host) { + addTab({ + type: "terminal", + title: host.name || host.ip, + data: { + host, + initialCommand: "", + }, + }); + } + } catch (error) { + console.error("Failed to open terminal for host:", error); + } + }; + + openTerminalForHost(); + } + }, [addTab]); + useEffect(() => { const checkAuth = () => { setAuthLoading(true); @@ -105,8 +135,6 @@ function AppContent() { localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen)); }, [isTopbarOpen]); - const handleSelectView = () => {}; - const handleAuthSuccess = useCallback( (authData: { isAdmin: boolean; @@ -160,6 +188,7 @@ function AppContent() { const showSshManager = currentTabData?.type === "ssh_manager"; const showAdmin = currentTabData?.type === "admin"; const showProfile = currentTabData?.type === "user_profile"; + const showNetworkGraph = currentTabData?.type === "network_graph"; if (authLoading) { return ( @@ -191,7 +220,6 @@ function AppContent() { {!isAuthenticated && (
)} + {showNetworkGraph && ( +
+ +
+ )} + { + return ( +
+ {}} /> +
+ ); +}; + +export default HostManagerApp; diff --git a/src/ui/desktop/apps/NetworkGraphApp.tsx b/src/ui/desktop/apps/NetworkGraphApp.tsx new file mode 100644 index 00000000..e48419e6 --- /dev/null +++ b/src/ui/desktop/apps/NetworkGraphApp.tsx @@ -0,0 +1,12 @@ +import NetworkGraphView from "@/ui/desktop/dashboard/network-graph/NetworkGraphView"; +import React from "react"; + +const NetworkGraphApp: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default NetworkGraphApp; diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 836b95c0..68ec6ce8 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -1,4 +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"; @@ -60,7 +61,6 @@ export function Dashboard({ authLoading, onAuthSuccess, isTopbarOpen, - onSelectView, rightSidebarOpen = false, rightSidebarWidth = 400, }: DashboardProps): React.ReactElement { @@ -89,6 +89,7 @@ export function Dashboard({ Array<{ id: number; name: string; cpu: number | null; ram: number | null }> >([]); const [serverStatsLoading, setServerStatsLoading] = useState(true); + const [showNetworkGraph, setShowNetworkGraph] = useState(true); const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); @@ -158,7 +159,12 @@ export function Dashboard({ const versionInfo = await getVersionInfo(); setVersionText(`v${versionInfo.localVersion}`); - setVersionStatus(versionInfo.status || "up_to_date"); + if ( + versionInfo.status === "up_to_date" || + versionInfo.status === "requires_update" + ) { + setVersionStatus(versionInfo.status); + } try { await getDatabaseHealth(); @@ -578,50 +584,76 @@ export function Dashboard({

- - {t("dashboard.recentActivity")} + {showNetworkGraph ? ( + <> + + {t("dashboard.networkGraph", { defaultValue: "Network Graph" })} + + ) : ( + <> + + {t("dashboard.recentActivity")} + + )}

- -
-
- {recentActivityLoading ? ( -
- - {t("dashboard.loadingRecentActivity")} -
- ) : recentActivity.length === 0 ? ( -

- {t("dashboard.noRecentActivity")} -

- ) : ( - recentActivity.map((item) => ( - - )) - )} +
+ + +
+ {showNetworkGraph ? ( + + ) : ( +
+ {recentActivityLoading ? ( +
+ + {t("dashboard.loadingRecentActivity")} +
+ ) : recentActivity.length === 0 ? ( +

+ {t("dashboard.noRecentActivity")} +

+ ) : ( + recentActivity.map((item) => ( + + )) + )} +
+ )}
diff --git a/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx b/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx new file mode 100644 index 00000000..b78746e8 --- /dev/null +++ b/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx @@ -0,0 +1,922 @@ +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'; + +// --- 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; +} + +const NetworkGraphView: React.FC = () => { + const { t } = useTranslation(); + const { addTab } = useTabs(); + + // --- 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]); + + // --- 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 statusColor = + host.status === 'online' ? '#22c55e' : + (host.status === 'offline' ? '#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' + }) + + /* =========================== + * 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()); + + if (!node.isParent()) { + const currentHostMap = hostMapRef.current; + const host = currentHostMap[node.id()]; + if (host) { + setSelectedNodeForDetail(host); + setShowNodeDetail(true); + } + } + }); + + 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 --- + + return ( +
+ {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" /> +
+ +
+ +
+
+ +
+ Online + Offline +
+
+ + {/* --- 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" style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-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} + ))} +
+ )} +
+ )} + + + +
+
+
+ ); +}; + +export default NetworkGraphView; diff --git a/src/ui/desktop/dashboard/network-graph/index.ts b/src/ui/desktop/dashboard/network-graph/index.ts new file mode 100644 index 00000000..eada22aa --- /dev/null +++ b/src/ui/desktop/dashboard/network-graph/index.ts @@ -0,0 +1 @@ +export { default as NetworkGraphView } from './NetworkGraphView'; diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 2cb11ef4..8fd41456 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -379,7 +379,8 @@ export function TopNavbar({ ((tab.type === "home" || tab.type === "ssh_manager" || tab.type === "admin" || - tab.type === "user_profile") && + tab.type === "user_profile" || + tab.type === "network_graph") && isSplitScreenActive); const isHome = tab.type === "home"; const disableClose = isHome; @@ -486,7 +487,8 @@ export function TopNavbar({ isFileManager || isSshManager || isAdmin || - isUserProfile + isUserProfile || + tab.type === "network_graph" ? () => handleTabClose(tab.id) : undefined } @@ -500,7 +502,8 @@ export function TopNavbar({ isFileManager || isSshManager || isAdmin || - isUserProfile + isUserProfile || + tab.type === "network_graph" } disableActivate={disableActivate} disableSplit={disableSplit} diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index 18dc75e0..23824b37 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -10,6 +10,7 @@ import { Server as ServerIcon, Folder as FolderIcon, User as UserIcon, + Network, } from "lucide-react"; interface TabProps { @@ -273,5 +274,42 @@ export function Tab({ ); } + if (tabType === "network_graph") { + const displayTitle = title || "Network Graph"; + const { base, suffix } = splitTitle(displayTitle); + + return ( +
+
+ + {base} + {suffix && {suffix}} +
+ + {canClose && ( + + )} +
+ ); + } + return null; } diff --git a/src/ui/desktop/navigation/tabs/TabDropdown.tsx b/src/ui/desktop/navigation/tabs/TabDropdown.tsx index 578a550b..970fce58 100644 --- a/src/ui/desktop/navigation/tabs/TabDropdown.tsx +++ b/src/ui/desktop/navigation/tabs/TabDropdown.tsx @@ -15,6 +15,7 @@ import { Shield as AdminIcon, Network as SshManagerIcon, User as UserIcon, + Network, } from "lucide-react"; import { useTabs, type Tab } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTranslation } from "react-i18next"; @@ -39,6 +40,8 @@ export function TabDropdown(): React.ReactElement { return ; case "admin": return ; + case "network_graph": + return ; default: return ; } @@ -58,6 +61,8 @@ export function TabDropdown(): React.ReactElement { return tab.title || t("nav.sshManager"); case "admin": return tab.title || t("nav.admin"); + case "network_graph": + return tab.title || "Network Graph"; case "terminal": default: return tab.title || t("nav.terminal"); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 0bd7ee5b..96034f43 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1,4 +1,4 @@ -import axios, { AxiosError, type AxiosInstance } from "axios"; +import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"; import type { SSHHost, SSHHostData, @@ -33,6 +33,10 @@ export type ServerStatus = { lastChecked: string; }; +export type SSHHostWithStatus = SSHHost & { + status: "online" | "offline" | "unknown"; +}; + interface CpuMetrics { percent: number | null; cores: number | null; @@ -68,6 +72,8 @@ interface AuthResponse { is_oidc?: boolean; totp_enabled?: boolean; data_unlocked?: boolean; + requires_totp?: boolean; + temp_token?: string; } interface UserInfo { @@ -234,12 +240,12 @@ function createApiInstance( withCredentials: true, }); - instance.interceptors.request.use((config) => { + instance.interceptors.request.use((config: AxiosRequestConfig) => { const startTime = performance.now(); const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - (config as Record).startTime = startTime; - (config as Record).requestId = requestId; + (config as any).startTime = startTime; + (config as any).requestId = requestId; const method = config.method?.toUpperCase() || "UNKNOWN"; const url = config.url || "UNKNOWN"; @@ -287,11 +293,11 @@ function createApiInstance( }); instance.interceptors.response.use( - (response) => { + (response: AxiosResponse) => { const endTime = performance.now(); - const startTime = (response.config as Record).startTime; - const requestId = (response.config as Record).requestId; - const responseTime = Math.round(endTime - startTime); + const startTime = (response.config as any).startTime; + const requestId = (response.config as any).requestId; + const responseTime = Math.round(endTime - (startTime || endTime)); const method = response.config.method?.toUpperCase() || "UNKNOWN"; const url = response.config.url || "UNKNOWN"; @@ -325,26 +331,22 @@ function createApiInstance( return response; }, - (error: AxiosError) => { + (error: AxiosErrorExtended) => { const endTime = performance.now(); - const startTime = (error.config as Record | undefined) - ?.startTime; - const requestId = (error.config as Record | undefined) - ?.requestId; - const responseTime = startTime - ? Math.round(endTime - startTime) - : undefined; + const startTime = error.config?.startTime; + const requestId = error.config?.requestId; + const responseTime = startTime ? Math.round(endTime - startTime) : undefined; const method = error.config?.method?.toUpperCase() || "UNKNOWN"; const url = error.config?.url || "UNKNOWN"; const fullUrl = error.config ? `${error.config.baseURL}${url}` : url; const status = error.response?.status; const message = - (error.response?.data as Record)?.error || + (error.response?.data as { error?: string })?.error || (error as Error).message || "Unknown error"; const errorCode = - (error.response?.data as Record)?.code || error.code; + (error.response?.data as { code?: string })?.code || error.code; const context: LogContext = { requestId, @@ -443,6 +445,19 @@ export interface ServerConfig { lastUpdated: string; } +interface AxiosRequestConfigExtended extends AxiosRequestConfig { + startTime?: number; + requestId?: string; +} + +interface AxiosResponseExtended extends AxiosResponse { + config: AxiosRequestConfigExtended; +} + +interface AxiosErrorExtended extends AxiosError { + config?: AxiosRequestConfigExtended; +} + export async function getServerConfig(): Promise { if (!isElectron()) return null; @@ -494,6 +509,19 @@ export async function saveServerConfig(config: ServerConfig): Promise { } } +interface AxiosRequestConfigExtended extends AxiosRequestConfig { + startTime?: number; + requestId?: string; +} + +interface AxiosResponseExtended extends AxiosResponse { + config: AxiosRequestConfigExtended; +} + +interface AxiosErrorExtended extends AxiosError { + config?: AxiosRequestConfigExtended; +} + export async function testServerConnection( serverUrl: string, ): Promise<{ success: boolean; error?: string }> { @@ -826,12 +854,20 @@ function handleApiError(error: unknown, operation: string): never { // SSH HOST MANAGEMENT // ============================================================================ -export async function getSSHHosts(): Promise { +export async function getSSHHosts(): Promise { try { - const response = await sshHostApi.get("/db/host"); - return response.data; + const hostsResponse = await sshHostApi.get("/db/host"); + const hosts: SSHHost[] = hostsResponse.data; + + const statusesResponse = await getAllServerStatuses(); + const statuses = statusesResponse || {}; + + return hosts.map((host) => ({ + ...host, + status: statuses[host.id]?.status || "unknown", + })); } catch (error) { - handleApiError(error, "fetch SSH hosts"); + throw handleApiError(error, "fetch SSH hosts"); } } @@ -1977,9 +2013,12 @@ export async function loginUser( username: response.data.username, requires_totp: response.data.requires_totp, temp_token: response.data.temp_token, + is_oidc: response.data.is_oidc, + totp_enabled: response.data.totp_enabled, + data_unlocked: response.data.data_unlocked, }; } catch (error) { - handleApiError(error, "login user"); + throw handleApiError(error, "login user"); } } @@ -2874,14 +2913,32 @@ export async function executeSnippet( } } -export async function reorderSnippets( - snippets: Array<{ id: number; order: number; folder?: string }>, -): Promise<{ success: boolean; updated: number }> { +// ============================================================================ +// MISCELLANEOUS API CALLS +// ============================================================================ + +export interface NetworkTopologyData { + nodes: any[]; + edges: any[]; +} + +export async function getNetworkTopology(): Promise { try { - const response = await authApi.put("/snippets/reorder", { snippets }); + const response = await authApi.get("/network-topology/"); return response.data; } catch (error) { - throw handleApiError(error, "reorder snippets"); + throw handleApiError(error, "fetch network topology"); + } +} + +export async function saveNetworkTopology( + topology: NetworkTopologyData, +): Promise<{ success: boolean }> { + try { + const response = await authApi.post("/network-topology/", { topology }); + return response.data; + } catch (error) { + throw handleApiError(error, "save network topology"); } } @@ -2950,6 +3007,17 @@ export async function deleteSnippetFolder( } } +export async function reorderSnippets( + updates: Array<{ id: number; order: number; folder?: string }>, +): Promise<{ success: boolean }> { + try { + const response = await authApi.post("/snippets/reorder", { updates }); + return response.data; + } catch (error) { + throw handleApiError(error, "reorder snippets"); + } +} + // ============================================================================ // HOMEPAGE API // ============================================================================