diff --git a/homebrew/termix.rb b/Casks/termix.rb
similarity index 80%
rename from homebrew/termix.rb
rename to Casks/termix.rb
index 9522fa73..6f6d0d86 100644
--- a/homebrew/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"
diff --git a/README.md b/README.md
index 0e9c10f0..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:
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 = `
+
+ `;
+ 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 --- */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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
// ============================================================================