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 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:
|
||||||
@@ -69,16 +121,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 [(AUR available)](https://aur.archlinux.org/packages/termix-bin)
|
- Portable [(AUR available)](https://aur.archlinux.org/packages/termix-bin)
|
||||||
- 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
|
||||||
|
|||||||
@@ -288,6 +288,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;
|
||||||
|
|||||||
@@ -277,6 +277,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;
|
||||||
|
|||||||
@@ -53,6 +53,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",
|
||||||
@@ -75,6 +76,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",
|
||||||
@@ -91,6 +93,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 rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
@@ -1437,6 +1438,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("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -654,6 +654,23 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -295,6 +295,12 @@ export const commandHistory = sqliteTable("command_history", {
|
|||||||
.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"),
|
||||||
export const hostAccess = sqliteTable("host_access", {
|
export const hostAccess = sqliteTable("host_access", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
hostId: integer("host_id")
|
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 { 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 />;
|
||||||
|
}
|
||||||
|
};
|
||||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
import { useServiceWorker } from "@/hooks/use-service-worker";
|
||||||
|
|
||||||
function useWindowWidth() {
|
function useWindowWidth() {
|
||||||
@@ -65,8 +81,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 />;
|
||||||
}
|
}
|
||||||
@@ -98,7 +121,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/features/terminal/command-history/CommandHistoryContext.tsx";
|
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
|
import { AdminSettings } from "@/ui/desktop/apps/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, logoutUser, isElectron } from "@/ui/main-axios.ts";
|
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
|
||||||
@@ -29,7 +30,7 @@ function AppContent() {
|
|||||||
const [transitionPhase, setTransitionPhase] = useState<
|
const [transitionPhase, setTransitionPhase] = useState<
|
||||||
"idle" | "fadeOut" | "fadeIn"
|
"idle" | "fadeOut" | "fadeIn"
|
||||||
>("idle");
|
>("idle");
|
||||||
const { currentTab, tabs, updateTab } = useTabs();
|
const { currentTab, tabs, updateTab, addTab } = useTabs();
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||||
@@ -86,6 +87,35 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
}, [theme, setTheme]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
@@ -131,8 +161,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;
|
||||||
@@ -187,6 +215,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 (
|
||||||
@@ -219,7 +248,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}
|
||||||
@@ -230,7 +258,6 @@ function AppContent() {
|
|||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
onSelectView={handleSelectView}
|
|
||||||
disabled={!isAuthenticated || authLoading}
|
disabled={!isAuthenticated || authLoading}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
username={username}
|
username={username}
|
||||||
@@ -250,7 +277,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}
|
||||||
@@ -264,7 +290,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}
|
||||||
@@ -297,6 +322,17 @@ function AppContent() {
|
|||||||
</div>
|
</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
|
<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";
|
||||||
@@ -63,7 +64,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 {
|
||||||
@@ -92,6 +92,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>(false);
|
||||||
|
|
||||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||||
|
|
||||||
@@ -161,7 +162,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();
|
||||||
@@ -594,9 +600,36 @@ 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>
|
||||||
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -656,6 +689,45 @@ export function Dashboard({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</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"}`}
|
||||||
|
>
|
||||||
|
{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>
|
</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 { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.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 { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
@@ -70,7 +71,8 @@ export function AppView({
|
|||||||
tab.type === "server_stats" ||
|
tab.type === "server_stats" ||
|
||||||
tab.type === "file_manager" ||
|
tab.type === "file_manager" ||
|
||||||
tab.type === "tunnel" ||
|
tab.type === "tunnel" ||
|
||||||
tab.type === "docker",
|
tab.type === "docker" ||
|
||||||
|
tab.type === "network_graph",
|
||||||
),
|
),
|
||||||
[tabs],
|
[tabs],
|
||||||
);
|
);
|
||||||
@@ -353,6 +355,12 @@ export function AppView({
|
|||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
embedded
|
embedded
|
||||||
/>
|
/>
|
||||||
|
) : t.type === "network_graph" ? (
|
||||||
|
<NetworkGraphView
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
rightSidebarOpen={rightSidebarOpen}
|
||||||
|
rightSidebarWidth={rightSidebarWidth}
|
||||||
|
isStandalone={true}
|
||||||
) : t.type === "tunnel" ? (
|
) : t.type === "tunnel" ? (
|
||||||
<TunnelManager
|
<TunnelManager
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
|
|||||||
@@ -382,7 +382,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;
|
||||||
@@ -491,7 +492,8 @@ export function TopNavbar({
|
|||||||
isDocker ||
|
isDocker ||
|
||||||
isSshManager ||
|
isSshManager ||
|
||||||
isAdmin ||
|
isAdmin ||
|
||||||
isUserProfile
|
isUserProfile ||
|
||||||
|
tab.type === "network_graph"
|
||||||
? () => handleTabClose(tab.id)
|
? () => handleTabClose(tab.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -507,7 +509,8 @@ export function TopNavbar({
|
|||||||
isDocker ||
|
isDocker ||
|
||||||
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,
|
||||||
ArrowDownUp as TunnelIcon,
|
ArrowDownUp as TunnelIcon,
|
||||||
Container as DockerIcon,
|
Container as DockerIcon,
|
||||||
} from "lucide-react";
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,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";
|
||||||
@@ -45,6 +46,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" />;
|
||||||
}
|
}
|
||||||
@@ -68,6 +71,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,5 +1,4 @@
|
|||||||
import axios, { AxiosError, type AxiosInstance } from "axios";
|
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
|
||||||
import { toast } from "sonner";
|
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
SSHHostData,
|
SSHHostData,
|
||||||
@@ -78,6 +77,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;
|
||||||
@@ -113,6 +116,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 {
|
||||||
@@ -279,12 +284,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";
|
||||||
@@ -332,11 +337,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";
|
||||||
@@ -370,26 +375,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,
|
||||||
@@ -486,6 +487,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;
|
||||||
|
|
||||||
@@ -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(
|
export async function testServerConnection(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
@@ -881,12 +908,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2147,9 +2182,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3044,14 +3082,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
// HOMEPAGE API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user