Feature request network graph
This commit is contained in:
60
README.md
60
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 History** - Auto-complete and view previously ran SSH commands
|
||||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
- **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.
|
- **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
|
# 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).
|
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
|
# Installation
|
||||||
|
|
||||||
Supported Devices:
|
Supported Devices:
|
||||||
@@ -80,16 +132,16 @@ Supported Devices:
|
|||||||
- Windows (x64/ia32)
|
- Windows (x64/ia32)
|
||||||
- Portable
|
- Portable
|
||||||
- MSI Installer
|
- MSI Installer
|
||||||
- Chocolatey Package Manager
|
- Chocolatey Package Manager (coming soon)
|
||||||
- Linux (x64/ia32)
|
- Linux (x64/ia32)
|
||||||
- Portable
|
- Portable
|
||||||
- AppImage
|
- AppImage
|
||||||
- Deb
|
- Deb
|
||||||
- Flatpak
|
- Flatpak (coming soon)
|
||||||
- macOS (x64/ia32 on v12.0+)
|
- macOS (x64/ia32 on v12.0+)
|
||||||
- Apple App Store
|
- Apple App Store (coming soon)
|
||||||
- DMG
|
- DMG
|
||||||
- Homebrew
|
- Homebrew (coming soon)
|
||||||
- iOS/iPadOS (v15.1+)
|
- iOS/iPadOS (v15.1+)
|
||||||
- Apple App Store
|
- Apple App Store
|
||||||
- ISO
|
- ISO
|
||||||
|
|||||||
@@ -258,6 +258,15 @@ http {
|
|||||||
proxy_buffering off;
|
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 {
|
location /health {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -255,6 +255,15 @@ http {
|
|||||||
proxy_buffering off;
|
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 {
|
location /health {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/cytoscape": "^3.21.9",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cytoscape": "^3.33.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-orm": "^0.44.3",
|
"drizzle-orm": "^0.44.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@@ -88,6 +90,7 @@
|
|||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-h5-audio-player": "^3.10.1",
|
"react-h5-audio-player": "^3.10.1",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
|
|||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes);
|
|||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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", {
|
databaseLogger.success("Schema migration completed", {
|
||||||
operation: "schema_migration",
|
operation: "schema_migration",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -276,3 +276,17 @@ export const commandHistory = sqliteTable("command_history", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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`),
|
||||||
|
});
|
||||||
|
|||||||
88
src/backend/database/routes/network-topology.ts
Normal file
88
src/backend/database/routes/network-topology.ts
Normal 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;
|
||||||
25
src/main.tsx
25
src/main.tsx
@@ -8,6 +8,22 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
||||||
import "./i18n/i18n";
|
import "./i18n/i18n";
|
||||||
import { isElectron } from "./ui/main-axios.ts";
|
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() {
|
function useWindowWidth() {
|
||||||
const [width, setWidth] = useState(window.innerWidth);
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
@@ -61,8 +77,15 @@ function RootApp() {
|
|||||||
const userAgent =
|
const userAgent =
|
||||||
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
||||||
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const isFullscreen = searchParams.has('view');
|
||||||
|
|
||||||
const renderApp = () => {
|
const renderApp = () => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
return <FullscreenApp />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
return <DesktopApp />;
|
return <DesktopApp />;
|
||||||
}
|
}
|
||||||
@@ -94,7 +117,7 @@ function RootApp() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
|
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
|
||||||
{isElectron() && showVersionCheck ? (
|
{isElectron() && showVersionCheck && !isFullscreen ? (
|
||||||
<ElectronVersionCheck
|
<ElectronVersionCheck
|
||||||
onContinue={() => setShowVersionCheck(false)}
|
onContinue={() => setShowVersionCheck(false)}
|
||||||
isAuthenticated={false}
|
isAuthenticated={false}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
|||||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
||||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.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 { Toaster } from "@/components/ui/sonner.tsx";
|
||||||
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
||||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||||
@@ -28,7 +29,7 @@ function AppContent() {
|
|||||||
const [transitionPhase, setTransitionPhase] = useState<
|
const [transitionPhase, setTransitionPhase] = useState<
|
||||||
"idle" | "fadeOut" | "fadeIn"
|
"idle" | "fadeOut" | "fadeIn"
|
||||||
>("idle");
|
>("idle");
|
||||||
const { currentTab, tabs } = useTabs();
|
const { currentTab, tabs, addTab } = useTabs();
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
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(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
@@ -105,8 +135,6 @@ function AppContent() {
|
|||||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||||
}, [isTopbarOpen]);
|
}, [isTopbarOpen]);
|
||||||
|
|
||||||
const handleSelectView = () => {};
|
|
||||||
|
|
||||||
const handleAuthSuccess = useCallback(
|
const handleAuthSuccess = useCallback(
|
||||||
(authData: {
|
(authData: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -160,6 +188,7 @@ function AppContent() {
|
|||||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||||
const showAdmin = currentTabData?.type === "admin";
|
const showAdmin = currentTabData?.type === "admin";
|
||||||
const showProfile = currentTabData?.type === "user_profile";
|
const showProfile = currentTabData?.type === "user_profile";
|
||||||
|
const showNetworkGraph = currentTabData?.type === "network_graph";
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +220,6 @@ function AppContent() {
|
|||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||||
<Dashboard
|
<Dashboard
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
authLoading={authLoading}
|
authLoading={authLoading}
|
||||||
onAuthSuccess={handleAuthSuccess}
|
onAuthSuccess={handleAuthSuccess}
|
||||||
@@ -202,7 +230,6 @@ function AppContent() {
|
|||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
onSelectView={handleSelectView}
|
|
||||||
disabled={!isAuthenticated || authLoading}
|
disabled={!isAuthenticated || authLoading}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
username={username}
|
username={username}
|
||||||
@@ -222,7 +249,6 @@ function AppContent() {
|
|||||||
{showHome && (
|
{showHome && (
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
<Dashboard
|
<Dashboard
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
authLoading={authLoading}
|
authLoading={authLoading}
|
||||||
onAuthSuccess={handleAuthSuccess}
|
onAuthSuccess={handleAuthSuccess}
|
||||||
@@ -236,7 +262,6 @@ function AppContent() {
|
|||||||
{showSshManager && (
|
{showSshManager && (
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
<HostManager
|
<HostManager
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
initialTab={currentTabData?.initialTab}
|
initialTab={currentTabData?.initialTab}
|
||||||
hostConfig={currentTabData?.hostConfig}
|
hostConfig={currentTabData?.hostConfig}
|
||||||
@@ -266,6 +291,12 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNetworkGraph && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden flex flex-col pt-16">
|
||||||
|
<NetworkGraphView />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TopNavbar
|
<TopNavbar
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
setIsTopbarOpen={setIsTopbarOpen}
|
setIsTopbarOpen={setIsTopbarOpen}
|
||||||
|
|||||||
12
src/ui/desktop/apps/HostManagerApp.tsx
Normal file
12
src/ui/desktop/apps/HostManagerApp.tsx
Normal 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;
|
||||||
12
src/ui/desktop/apps/NetworkGraphApp.tsx
Normal file
12
src/ui/desktop/apps/NetworkGraphApp.tsx
Normal 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;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
|
||||||
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
||||||
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
|
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
|
||||||
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
||||||
@@ -60,7 +61,6 @@ export function Dashboard({
|
|||||||
authLoading,
|
authLoading,
|
||||||
onAuthSuccess,
|
onAuthSuccess,
|
||||||
isTopbarOpen,
|
isTopbarOpen,
|
||||||
onSelectView,
|
|
||||||
rightSidebarOpen = false,
|
rightSidebarOpen = false,
|
||||||
rightSidebarWidth = 400,
|
rightSidebarWidth = 400,
|
||||||
}: DashboardProps): React.ReactElement {
|
}: DashboardProps): React.ReactElement {
|
||||||
@@ -89,6 +89,7 @@ export function Dashboard({
|
|||||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||||
>([]);
|
>([]);
|
||||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||||
|
const [showNetworkGraph, setShowNetworkGraph] = useState<boolean>(true);
|
||||||
|
|
||||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||||
|
|
||||||
@@ -158,7 +159,12 @@ export function Dashboard({
|
|||||||
|
|
||||||
const versionInfo = await getVersionInfo();
|
const versionInfo = await getVersionInfo();
|
||||||
setVersionText(`v${versionInfo.localVersion}`);
|
setVersionText(`v${versionInfo.localVersion}`);
|
||||||
setVersionStatus(versionInfo.status || "up_to_date");
|
if (
|
||||||
|
versionInfo.status === "up_to_date" ||
|
||||||
|
versionInfo.status === "requires_update"
|
||||||
|
) {
|
||||||
|
setVersionStatus(versionInfo.status);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getDatabaseHealth();
|
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-col mx-3 my-2 flex-1 overflow-hidden">
|
||||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||||
<p className="text-xl font-semibold flex flex-row items-center">
|
<p className="text-xl font-semibold flex flex-row items-center">
|
||||||
<Clock className="mr-3" />
|
{showNetworkGraph ? (
|
||||||
{t("dashboard.recentActivity")}
|
<>
|
||||||
|
<Network className="mr-3" />
|
||||||
|
{t("dashboard.networkGraph", { defaultValue: "Network Graph" })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-3" />
|
||||||
|
{t("dashboard.recentActivity")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="border-2 !border-dark-border h-7"
|
size="sm"
|
||||||
onClick={handleResetActivity}
|
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")}
|
{recentActivityLoading ? (
|
||||||
</Button>
|
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||||
</div>
|
<Loader2
|
||||||
<div
|
className="animate-spin mr-2"
|
||||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
size={16}
|
||||||
>
|
/>
|
||||||
{recentActivityLoading ? (
|
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
</div>
|
||||||
<Loader2 className="animate-spin mr-2" size={16} />
|
) : recentActivity.length === 0 ? (
|
||||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
<p className="text-muted-foreground text-sm">
|
||||||
</div>
|
{t("dashboard.noRecentActivity")}
|
||||||
) : recentActivity.length === 0 ? (
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
) : (
|
||||||
{t("dashboard.noRecentActivity")}
|
recentActivity.map((item) => (
|
||||||
</p>
|
<Button
|
||||||
) : (
|
key={item.id}
|
||||||
recentActivity.map((item) => (
|
variant="outline"
|
||||||
<Button
|
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
||||||
key={item.id}
|
onClick={() => handleActivityClick(item)}
|
||||||
variant="outline"
|
>
|
||||||
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
{item.type === "terminal" ? (
|
||||||
onClick={() => handleActivityClick(item)}
|
<Terminal size={20} className="shrink-0" />
|
||||||
>
|
) : (
|
||||||
{item.type === "terminal" ? (
|
<FolderOpen size={20} className="shrink-0" />
|
||||||
<Terminal size={20} className="shrink-0" />
|
)}
|
||||||
) : (
|
<p className="truncate ml-2 font-semibold">
|
||||||
<FolderOpen size={20} className="shrink-0" />
|
{item.hostName}
|
||||||
)}
|
</p>
|
||||||
<p className="truncate ml-2 font-semibold">
|
</Button>
|
||||||
{item.hostName}
|
))
|
||||||
</p>
|
)}
|
||||||
</Button>
|
</div>
|
||||||
))
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
922
src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx
Normal file
922
src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx
Normal 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;
|
||||||
1
src/ui/desktop/dashboard/network-graph/index.ts
Normal file
1
src/ui/desktop/dashboard/network-graph/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as NetworkGraphView } from './NetworkGraphView';
|
||||||
@@ -379,7 +379,8 @@ export function TopNavbar({
|
|||||||
((tab.type === "home" ||
|
((tab.type === "home" ||
|
||||||
tab.type === "ssh_manager" ||
|
tab.type === "ssh_manager" ||
|
||||||
tab.type === "admin" ||
|
tab.type === "admin" ||
|
||||||
tab.type === "user_profile") &&
|
tab.type === "user_profile" ||
|
||||||
|
tab.type === "network_graph") &&
|
||||||
isSplitScreenActive);
|
isSplitScreenActive);
|
||||||
const isHome = tab.type === "home";
|
const isHome = tab.type === "home";
|
||||||
const disableClose = isHome;
|
const disableClose = isHome;
|
||||||
@@ -486,7 +487,8 @@ export function TopNavbar({
|
|||||||
isFileManager ||
|
isFileManager ||
|
||||||
isSshManager ||
|
isSshManager ||
|
||||||
isAdmin ||
|
isAdmin ||
|
||||||
isUserProfile
|
isUserProfile ||
|
||||||
|
tab.type === "network_graph"
|
||||||
? () => handleTabClose(tab.id)
|
? () => handleTabClose(tab.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -500,7 +502,8 @@ export function TopNavbar({
|
|||||||
isFileManager ||
|
isFileManager ||
|
||||||
isSshManager ||
|
isSshManager ||
|
||||||
isAdmin ||
|
isAdmin ||
|
||||||
isUserProfile
|
isUserProfile ||
|
||||||
|
tab.type === "network_graph"
|
||||||
}
|
}
|
||||||
disableActivate={disableActivate}
|
disableActivate={disableActivate}
|
||||||
disableSplit={disableSplit}
|
disableSplit={disableSplit}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Server as ServerIcon,
|
Server as ServerIcon,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
|
Network,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface TabProps {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Shield as AdminIcon,
|
Shield as AdminIcon,
|
||||||
Network as SshManagerIcon,
|
Network as SshManagerIcon,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
|
Network,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTabs, type Tab } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs, type Tab } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -39,6 +40,8 @@ export function TabDropdown(): React.ReactElement {
|
|||||||
return <SshManagerIcon className="h-4 w-4" />;
|
return <SshManagerIcon className="h-4 w-4" />;
|
||||||
case "admin":
|
case "admin":
|
||||||
return <AdminIcon className="h-4 w-4" />;
|
return <AdminIcon className="h-4 w-4" />;
|
||||||
|
case "network_graph":
|
||||||
|
return <Network className="h-4 w-4" />;
|
||||||
default:
|
default:
|
||||||
return <TerminalIcon className="h-4 w-4" />;
|
return <TerminalIcon className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,8 @@ export function TabDropdown(): React.ReactElement {
|
|||||||
return tab.title || t("nav.sshManager");
|
return tab.title || t("nav.sshManager");
|
||||||
case "admin":
|
case "admin":
|
||||||
return tab.title || t("nav.admin");
|
return tab.title || t("nav.admin");
|
||||||
|
case "network_graph":
|
||||||
|
return tab.title || "Network Graph";
|
||||||
case "terminal":
|
case "terminal":
|
||||||
default:
|
default:
|
||||||
return tab.title || t("nav.terminal");
|
return tab.title || t("nav.terminal");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios, { AxiosError, type AxiosInstance } from "axios";
|
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
SSHHostData,
|
SSHHostData,
|
||||||
@@ -33,6 +33,10 @@ export type ServerStatus = {
|
|||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SSHHostWithStatus = SSHHost & {
|
||||||
|
status: "online" | "offline" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
interface CpuMetrics {
|
interface CpuMetrics {
|
||||||
percent: number | null;
|
percent: number | null;
|
||||||
cores: number | null;
|
cores: number | null;
|
||||||
@@ -68,6 +72,8 @@ interface AuthResponse {
|
|||||||
is_oidc?: boolean;
|
is_oidc?: boolean;
|
||||||
totp_enabled?: boolean;
|
totp_enabled?: boolean;
|
||||||
data_unlocked?: boolean;
|
data_unlocked?: boolean;
|
||||||
|
requires_totp?: boolean;
|
||||||
|
temp_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
@@ -234,12 +240,12 @@ function createApiInstance(
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
instance.interceptors.request.use((config) => {
|
instance.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
(config as Record<string, unknown>).startTime = startTime;
|
(config as any).startTime = startTime;
|
||||||
(config as Record<string, unknown>).requestId = requestId;
|
(config as any).requestId = requestId;
|
||||||
|
|
||||||
const method = config.method?.toUpperCase() || "UNKNOWN";
|
const method = config.method?.toUpperCase() || "UNKNOWN";
|
||||||
const url = config.url || "UNKNOWN";
|
const url = config.url || "UNKNOWN";
|
||||||
@@ -287,11 +293,11 @@ function createApiInstance(
|
|||||||
});
|
});
|
||||||
|
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(response) => {
|
(response: AxiosResponse) => {
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const startTime = (response.config as Record<string, unknown>).startTime;
|
const startTime = (response.config as any).startTime;
|
||||||
const requestId = (response.config as Record<string, unknown>).requestId;
|
const requestId = (response.config as any).requestId;
|
||||||
const responseTime = Math.round(endTime - startTime);
|
const responseTime = Math.round(endTime - (startTime || endTime));
|
||||||
|
|
||||||
const method = response.config.method?.toUpperCase() || "UNKNOWN";
|
const method = response.config.method?.toUpperCase() || "UNKNOWN";
|
||||||
const url = response.config.url || "UNKNOWN";
|
const url = response.config.url || "UNKNOWN";
|
||||||
@@ -325,26 +331,22 @@ function createApiInstance(
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
(error: AxiosErrorExtended) => {
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const startTime = (error.config as Record<string, unknown> | undefined)
|
const startTime = error.config?.startTime;
|
||||||
?.startTime;
|
const requestId = error.config?.requestId;
|
||||||
const requestId = (error.config as Record<string, unknown> | undefined)
|
const responseTime = startTime ? Math.round(endTime - startTime) : undefined;
|
||||||
?.requestId;
|
|
||||||
const responseTime = startTime
|
|
||||||
? Math.round(endTime - startTime)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const method = error.config?.method?.toUpperCase() || "UNKNOWN";
|
const method = error.config?.method?.toUpperCase() || "UNKNOWN";
|
||||||
const url = error.config?.url || "UNKNOWN";
|
const url = error.config?.url || "UNKNOWN";
|
||||||
const fullUrl = error.config ? `${error.config.baseURL}${url}` : url;
|
const fullUrl = error.config ? `${error.config.baseURL}${url}` : url;
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const message =
|
const message =
|
||||||
(error.response?.data as Record<string, unknown>)?.error ||
|
(error.response?.data as { error?: string })?.error ||
|
||||||
(error as Error).message ||
|
(error as Error).message ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
const errorCode =
|
const errorCode =
|
||||||
(error.response?.data as Record<string, unknown>)?.code || error.code;
|
(error.response?.data as { code?: string })?.code || error.code;
|
||||||
|
|
||||||
const context: LogContext = {
|
const context: LogContext = {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -443,6 +445,19 @@ export interface ServerConfig {
|
|||||||
lastUpdated: string;
|
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> {
|
export async function getServerConfig(): Promise<ServerConfig | null> {
|
||||||
if (!isElectron()) return 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(
|
export async function testServerConnection(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
@@ -826,12 +854,20 @@ function handleApiError(error: unknown, operation: string): never {
|
|||||||
// SSH HOST MANAGEMENT
|
// SSH HOST MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get("/db/host");
|
const hostsResponse = await sshHostApi.get("/db/host");
|
||||||
return response.data;
|
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) {
|
} 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,
|
username: response.data.username,
|
||||||
requires_totp: response.data.requires_totp,
|
requires_totp: response.data.requires_totp,
|
||||||
temp_token: response.data.temp_token,
|
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) {
|
} 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 }>,
|
// MISCELLANEOUS API CALLS
|
||||||
): Promise<{ success: boolean; updated: number }> {
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NetworkTopologyData {
|
||||||
|
nodes: any[];
|
||||||
|
edges: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.put("/snippets/reorder", { snippets });
|
const response = await authApi.get("/network-topology/");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} 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
|
// HOMEPAGE API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user