Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions homebrew/termix.rb → Casks/termix.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
cask "termix" do
version "VERSION_PLACEHOLDER"
sha256 "CHECKSUM_PLACEHOLDER"
version "1.9.0"
sha256 "8fedd242b3cae1ebfd0c391a36f1c246a26ecac258b02478ee8dea2f33cd6d96"

url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_#{version}_dmg.dmg"
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
name "Termix"
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
homepage "https://github.com/Termix-SSH/Termix"
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,63 @@ free and self-hosted alternative to Termius available for all platforms.
- **Command History** - Auto-complete and view previously ran SSH commands
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
- **Network Graph View** - Visualize your SSH hosts and their connections in an interactive graph. Drag nodes, pan, zoom, and manage your network topology. Export/import topology as JSON for backup and sharing.

# Planned Features

See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).

# Network Graph View

The Network Graph View is a powerful visualization tool available on the Dashboard that helps you manage and understand your SSH network topology.

## Features

- **Interactive Graph Visualization** - Visualize your SSH hosts and connections using Cytoscape.js with a force-directed layout algorithm
- **Host Management** - Add or remove hosts from the topology. All your SSH hosts are available for selection
- **Connection Management** - Create bidirectional connections (edges) between hosts to represent your network topology
- **Node Interactions** -
- Click nodes to view host details (name, address, status, tags)
- Drag nodes to reposition them manually
- Pan and zoom the graph for better visibility
- **Layout Options** -
- Automatic force-directed layout with a button to reset
- Manual node positioning with automatic position persistence
- **Status Indicators** - Nodes display color-coded status:
- **Green** - Host is online and reachable
- **Red** - Host is offline or unreachable
- **Gray** - Status unknown
- **Export/Import** -
- Export your topology as a JSON file for backup or sharing
- Import previously saved topology files
- **Graph Controls** -
- Add Host to Topology
- Add Connection between hosts
- Remove Node or Connection
- Auto-layout to reorganize graph
- Zoom In/Out for detail work
- Fit to Screen to see entire topology

## How to Use

1. Navigate to the Dashboard in Termix
2. Click the "Network Graph View" toggle to activate the graph view
3. Use the "+" button to add hosts from your SSH Host Manager
4. Use the connection button to add relationships between hosts
5. Drag nodes to position them as needed
6. Click any node to view detailed information about that host
7. Use Export to download your topology as JSON
8. Use Import to restore a previously saved topology

## Limitations & Notes

- The graph view only displays hosts that you've explicitly added to the topology. It does not automatically include all SSH hosts
- Connections are directional (from source to target host)
- Manual node positions are saved automatically when you drag nodes
- The graph updates host status every 30 seconds based on your SSH Host Manager data
- For best performance on mobile devices, limit the topology to 20-30 nodes
- The topology data is stored securely in your encrypted database alongside other SSH configurations

# Installation

Supported Devices:
Expand Down
9 changes: 9 additions & 0 deletions docker/nginx-https.conf
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,15 @@ http {
proxy_buffering off;
}

location ~ ^/network-topology(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /health {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
Expand Down
9 changes: 9 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,15 @@ http {
proxy_buffering off;
}

location ~ ^/network-topology(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /health {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@tailwindcss/vite": "^4.1.14",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/cytoscape": "^3.21.9",
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
Expand All @@ -73,6 +74,7 @@
"cmdk": "^1.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cytoscape": "^3.33.1",
"dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
Expand All @@ -88,6 +90,7 @@
"node-fetch": "^3.3.2",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.1.0",
"react-h5-audio-player": "^3.10.1",
"react-hook-form": "^7.60.0",
Expand Down
2 changes: 2 additions & 0 deletions src/backend/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
Expand Down Expand Up @@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes);
app.use("/network-topology", networkTopologyRoutes);

app.use(
(
Expand Down
24 changes: 24 additions & 0 deletions src/backend/database/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,30 @@ const migrateSchema = () => {
}
}

try {
sqlite
.prepare("SELECT id FROM network_topology LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS network_topology (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
topology TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create network_topology table", {
operation: "schema_migration",
error: createError,
});
}
}

databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
Expand Down
14 changes: 14 additions & 0 deletions src/backend/database/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,17 @@ export const commandHistory = sqliteTable("command_history", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

export const networkTopology = sqliteTable("network_topology", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
88 changes: 88 additions & 0 deletions src/backend/database/routes/network-topology.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
import NetworkGraphApp from "./ui/desktop/apps/NetworkGraphApp.tsx";

const FullscreenApp: React.FC = () => {
const searchParams = new URLSearchParams(window.location.search);
const view = searchParams.get('view');

switch (view) {
case 'host-manager':
return <HostManagerApp />;
case 'network-graph':
return <NetworkGraphApp />;
default:
return <DesktopApp />;
}
};

function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
Expand Down Expand Up @@ -61,8 +77,15 @@ function RootApp() {
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera || "";
const isTermixMobile = /Termix-Mobile/.test(userAgent);

const searchParams = new URLSearchParams(window.location.search);
const isFullscreen = searchParams.has('view');

const renderApp = () => {
if (isFullscreen) {
return <FullscreenApp />;
}

if (isElectron()) {
return <DesktopApp />;
}
Expand Down Expand Up @@ -94,7 +117,7 @@ function RootApp() {
}}
/>
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
{isElectron() && showVersionCheck ? (
{isElectron() && showVersionCheck && !isFullscreen ? (
<ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false}
Expand Down
Loading