Feature request network graph

This commit is contained in:
Steven Josefs
2025-12-07 20:50:03 +01:00
parent f0647dc7c1
commit 8106999d1e
19 changed files with 1435 additions and 87 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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(
(

View File

@@ -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",
});

View File

@@ -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`),
});

View File

@@ -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;

View File

@@ -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 <HostManagerApp />;
case 'network-graph':
return <NetworkGraphApp />;
default:
return <DesktopApp />;
}
};
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 <FullscreenApp />;
}
if (isElectron()) {
return <DesktopApp />;
}
@@ -94,7 +117,7 @@ function RootApp() {
}}
/>
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
{isElectron() && showVersionCheck ? (
{isElectron() && showVersionCheck && !isFullscreen ? (
<ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false}

View File

@@ -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 && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
<Dashboard
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
@@ -202,7 +230,6 @@ function AppContent() {
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
@@ -222,7 +249,6 @@ function AppContent() {
{showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Dashboard
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
@@ -236,7 +262,6 @@ function AppContent() {
{showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<HostManager
onSelectView={handleSelectView}
isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab}
hostConfig={currentTabData?.hostConfig}
@@ -266,6 +291,12 @@ function AppContent() {
</div>
)}
{showNetworkGraph && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden flex flex-col pt-16">
<NetworkGraphView />
</div>
)}
<TopNavbar
isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen}

View File

@@ -0,0 +1,12 @@
import { HostManager } from "@/ui/desktop/apps/host-manager/HostManager";
import React from "react";
const HostManagerApp: React.FC = () => {
return (
<div className="w-full h-screen">
<HostManager isTopbarOpen={false} onSelectView={() => {}} />
</div>
);
};
export default HostManagerApp;

View File

@@ -0,0 +1,12 @@
import NetworkGraphView from "@/ui/desktop/dashboard/network-graph/NetworkGraphView";
import React from "react";
const NetworkGraphApp: React.FC = () => {
return (
<div className="w-full h-screen">
<NetworkGraphView />
</div>
);
};
export default NetworkGraphApp;

View File

@@ -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<boolean>(true);
const [showNetworkGraph, setShowNetworkGraph] = useState<boolean>(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({
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
<Clock className="mr-3" />
{t("dashboard.recentActivity")}
{showNetworkGraph ? (
<>
<Network className="mr-3" />
{t("dashboard.networkGraph", { defaultValue: "Network Graph" })}
</>
) : (
<>
<Clock className="mr-3" />
{t("dashboard.recentActivity")}
</>
)}
</p>
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
onClick={handleResetActivity}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
onClick={() => setShowNetworkGraph(!showNetworkGraph)}
>
{showNetworkGraph ? "Show Activity" : "Show Graph"}
</Button>
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
onClick={handleResetActivity}
>
{t("dashboard.reset")}
</Button>
</div>
</div>
{showNetworkGraph ? (
<NetworkGraphView />
) : (
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{t("dashboard.reset")}
</Button>
</div>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : recentActivity.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2
className="animate-spin mr-2"
size={16}
/>
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : recentActivity.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -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<any[]>([]);
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [hostMap, setHostMap] = useState<HostMap>({});
// Refs
const hostMapRef = useRef<HostMap>({});
// UI State
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(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<string>('');
const [selectedGroupForAddNode, setSelectedGroupForAddNode] = useState<string>('ROOT');
const [newGroupName, setNewGroupName] = useState('');
const [newGroupColor, setNewGroupColor] = useState('#3b82f6'); // Default Blue
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [selectedGroupForMove, setSelectedGroupForMove] = useState<string>('ROOT');
const [selectedHostForEdge, setSelectedHostForEdge] = useState<string>('');
const [targetHostForEdge, setTargetHostForEdge] = useState<string>('');
const [selectedNodeForDetail, setSelectedNodeForDetail] = useState<SSHHostWithStatus | null>(null);
// Context Menu State
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false, x: 0, y: 0, targetId: '', type: null
});
// System Refs
const cyRef = useRef<cytoscape.Core | null>(null);
const statusCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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 => `
<span style="
background-color:#f97316;
color:#fff;
padding:2px 8px;
border-radius:9999px;
font-size:9px;
font-weight:700;
margin:0 2px;
display:inline-block;
box-shadow:0 1px 2px rgba(0,0,0,0.3);
">${t}</span>`).join('');
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="90" viewBox="0 0 180 90">
<defs>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#000" flood-opacity="0.25"/>
</filter>
</defs>
<rect x="3" y="3" width="174" height="84" rx="8"
fill="#09090b" stroke="${statusColor}" stroke-width="2" filter="url(#shadow)"/>
<foreignObject x="8" y="8" width="164" height="74">
<div xmlns="http://www.w3.org/1999/xhtml"
style="color:#f1f5f9;text-align:center;font-family:sans-serif;
height:100%;display:flex;flex-direction:column;justify-content:center;
align-items:center;line-height:1.2;">
<div style="font-weight:700;font-size:14px;margin-bottom:2px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;">${name}</div>
<div style="font-weight:600;font-size:11px;color:#94a3b8;margin-bottom:6px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;">${ip}</div>
<div style="display:flex;flex-wrap:wrap;justify-content:center;align-items:center;">
${tagsHtml}
</div>
</div>
</foreignObject>
</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 (
<div className="w-full h-full flex flex-col bg-dark-bg-darker">
{error && (
<div className="absolute top-16 right-4 z-50 flex items-center gap-2 p-3 text-red-100 text-sm rounded shadow-lg animate-in slide-in-from-top-2" style={{backgroundColor: 'rgba(127, 29, 29, 0.9)', border: '1px solid rgb(185, 28, 28)'}}>
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
<button onClick={() => setError(null)} className="ml-2 hover:text-white"></button>
</div>
)}
{/* --- Toolbar --- */}
<div className="flex items-center justify-between p-2 border-b bg-dark-bg-panel backdrop-blur" style={{borderColor: 'var(--color-dark-border)'}}>
<div className="flex items-center gap-4">
<div className="flex items-center rounded-md border p-0.5" style={{backgroundColor: 'var(--color-dark-bg-button)', borderColor: 'var(--color-dark-border)'}}>
<Button variant="ghost" size="sm" onClick={handleAddNode} title="Add Host" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Plus className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => { setNewGroupName(''); setNewGroupColor('#3b82f6'); setShowAddGroupDialog(true); }} title="Add Group" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<FolderPlus className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowAddEdgeDialog(true)} title="Add Link" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Link2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleRemoveSelected} disabled={!selectedNodeId && !selectedEdgeId} title="Delete Selected" className="h-8 w-8 p-0 rounded text-red-400 hover:text-red-300 disabled:opacity-30" onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center rounded-md border p-0.5" style={{backgroundColor: 'var(--color-dark-bg-button)', borderColor: 'var(--color-dark-border)'}}>
<Button variant="ghost" size="sm" onClick={() => cyRef.current?.layout({name: 'cose', animate: true}).run()} title="Auto Layout" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Move3D className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => cyRef.current?.zoom(cyRef.current.zoom() * 1.2)} title="Zoom In" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => cyRef.current?.zoom(cyRef.current.zoom() / 1.2)} title="Zoom Out" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => cyRef.current?.fit()} title="Reset View" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<RotateCw className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center rounded-md border p-0.5" style={{backgroundColor: 'var(--color-dark-bg-button)', borderColor: 'var(--color-dark-border)'}}>
<Button variant="ghost" size="sm" onClick={() => {
if (!cyRef.current) return;
const json = JSON.stringify(cyRef.current.json().elements);
const blob = new Blob([json], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'network.json'; a.click();
}} title="Export JSON" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import JSON" className="h-8 w-8 p-0 rounded" style={{color: 'var(--color-dark-border-light)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Upload className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" accept=".json" onChange={(e) => {
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" />
</div>
<div className="flex items-center rounded-md border p-0.5" style={{backgroundColor: 'var(--color-dark-bg-button)', borderColor: 'var(--color-dark-border)'}}>
<Button variant="ghost" size="sm" onClick={() => {
addTab({ type: 'network_graph', title: 'Network Graph' });
}} title="Open in new tab" className="h-8 w-8 p-0 rounded hover:bg-slate-800 text-slate-300">
<ExternalLink className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className="bg-green-600 hover:bg-green-700 text-white border-0 px-2 py-0.5 h-6">Online</Badge>
<Badge className="bg-red-600 hover:bg-red-700 text-white border-0 px-2 py-0.5 h-6">Offline</Badge>
</div>
</div>
{/* --- Graph Area --- */}
<div className="flex-1 relative overflow-hidden" style={{backgroundColor: 'var(--color-dark-bg-darkest)'}}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
)}
{/* Context Menu - Fixed Position with High Z-Index */}
{contextMenu.visible && (
<div
ref={contextMenuRef}
className="fixed z-[100] min-w-[180px] rounded-md shadow-2xl p-1 flex flex-col gap-0.5 animate-in fade-in zoom-in-95 duration-100"
style={{ top: contextMenu.y, left: contextMenu.x, backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)', border: '1px solid var(--color-dark-border)', color: 'var(--color-dark-border-light)' }}
>
{contextMenu.type === 'node' && (
<>
<button onClick={() => handleContextAction('connect')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors text-white" style={{color: '#ffffff'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Terminal className="w-3.5 h-3.5 text-green-400" /> Connect to Host
</button>
<button onClick={() => handleContextAction('details')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors text-white" style={{color: '#ffffff'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<Settings2 className="w-3.5 h-3.5 text-blue-400" /> Host Details
</button>
<button onClick={() => handleContextAction('move')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors text-white" style={{color: '#ffffff'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<FolderInput className="w-3.5 h-3.5 text-yellow-400" /> Move to Group...
</button>
{cyRef.current?.$id(contextMenu.targetId).parent().length ? (
<button onClick={() => handleContextAction('removeFromGroup')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors text-white" style={{color: '#ffffff'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<FolderMinus className="w-3.5 h-3.5 text-orange-400" /> Remove from Group
</button>
) : null}
</>
)}
{contextMenu.type === 'group' && (
<>
<button onClick={() => handleContextAction('addHostToGroup')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded hover:bg-slate-800 text-left w-full transition-colors">
<FolderPlus className="w-3.5 h-3.5 text-green-400" /> Add Host Here
</button>
<button onClick={() => handleContextAction('editGroup')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded hover:bg-slate-800 text-left w-full transition-colors">
<Edit className="w-3.5 h-3.5 text-blue-400" /> Edit Group
</button>
</>
)}
<div className="h-px bg-slate-800 my-1" />
<button onClick={() => handleContextAction('delete')} className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded hover:bg-red-950 text-red-400 hover:text-red-300 text-left w-full transition-colors">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
)}
<CytoscapeComponent
elements={elements}
style={{ width: '100%', height: '100%' }}
layout={{ name: 'preset' }}
cy={handleNodeInit}
wheelSensitivity={0.1}
minZoom={0.2}
maxZoom={3}
/>
</div>
{/* --- Dialogs --- */}
<Dialog open={showAddNodeDialog} onOpenChange={setShowAddNodeDialog}>
<DialogContent className="text-slate-200" style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}}>
<DialogHeader><DialogTitle>Add Host</DialogTitle></DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Select Host</Label>
<Select value={selectedHostForAddNode} onValueChange={setSelectedHostForAddNode}>
<SelectTrigger className="bg-slate-800 border-slate-700"><SelectValue placeholder="Choose a host..." /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{availableHostsForAdd.length > 0 ? availableHostsForAdd.map(h => (
<SelectItem key={h.id} value={String(h.id)}>{h.name || h.ip}</SelectItem>
)) : <SelectItem value="NONE" disabled>No available hosts</SelectItem>}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Parent Group</Label>
<Select value={selectedGroupForAddNode} onValueChange={setSelectedGroupForAddNode}>
<SelectTrigger className="bg-slate-800 border-slate-700"><SelectValue placeholder="No Group (Root)" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
<SelectItem value="ROOT">No Group</SelectItem>
{availableGroups.map(g => (
<SelectItem key={g.id} value={g.id}>{g.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddNodeDialog(false)} className="border-slate-700 hover:bg-slate-800 hover:text-slate-200">Cancel</Button>
<Button onClick={handleConfirmAddNode} disabled={!selectedHostForAddNode} className="bg-blue-600 hover:bg-blue-700 text-white">Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAddGroupDialog || showEditGroupDialog} onOpenChange={(open) => {
if(!open) { setShowAddGroupDialog(false); setShowEditGroupDialog(false); }
}}>
<DialogContent className="text-slate-200" style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}}>
<DialogHeader>
<DialogTitle>{showEditGroupDialog ? 'Edit Group' : 'Create Group'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Group Name</Label>
<Input value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} placeholder="e.g. Cluster A" style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-dark-border)'}} />
</div>
<div className="grid gap-2">
<Label>Color</Label>
<div className="flex gap-2 items-center p-2 rounded border" style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-dark-border)'}}>
<input
type="color"
value={newGroupColor}
onChange={(e) => setNewGroupColor(e.target.value)}
className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent"
/>
<span className="text-sm text-muted-foreground uppercase">{newGroupColor}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowAddGroupDialog(false); setShowEditGroupDialog(false); }} style={{borderColor: 'var(--color-dark-border)', backgroundColor: 'var(--color-dark-bg-button)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-bg-button)'}>Cancel</Button>
<Button onClick={showEditGroupDialog ? handleUpdateGroup : handleAddGroup} disabled={!newGroupName} className="bg-blue-600 hover:bg-blue-700 text-white">
{showEditGroupDialog ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showMoveNodeDialog} onOpenChange={setShowMoveNodeDialog}>
<DialogContent className="text-slate-200" style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}}>
<DialogHeader><DialogTitle>Move to Group</DialogTitle></DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Select Group</Label>
<Select value={selectedGroupForMove} onValueChange={setSelectedGroupForMove}>
<SelectTrigger style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-dark-border)'}}><SelectValue placeholder="Select group..." /></SelectTrigger>
<SelectContent style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}} className="text-slate-200">
<SelectItem value="ROOT">(No Group)</SelectItem>
{availableGroups.map(g => (
<SelectItem key={g.id} value={g.id} disabled={g.id === selectedNodeId}>{g.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMoveNodeDialog(false)} style={{borderColor: 'var(--color-dark-border)', backgroundColor: 'var(--color-dark-bg-button)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-bg-button)'}>Cancel</Button>
<Button onClick={handleMoveNodeToGroup} className="bg-blue-600 hover:bg-blue-700 text-white">Move</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAddEdgeDialog} onOpenChange={setShowAddEdgeDialog}>
<DialogContent className="text-slate-200" style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}}>
<DialogHeader><DialogTitle>Add Connection</DialogTitle></DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Source</Label>
<Select value={selectedHostForEdge} onValueChange={setSelectedHostForEdge}>
<SelectTrigger style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-dark-border)'}}><SelectValue placeholder="Select Source..." /></SelectTrigger>
<SelectContent style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}} className="text-slate-200">
{availableNodesForConnection.map(el => (
<SelectItem key={el.id} value={el.id}>{el.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Target</Label>
<Select value={targetHostForEdge} onValueChange={setTargetHostForEdge}>
<SelectTrigger style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-dark-border)'}}><SelectValue placeholder="Select Target..." /></SelectTrigger>
<SelectContent style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}} className="text-slate-200">
{availableNodesForConnection.map(el => (
<SelectItem key={el.id} value={el.id}>{el.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddEdgeDialog(false)} style={{borderColor: 'var(--color-dark-border)', backgroundColor: 'var(--color-dark-bg-button)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-bg-button)'}>Cancel</Button>
<Button onClick={handleAddEdge} className="bg-blue-600 hover:bg-blue-700 text-white">Connect</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNodeDetail} onOpenChange={setShowNodeDetail}>
<DialogContent className="text-slate-200" style={{backgroundColor: 'var(--color-dark-bg-panel)', borderColor: 'var(--color-dark-border)'}}>
<DialogHeader><DialogTitle>Host Details</DialogTitle></DialogHeader>
{selectedNodeForDetail && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="font-semibold" style={{color: 'var(--color-dark-border-light)'}}>Name:</span> <span>{selectedNodeForDetail.name}</span>
<span className="font-semibold" style={{color: 'var(--color-dark-border-light)'}}>IP:</span> <span>{selectedNodeForDetail.ip}</span>
<span className="font-semibold" style={{color: 'var(--color-dark-border-light)'}}>Status:</span>
<span className={selectedNodeForDetail.status === 'online' ? 'text-green-500' : 'text-red-500'}>
{selectedNodeForDetail.status}
</span>
<span className="font-semibold" style={{color: 'var(--color-dark-border-light)'}}>ID:</span> <span className="text-xs" style={{color: 'var(--color-dark-border-medium)'}}>{selectedNodeForDetail.id}</span>
</div>
{selectedNodeForDetail.tags && selectedNodeForDetail.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{selectedNodeForDetail.tags.map(t => (
<Badge key={t} variant="outline" className="text-xs" style={{borderColor: 'var(--color-dark-border-medium)', color: 'var(--color-dark-border-light)'}}>{t}</Badge>
))}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowNodeDetail(false)} style={{borderColor: 'var(--color-dark-border)', backgroundColor: 'var(--color-dark-bg-button)'}} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-dark-bg-button)'}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default NetworkGraphView;

View File

@@ -0,0 +1 @@
export { default as NetworkGraphView } from './NetworkGraphView';

View File

@@ -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}

View File

@@ -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 (
<div
className={cn(tabBaseClasses, "cursor-pointer")}
onClick={!disableActivate ? onActivate : undefined}
style={{
marginBottom: "-2px",
borderBottom: isActive ? "2px solid white" : "none",
}}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<Network className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-sm flex-1 min-w-0">{base}</span>
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
</div>
{canClose && (
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", disableClose && "opacity-50")}
onClick={(e) => {
e.stopPropagation();
if (!disableClose && onClose) onClose();
}}
disabled={disableClose}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
);
}
return null;
}

View File

@@ -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 <SshManagerIcon className="h-4 w-4" />;
case "admin":
return <AdminIcon className="h-4 w-4" />;
case "network_graph":
return <Network className="h-4 w-4" />;
default:
return <TerminalIcon className="h-4 w-4" />;
}
@@ -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");

View File

@@ -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<string, unknown>).startTime = startTime;
(config as Record<string, unknown>).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<string, unknown>).startTime;
const requestId = (response.config as Record<string, unknown>).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<string, unknown> | undefined)
?.startTime;
const requestId = (error.config as Record<string, unknown> | 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<string, unknown>)?.error ||
(error.response?.data as { error?: string })?.error ||
(error as Error).message ||
"Unknown error";
const errorCode =
(error.response?.data as Record<string, unknown>)?.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<ServerConfig | null> {
if (!isElectron()) return null;
@@ -494,6 +509,19 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
}
}
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<SSHHost[]> {
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
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<NetworkTopologyData | null> {
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
// ============================================================================