Merge pull request #478 from SteveJos/feature/add-network-graph
Feature request: Network graph
This commit was merged in pull request #478.
This commit is contained in:
60
README.md
60
README.md
@@ -56,11 +56,63 @@ free and self-hosted alternative to Termius available for all platforms.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
|
||||
- **Network Graph View** - Visualize your SSH hosts and their connections in an interactive graph. Drag nodes, pan, zoom, and manage your network topology. Export/import topology as JSON for backup and sharing.
|
||||
|
||||
# Planned Features
|
||||
|
||||
See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Network Graph View
|
||||
|
||||
The Network Graph View is a powerful visualization tool available on the Dashboard that helps you manage and understand your SSH network topology.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Graph Visualization** - Visualize your SSH hosts and connections using Cytoscape.js with a force-directed layout algorithm
|
||||
- **Host Management** - Add or remove hosts from the topology. All your SSH hosts are available for selection
|
||||
- **Connection Management** - Create bidirectional connections (edges) between hosts to represent your network topology
|
||||
- **Node Interactions** -
|
||||
- Click nodes to view host details (name, address, status, tags)
|
||||
- Drag nodes to reposition them manually
|
||||
- Pan and zoom the graph for better visibility
|
||||
- **Layout Options** -
|
||||
- Automatic force-directed layout with a button to reset
|
||||
- Manual node positioning with automatic position persistence
|
||||
- **Status Indicators** - Nodes display color-coded status:
|
||||
- **Green** - Host is online and reachable
|
||||
- **Red** - Host is offline or unreachable
|
||||
- **Gray** - Status unknown
|
||||
- **Export/Import** -
|
||||
- Export your topology as a JSON file for backup or sharing
|
||||
- Import previously saved topology files
|
||||
- **Graph Controls** -
|
||||
- Add Host to Topology
|
||||
- Add Connection between hosts
|
||||
- Remove Node or Connection
|
||||
- Auto-layout to reorganize graph
|
||||
- Zoom In/Out for detail work
|
||||
- Fit to Screen to see entire topology
|
||||
|
||||
## How to Use
|
||||
|
||||
1. Navigate to the Dashboard in Termix
|
||||
2. Click the "Network Graph View" toggle to activate the graph view
|
||||
3. Use the "+" button to add hosts from your SSH Host Manager
|
||||
4. Use the connection button to add relationships between hosts
|
||||
5. Drag nodes to position them as needed
|
||||
6. Click any node to view detailed information about that host
|
||||
7. Use Export to download your topology as JSON
|
||||
8. Use Import to restore a previously saved topology
|
||||
|
||||
## Limitations & Notes
|
||||
|
||||
- The graph view only displays hosts that you've explicitly added to the topology. It does not automatically include all SSH hosts
|
||||
- Connections are directional (from source to target host)
|
||||
- Manual node positions are saved automatically when you drag nodes
|
||||
- The graph updates host status every 30 seconds based on your SSH Host Manager data
|
||||
- For best performance on mobile devices, limit the topology to 20-30 nodes
|
||||
- The topology data is stored securely in your encrypted database alongside other SSH configurations
|
||||
|
||||
# Installation
|
||||
|
||||
Supported Devices:
|
||||
@@ -69,16 +121,16 @@ Supported Devices:
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager
|
||||
- Chocolatey Package Manager (coming soon)
|
||||
- Linux (x64/ia32)
|
||||
- Portable [(AUR available)](https://aur.archlinux.org/packages/termix-bin)
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- Flatpak (coming soon)
|
||||
- macOS (x64/ia32 on v12.0+)
|
||||
- Apple App Store
|
||||
- Apple App Store (coming soon)
|
||||
- DMG
|
||||
- Homebrew
|
||||
- Homebrew (coming soon)
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
|
||||
@@ -288,6 +288,15 @@ http {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/network-topology(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -277,6 +277,15 @@ http {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/network-topology(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -75,6 +76,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"cytoscape": "^3.33.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
@@ -91,6 +93,7 @@
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-h5-audio-player": "^3.10.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
@@ -1437,6 +1438,7 @@ app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -654,6 +654,23 @@ 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", {
|
||||
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
|
||||
@@ -295,6 +295,12 @@ export const commandHistory = sqliteTable("command_history", {
|
||||
.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"),
|
||||
export const hostAccess = sqliteTable("host_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
|
||||
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 "./i18n/i18n";
|
||||
import { isElectron } from "./ui/main-axios.ts";
|
||||
import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
|
||||
import NetworkGraphApp from "./ui/desktop/apps/NetworkGraphApp.tsx";
|
||||
|
||||
const FullscreenApp: React.FC = () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const view = searchParams.get('view');
|
||||
|
||||
switch (view) {
|
||||
case 'host-manager':
|
||||
return <HostManagerApp />;
|
||||
case 'network-graph':
|
||||
return <NetworkGraphApp />;
|
||||
default:
|
||||
return <DesktopApp />;
|
||||
}
|
||||
};
|
||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
||||
|
||||
function useWindowWidth() {
|
||||
@@ -65,8 +81,15 @@ function RootApp() {
|
||||
const userAgent =
|
||||
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
||||
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const isFullscreen = searchParams.has('view');
|
||||
|
||||
const renderApp = () => {
|
||||
if (isFullscreen) {
|
||||
return <FullscreenApp />;
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
return <DesktopApp />;
|
||||
}
|
||||
@@ -98,7 +121,7 @@ function RootApp() {
|
||||
}}
|
||||
/>
|
||||
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
|
||||
{isElectron() && showVersionCheck ? (
|
||||
{isElectron() && showVersionCheck && !isFullscreen ? (
|
||||
<ElectronVersionCheck
|
||||
onContinue={() => setShowVersionCheck(false)}
|
||||
isAuthenticated={false}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
||||
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
|
||||
@@ -29,7 +30,7 @@ function AppContent() {
|
||||
const [transitionPhase, setTransitionPhase] = useState<
|
||||
"idle" | "fadeOut" | "fadeIn"
|
||||
>("idle");
|
||||
const { currentTab, tabs, updateTab } = useTabs();
|
||||
const { currentTab, tabs, updateTab, addTab } = useTabs();
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
@@ -86,6 +87,35 @@ function AppContent() {
|
||||
};
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/hosts\/([a-zA-Z0-9_-]+)\/terminal$/);
|
||||
if (match) {
|
||||
const hostId = match[1];
|
||||
|
||||
const openTerminalForHost = async () => {
|
||||
try {
|
||||
const { getSSHHostById } = await import("@/ui/main-axios.ts");
|
||||
const host = await getSSHHostById(parseInt(hostId, 10));
|
||||
if (host) {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: host.name || host.ip,
|
||||
data: {
|
||||
host,
|
||||
initialCommand: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open terminal for host:", error);
|
||||
}
|
||||
};
|
||||
|
||||
openTerminalForHost();
|
||||
}
|
||||
}, [addTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
setAuthLoading(true);
|
||||
@@ -131,8 +161,6 @@ function AppContent() {
|
||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||
}, [isTopbarOpen]);
|
||||
|
||||
const handleSelectView = () => {};
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
(authData: {
|
||||
isAdmin: boolean;
|
||||
@@ -187,6 +215,7 @@ function AppContent() {
|
||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
const showNetworkGraph = currentTabData?.type === "network_graph";
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -219,7 +248,6 @@ function AppContent() {
|
||||
{!isAuthenticated && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
@@ -230,7 +258,6 @@ function AppContent() {
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
@@ -250,7 +277,6 @@ function AppContent() {
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
@@ -264,7 +290,6 @@ function AppContent() {
|
||||
{showSshManager && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<HostManager
|
||||
onSelectView={handleSelectView}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
@@ -297,6 +322,17 @@ function AppContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNetworkGraph && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<NetworkGraphView
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
isStandalone={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TopNavbar
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
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 { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
|
||||
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
||||
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
|
||||
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
||||
@@ -63,7 +64,6 @@ export function Dashboard({
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
@@ -92,6 +92,7 @@ export function Dashboard({
|
||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
const [showNetworkGraph, setShowNetworkGraph] = useState<boolean>(false);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
|
||||
@@ -161,7 +162,12 @@ export function Dashboard({
|
||||
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
setVersionStatus(versionInfo.status || "up_to_date");
|
||||
if (
|
||||
versionInfo.status === "up_to_date" ||
|
||||
versionInfo.status === "requires_update"
|
||||
) {
|
||||
setVersionStatus(versionInfo.status);
|
||||
}
|
||||
|
||||
try {
|
||||
await getDatabaseHealth();
|
||||
@@ -594,9 +600,36 @@ export function Dashboard({
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
{showNetworkGraph ? (
|
||||
<>
|
||||
<Network className="mr-3" />
|
||||
{t("dashboard.networkGraph", { defaultValue: "Network Graph" })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-dark-border h-7"
|
||||
onClick={() => setShowNetworkGraph(!showNetworkGraph)}
|
||||
>
|
||||
{showNetworkGraph ? "Show Activity" : "Show Graph"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-dark-border h-7"
|
||||
onClick={handleResetActivity}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -656,6 +689,45 @@ export function Dashboard({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{showNetworkGraph ? (
|
||||
<NetworkGraphView />
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{recentActivityLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2
|
||||
className="animate-spin mr-2"
|
||||
size={16}
|
||||
/>
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
recentActivity.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1072
src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx
Normal file
1072
src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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';
|
||||
@@ -4,6 +4,7 @@ import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-sta
|
||||
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
@@ -70,7 +71,8 @@ export function AppView({
|
||||
tab.type === "server_stats" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
tab.type === "docker" ||
|
||||
tab.type === "network_graph",
|
||||
),
|
||||
[tabs],
|
||||
);
|
||||
@@ -353,6 +355,12 @@ export function AppView({
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "network_graph" ? (
|
||||
<NetworkGraphView
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
isStandalone={true}
|
||||
) : t.type === "tunnel" ? (
|
||||
<TunnelManager
|
||||
hostConfig={t.hostConfig}
|
||||
|
||||
@@ -382,7 +382,8 @@ export function TopNavbar({
|
||||
((tab.type === "home" ||
|
||||
tab.type === "ssh_manager" ||
|
||||
tab.type === "admin" ||
|
||||
tab.type === "user_profile") &&
|
||||
tab.type === "user_profile" ||
|
||||
tab.type === "network_graph") &&
|
||||
isSplitScreenActive);
|
||||
const isHome = tab.type === "home";
|
||||
const disableClose = isHome;
|
||||
@@ -491,7 +492,8 @@ export function TopNavbar({
|
||||
isDocker ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
isUserProfile ||
|
||||
tab.type === "network_graph"
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
}
|
||||
@@ -507,7 +509,8 @@ export function TopNavbar({
|
||||
isDocker ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
isUserProfile ||
|
||||
tab.type === "network_graph"
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
disableSplit={disableSplit}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
User as UserIcon,
|
||||
Network,
|
||||
ArrowDownUp as TunnelIcon,
|
||||
Container as DockerIcon,
|
||||
} from "lucide-react";
|
||||
@@ -288,5 +289,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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Shield as AdminIcon,
|
||||
Network as SshManagerIcon,
|
||||
User as UserIcon,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { useTabs, type Tab } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,6 +46,8 @@ export function TabDropdown(): React.ReactElement {
|
||||
return <SshManagerIcon className="h-4 w-4" />;
|
||||
case "admin":
|
||||
return <AdminIcon className="h-4 w-4" />;
|
||||
case "network_graph":
|
||||
return <Network className="h-4 w-4" />;
|
||||
default:
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
}
|
||||
@@ -68,6 +71,8 @@ export function TabDropdown(): React.ReactElement {
|
||||
return tab.title || t("nav.sshManager");
|
||||
case "admin":
|
||||
return tab.title || t("nav.admin");
|
||||
case "network_graph":
|
||||
return tab.title || "Network Graph";
|
||||
case "terminal":
|
||||
default:
|
||||
return tab.title || t("nav.terminal");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios, { AxiosError, type AxiosInstance } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHHostData,
|
||||
@@ -78,6 +77,10 @@ export type ServerStatus = {
|
||||
lastChecked: string;
|
||||
};
|
||||
|
||||
export type SSHHostWithStatus = SSHHost & {
|
||||
status: "online" | "offline" | "unknown";
|
||||
};
|
||||
|
||||
interface CpuMetrics {
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
@@ -113,6 +116,8 @@ interface AuthResponse {
|
||||
is_oidc?: boolean;
|
||||
totp_enabled?: boolean;
|
||||
data_unlocked?: boolean;
|
||||
requires_totp?: boolean;
|
||||
temp_token?: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
@@ -279,12 +284,12 @@ function createApiInstance(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
instance.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||
const startTime = performance.now();
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
(config as Record<string, unknown>).startTime = startTime;
|
||||
(config as Record<string, unknown>).requestId = requestId;
|
||||
(config as any).startTime = startTime;
|
||||
(config as any).requestId = requestId;
|
||||
|
||||
const method = config.method?.toUpperCase() || "UNKNOWN";
|
||||
const url = config.url || "UNKNOWN";
|
||||
@@ -332,11 +337,11 @@ function createApiInstance(
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
(response: AxiosResponse) => {
|
||||
const endTime = performance.now();
|
||||
const startTime = (response.config as Record<string, unknown>).startTime;
|
||||
const requestId = (response.config as Record<string, unknown>).requestId;
|
||||
const responseTime = Math.round(endTime - startTime);
|
||||
const startTime = (response.config as any).startTime;
|
||||
const requestId = (response.config as any).requestId;
|
||||
const responseTime = Math.round(endTime - (startTime || endTime));
|
||||
|
||||
const method = response.config.method?.toUpperCase() || "UNKNOWN";
|
||||
const url = response.config.url || "UNKNOWN";
|
||||
@@ -370,26 +375,22 @@ function createApiInstance(
|
||||
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
(error: AxiosErrorExtended) => {
|
||||
const endTime = performance.now();
|
||||
const startTime = (error.config as Record<string, unknown> | undefined)
|
||||
?.startTime;
|
||||
const requestId = (error.config as Record<string, unknown> | undefined)
|
||||
?.requestId;
|
||||
const responseTime = startTime
|
||||
? Math.round(endTime - startTime)
|
||||
: undefined;
|
||||
const startTime = error.config?.startTime;
|
||||
const requestId = error.config?.requestId;
|
||||
const responseTime = startTime ? Math.round(endTime - startTime) : undefined;
|
||||
|
||||
const method = error.config?.method?.toUpperCase() || "UNKNOWN";
|
||||
const url = error.config?.url || "UNKNOWN";
|
||||
const fullUrl = error.config ? `${error.config.baseURL}${url}` : url;
|
||||
const status = error.response?.status;
|
||||
const message =
|
||||
(error.response?.data as Record<string, unknown>)?.error ||
|
||||
(error.response?.data as { error?: string })?.error ||
|
||||
(error as Error).message ||
|
||||
"Unknown error";
|
||||
const errorCode =
|
||||
(error.response?.data as Record<string, unknown>)?.code || error.code;
|
||||
(error.response?.data as { code?: string })?.code || error.code;
|
||||
|
||||
const context: LogContext = {
|
||||
requestId,
|
||||
@@ -486,6 +487,19 @@ export interface ServerConfig {
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
|
||||
startTime?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface AxiosResponseExtended extends AxiosResponse {
|
||||
config: AxiosRequestConfigExtended;
|
||||
}
|
||||
|
||||
interface AxiosErrorExtended extends AxiosError {
|
||||
config?: AxiosRequestConfigExtended;
|
||||
}
|
||||
|
||||
export async function getServerConfig(): Promise<ServerConfig | null> {
|
||||
if (!isElectron()) return null;
|
||||
|
||||
@@ -537,6 +551,19 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
|
||||
startTime?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface AxiosResponseExtended extends AxiosResponse {
|
||||
config: AxiosRequestConfigExtended;
|
||||
}
|
||||
|
||||
interface AxiosErrorExtended extends AxiosError {
|
||||
config?: AxiosRequestConfigExtended;
|
||||
}
|
||||
|
||||
export async function testServerConnection(
|
||||
serverUrl: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
@@ -881,12 +908,20 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/db/host");
|
||||
return response.data;
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
const hosts: SSHHost[] = hostsResponse.data;
|
||||
|
||||
const statusesResponse = await getAllServerStatuses();
|
||||
const statuses = statusesResponse || {};
|
||||
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: statuses[host.id]?.status || "unknown",
|
||||
}));
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch SSH hosts");
|
||||
throw handleApiError(error, "fetch SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2147,9 +2182,12 @@ export async function loginUser(
|
||||
username: response.data.username,
|
||||
requires_totp: response.data.requires_totp,
|
||||
temp_token: response.data.temp_token,
|
||||
is_oidc: response.data.is_oidc,
|
||||
totp_enabled: response.data.totp_enabled,
|
||||
data_unlocked: response.data.data_unlocked,
|
||||
};
|
||||
} catch (error) {
|
||||
handleApiError(error, "login user");
|
||||
throw handleApiError(error, "login user");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3044,14 +3082,32 @@ export async function executeSnippet(
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderSnippets(
|
||||
snippets: Array<{ id: number; order: number; folder?: string }>,
|
||||
): Promise<{ success: boolean; updated: number }> {
|
||||
// ============================================================================
|
||||
// MISCELLANEOUS API CALLS
|
||||
// ============================================================================
|
||||
|
||||
export interface NetworkTopologyData {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
}
|
||||
|
||||
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
|
||||
try {
|
||||
const response = await authApi.put("/snippets/reorder", { snippets });
|
||||
const response = await authApi.get("/network-topology/");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reorder snippets");
|
||||
throw handleApiError(error, "fetch network topology");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveNetworkTopology(
|
||||
topology: NetworkTopologyData,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/network-topology/", { topology });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save network topology");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3120,6 +3176,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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user