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:
Luke Gustafson
2026-01-12 03:06:02 -05:00
committed by GitHub
20 changed files with 1584 additions and 49 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import 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(

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import express from "express";
import { eq } from "drizzle-orm";
import { getDb } from "../db/index.js";
import { networkTopology } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
router.get(
"/",
authenticateJWT,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "User not authenticated" });
}
const db = getDb();
const result = await db
.select()
.from(networkTopology)
.where(eq(networkTopology.userId, userId));
if (result.length > 0) {
const topologyStr = result[0].topology;
const topology = topologyStr ? JSON.parse(topologyStr) : null;
return res.json(topology);
} else {
return res.json(null);
}
} catch (error) {
console.error("Error fetching network topology:", error);
return res.status(500).json({ error: "Failed to fetch network topology", details: (error as Error).message });
}
},
);
router.post(
"/",
authenticateJWT,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "User not authenticated" });
}
const { topology } = req.body;
if (!topology) {
return res.status(400).json({ error: "Topology data is required" });
}
const db = getDb();
// Ensure topology is a string
const topologyStr = typeof topology === 'string' ? topology : JSON.stringify(topology);
const existing = await db
.select()
.from(networkTopology)
.where(eq(networkTopology.userId, userId));
if (existing.length > 0) {
// Update existing record
await db
.update(networkTopology)
.set({ topology: topologyStr })
.where(eq(networkTopology.userId, userId));
} else {
// Insert new record
await db
.insert(networkTopology)
.values({ userId, topology: topologyStr });
}
return res.json({ success: true });
} catch (error) {
console.error("Error saving network topology:", error);
return res.status(500).json({ error: "Failed to save network topology", details: (error as Error).message });
}
},
);
export default router;

View File

@@ -8,6 +8,22 @@ import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
import NetworkGraphApp from "./ui/desktop/apps/NetworkGraphApp.tsx";
const FullscreenApp: React.FC = () => {
const searchParams = new URLSearchParams(window.location.search);
const view = searchParams.get('view');
switch (view) {
case 'host-manager':
return <HostManagerApp />;
case 'network-graph':
return <NetworkGraphApp />;
default:
return <DesktopApp />;
}
};
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}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
@@ -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>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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