mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: convert network-topology to a full plugin (B1)
This commit is contained in:
1 parent
95abe24798
commit
fdedca39fd
51 files changed
+30321
-814
No files matched your search
@@ -1,16 +1,72 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
startNetworkTopologyService,
|
||||
stopNetworkTopologyService,
|
||||
} from "./routes.js";
|
||||
import { graphs } from "./tables.js";
|
||||
import { createGraphRepository } from "./repository.js";
|
||||
import { registerGraphRoutes } from "./routes.js";
|
||||
import { mapTopologyHostIds } from "./topology-host-ids.js";
|
||||
|
||||
/** What other plugins get from ctx.services.get("network-topology.graph"). */
|
||||
export interface NetworkTopologyService {
|
||||
/** The calling user's saved topology, or null if they have none. */
|
||||
get: () => Promise<unknown | null>;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startNetworkTopologyService(ctx.http.router());
|
||||
const table = await ctx.db.define(graphs);
|
||||
const repo = createGraphRepository(ctx.db, table);
|
||||
|
||||
registerGraphRoutes(ctx.http.router<Router>(), repo, ctx);
|
||||
|
||||
ctx.sync.registerEntity({
|
||||
type: "networkTopology",
|
||||
table,
|
||||
order: 100,
|
||||
singleton: true,
|
||||
// Host ids live inside the topology JSON rather than in a column, so the
|
||||
// generic reference machinery cannot reach them.
|
||||
serialize: async (row, resolveSyncId) => ({
|
||||
...row,
|
||||
topology: await mapTopologyHostIds(
|
||||
row.topology as string | null | undefined,
|
||||
async (id) => {
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId)) return null;
|
||||
return resolveSyncId("hosts", numericId);
|
||||
},
|
||||
),
|
||||
}),
|
||||
deserialize: async (row, resolveId) => ({
|
||||
...row,
|
||||
topology: await mapTopologyHostIds(
|
||||
row.topology as string | null | undefined,
|
||||
async (syncId) => {
|
||||
const id = await resolveId("hosts", syncId);
|
||||
return id === null ? null : String(id);
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const service: NetworkTopologyService = {
|
||||
get: async () => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return null;
|
||||
const record = await repo.findByUserId(userId);
|
||||
if (!record?.topology) return null;
|
||||
try {
|
||||
return JSON.parse(record.topology);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
ctx.services.provide("network-topology.graph", service);
|
||||
|
||||
ctx.log.info(
|
||||
"Network Topology routes mounted at /plugin-api/network-topology",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopNetworkTopologyService();
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface GraphRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
topology: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type GraphRepository = ReturnType<typeof createGraphRepository>;
|
||||
|
||||
/** Per-user network topology storage, one row per user. */
|
||||
export function createGraphRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async findByUserId(userId: string): Promise<GraphRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId))
|
||||
.limit(1);
|
||||
return (rows[0] as GraphRecord) ?? null;
|
||||
},
|
||||
|
||||
async upsertForUser(userId: string, topology: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (rows[0]) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ topology, updatedAt: now })
|
||||
.where(eq(table.userId, userId));
|
||||
} else {
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ userId, topology, createdAt: now, updatedAt: now });
|
||||
}
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,184 +1,106 @@
|
||||
import express, { type Router } from "express";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { createCurrentNetworkTopologyRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { GraphRepository } from "./repository.js";
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
function actor(ctx: PluginContext): string {
|
||||
// Core's plugin router authenticates every request and runs it as that user.
|
||||
return ctx.currentActor() as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /network-topology:
|
||||
* get:
|
||||
* summary: Get network topology for authenticated user
|
||||
* description: Retrieves the saved network topology graph (nodes and edges) for the current user. Returns null if no topology exists.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Network topology retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: object
|
||||
* description: Node data including id, label, type, etc.
|
||||
* position:
|
||||
* type: object
|
||||
* description: Node position coordinates
|
||||
* properties:
|
||||
* x:
|
||||
* type: number
|
||||
* y:
|
||||
* type: number
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: object
|
||||
* description: Edge data including source and target node ids
|
||||
* 401:
|
||||
* description: User not authenticated
|
||||
* 500:
|
||||
* description: Server error
|
||||
* Mounts the graph routes on the plugin's router, which core serves at
|
||||
* /plugin-api/network-topology with auth in front.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
export function registerGraphRoutes(
|
||||
router: Router,
|
||||
repo: GraphRepository,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/network-topology:
|
||||
* get:
|
||||
* summary: Get network topology for authenticated user
|
||||
* description: Retrieves the saved network topology graph (nodes and edges) for the current user. Returns null if no topology exists.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Network topology retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
*/
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "User not authenticated" });
|
||||
}
|
||||
const record = await repo.findByUserId(userId);
|
||||
const topology = record?.topology ? JSON.parse(record.topology) : null;
|
||||
res.json(topology);
|
||||
} catch (err) {
|
||||
ctx.log.error(
|
||||
"Failed to fetch network topology",
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch network topology" });
|
||||
}
|
||||
});
|
||||
|
||||
const record =
|
||||
await createCurrentNetworkTopologyRepository().findByUserId(userId);
|
||||
|
||||
if (record) {
|
||||
const topologyStr = record.topology;
|
||||
const topology = topologyStr ? JSON.parse(topologyStr) : null;
|
||||
return res.json(topology);
|
||||
} else {
|
||||
return res.json(null);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to fetch network topology", error, {
|
||||
operation: "network_topology_fetch",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to fetch network topology",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/network-topology:
|
||||
* post:
|
||||
* summary: Save network topology for authenticated user
|
||||
* description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - topology
|
||||
* properties:
|
||||
* topology:
|
||||
* type: object
|
||||
* description: Network topology data containing nodes and edges
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Topology saved successfully
|
||||
* 400:
|
||||
* description: Invalid topology data
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { topology } = req.body ?? {};
|
||||
if (!topology) {
|
||||
return res.status(400).json({ error: "Topology data is required" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /network-topology:
|
||||
* post:
|
||||
* summary: Save network topology for authenticated user
|
||||
* description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - topology
|
||||
* properties:
|
||||
* topology:
|
||||
* type: object
|
||||
* description: Network topology data containing nodes and edges
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Topology saved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid topology data
|
||||
* 401:
|
||||
* description: User not authenticated
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
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 topologyStr =
|
||||
typeof topology === "string" ? topology : JSON.stringify(topology);
|
||||
|
||||
await createCurrentNetworkTopologyRepository().upsertForUser(
|
||||
userId,
|
||||
topologyStr,
|
||||
await repo.upsertForUser(userId, topologyStr);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
ctx.log.error(
|
||||
"Failed to save network topology",
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save network topology", error, {
|
||||
operation: "network_topology_save",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to save network topology",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to save network topology" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function startNetworkTopologyService(mountOn: Router): void {
|
||||
mountOn.use(router);
|
||||
}
|
||||
|
||||
export function stopNetworkTopologyService(): void {
|
||||
// Unmounted by the runtime when the plugin deactivates.
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* One row per user, holding their saved graph as a JSON blob.
|
||||
*
|
||||
* Adopted from core's network_topology, so the column names are the legacy
|
||||
* ones: the rename carries the existing rows across.
|
||||
*/
|
||||
export const graphs = adoptLegacyTable(
|
||||
"network_topology",
|
||||
defineTable("graphs", {
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
topology: text(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [graphs];
|
||||
@@ -0,0 +1,72 @@
|
||||
interface TopologyNode {
|
||||
data?: { id?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TopologyEdge {
|
||||
data?: { source?: string; target?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Topology {
|
||||
nodes?: TopologyNode[];
|
||||
edges?: TopologyEdge[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node/edge ids in a topology are either a host's local numeric id (as a
|
||||
* string) or a client-generated group-<timestamp> id. Only the former is
|
||||
* meaningful across sides, so translate just those through the mapper and
|
||||
* leave group ids untouched. Nodes/edges that fail to resolve (host deleted,
|
||||
* or not synced to this side yet) are dropped rather than left dangling.
|
||||
*/
|
||||
export async function mapTopologyHostIds(
|
||||
topologyJson: string | null | undefined,
|
||||
mapId: (id: string) => Promise<string | null>,
|
||||
): Promise<string | null> {
|
||||
if (!topologyJson) return topologyJson ?? null;
|
||||
|
||||
let topology: Topology;
|
||||
try {
|
||||
topology = JSON.parse(topologyJson);
|
||||
} catch {
|
||||
return topologyJson;
|
||||
}
|
||||
|
||||
const isGroupId = (id: string) => id.startsWith("group-");
|
||||
const idMap = new Map<string, string | null>();
|
||||
|
||||
const resolve = async (id: string): Promise<string | null> => {
|
||||
if (isGroupId(id)) return id;
|
||||
if (!idMap.has(id)) idMap.set(id, await mapId(id));
|
||||
return idMap.get(id) ?? null;
|
||||
};
|
||||
|
||||
const nodes: TopologyNode[] = [];
|
||||
for (const node of topology.nodes ?? []) {
|
||||
const id = node.data?.id;
|
||||
if (typeof id !== "string") continue;
|
||||
const mapped = await resolve(id);
|
||||
if (mapped === null) continue;
|
||||
nodes.push({ ...node, data: { ...node.data, id: mapped } });
|
||||
}
|
||||
|
||||
const nodeIds = new Set(nodes.map((n) => n.data?.id));
|
||||
const edges: TopologyEdge[] = [];
|
||||
for (const edge of topology.edges ?? []) {
|
||||
const source = edge.data?.source;
|
||||
const target = edge.data?.target;
|
||||
if (typeof source !== "string" || typeof target !== "string") continue;
|
||||
const mappedSource = await resolve(source);
|
||||
const mappedTarget = await resolve(target);
|
||||
if (mappedSource === null || mappedTarget === null) continue;
|
||||
if (!nodeIds.has(mappedSource) || !nodeIds.has(mappedTarget)) continue;
|
||||
edges.push({
|
||||
...edge,
|
||||
data: { ...edge.data, source: mappedSource, target: mappedTarget },
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...topology, nodes, edges });
|
||||
}
|
||||
@@ -7,34 +7,32 @@ import React, {
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from "react";
|
||||
import { Card } from "@/components/card";
|
||||
import CytoscapeComponent from "react-cytoscapejs";
|
||||
import cytoscape from "cytoscape";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getNetworkTopology,
|
||||
saveNetworkTopology,
|
||||
type SSHHostWithStatus,
|
||||
type NetworkTopologyEdge,
|
||||
type NetworkTopologyNode,
|
||||
} from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
} from "@/components/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
Input,
|
||||
Label,
|
||||
Select2,
|
||||
useTabsSafe,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
usePluginApi,
|
||||
useHosts,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
@@ -59,11 +57,12 @@ import {
|
||||
ArrowDownUp,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
createNetworkTopologyApi,
|
||||
type NetworkTopologyData,
|
||||
type NetworkTopologyNode,
|
||||
type NetworkTopologyEdge,
|
||||
} from "./network-topology-api";
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
@@ -76,8 +75,19 @@ const AVAILABLE_COLORS = [
|
||||
{ value: "#6b7280", label: "Gray" },
|
||||
];
|
||||
|
||||
type HostStatus = "online" | "reachable" | "offline" | "unknown";
|
||||
|
||||
interface HostWithStatus extends PluginHostRecord {
|
||||
status?: HostStatus;
|
||||
enableTerminal?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableDocker?: boolean;
|
||||
tunnelConnections?: unknown;
|
||||
}
|
||||
|
||||
interface HostMap {
|
||||
[key: string]: SSHHostWithStatus;
|
||||
[key: string]: HostWithStatus;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -100,6 +110,24 @@ interface NetworkGraphCardProps {
|
||||
|
||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
||||
|
||||
type StatusColorScheme = "accent" | "status";
|
||||
|
||||
/** Reads the sidebar preferences cache directly, same as core's status dots do. */
|
||||
function readStatusColorScheme(): StatusColorScheme {
|
||||
try {
|
||||
const raw = localStorage.getItem("hostSidebarPreferences");
|
||||
if (!raw) return "accent";
|
||||
const parsed = JSON.parse(raw) as {
|
||||
display?: { statusColorScheme?: string };
|
||||
};
|
||||
return parsed?.display?.statusColorScheme === "status"
|
||||
? "status"
|
||||
: "accent";
|
||||
} catch {
|
||||
return "accent";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCssVar(varName: string, fallback: string): string {
|
||||
const raw = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
@@ -205,15 +233,16 @@ export function NetworkGraphCard({
|
||||
}: NetworkGraphCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addTab } = useTabsSafe();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createNetworkTopologyApi(pluginApi), [pluginApi]);
|
||||
const { hosts: liveHosts } = useHosts();
|
||||
|
||||
// Gate Cytoscape mounting on the container actually having non-zero dimensions.
|
||||
// This avoids the "bb is undefined" crash when the card is hidden via display:none.
|
||||
const [containerReady, setContainerReady] = useState(false);
|
||||
|
||||
const [elements, setElements] = useState<NetworkElement[]>([]);
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [hostMap, setHostMap] = useState<HostMap>({});
|
||||
const hostMapRef = useRef<HostMap>({});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -237,7 +266,7 @@ export function NetworkGraphCard({
|
||||
const [selectedHostForEdge, setSelectedHostForEdge] = useState("");
|
||||
const [targetHostForEdge, setTargetHostForEdge] = useState("");
|
||||
const [selectedNodeForDetail, setSelectedNodeForDetail] =
|
||||
useState<SSHHostWithStatus | null>(null);
|
||||
useState<HostWithStatus | null>(null);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
@@ -265,30 +294,29 @@ export function NetworkGraphCard({
|
||||
}, [containerReady]);
|
||||
|
||||
const cyRef = useRef<cytoscape.Core | null>(null);
|
||||
const statusIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cyContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// hostMap tracks the live host list, so status/tags update without polling.
|
||||
useEffect(() => {
|
||||
hostMapRef.current = hostMap;
|
||||
}, [hostMap]);
|
||||
const newMap: HostMap = {};
|
||||
(liveHosts as HostWithStatus[]).forEach((h) => (newMap[String(h.id)] = h));
|
||||
setHostMap(newMap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
if (statusIntervalRef.current) {
|
||||
clearInterval(statusIntervalRef.current);
|
||||
statusIntervalRef.current = null;
|
||||
if (!cyRef.current) return;
|
||||
cyRef.current.nodes().forEach((node) => {
|
||||
if (node.isParent()) return;
|
||||
const h = newMap[node.data("id")];
|
||||
if (h) {
|
||||
node.data("status", h.status ?? "unknown");
|
||||
node.data("tags", h.tags ?? []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}, [liveHosts]);
|
||||
|
||||
loadData();
|
||||
statusIntervalRef.current = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
updateHostStatuses();
|
||||
}, 30000);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
contextMenuRef.current &&
|
||||
@@ -307,27 +335,25 @@ export function NetworkGraphCard({
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (statusIntervalRef.current) clearInterval(statusIntervalRef.current);
|
||||
document.removeEventListener("mousedown", onClickOutside, true);
|
||||
themeObserver.disconnect();
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const hostsData = await getSSHHosts();
|
||||
const hostsArray = Array.isArray(hostsData) ? hostsData : [];
|
||||
setHosts(hostsArray);
|
||||
const newMap: HostMap = {};
|
||||
hostsArray.forEach((h) => (newMap[String(h.id)] = h));
|
||||
(liveHosts as HostWithStatus[]).forEach(
|
||||
(h) => (newMap[String(h.id)] = h),
|
||||
);
|
||||
setHostMap(newMap);
|
||||
|
||||
let nodes: NetworkTopologyNode[] = [];
|
||||
let edges: NetworkTopologyEdge[] = [];
|
||||
try {
|
||||
const topo = await getNetworkTopology();
|
||||
const topo = await api.get();
|
||||
if (topo?.nodes && Array.isArray(topo.nodes)) {
|
||||
nodes = topo.nodes.map((node) => {
|
||||
const h = newMap[node.data.id];
|
||||
@@ -360,34 +386,18 @@ export function NetworkGraphCard({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [api, liveHosts]);
|
||||
|
||||
const updateHostStatuses = useCallback(async () => {
|
||||
if (!cyRef.current) return;
|
||||
try {
|
||||
const updated = await getSSHHosts();
|
||||
const updatedMap: HostMap = {};
|
||||
updated.forEach((h) => (updatedMap[String(h.id)] = h));
|
||||
cyRef.current.nodes().forEach((node) => {
|
||||
if (node.isParent()) return;
|
||||
const h = updatedMap[node.data("id")];
|
||||
if (h) {
|
||||
node.data("status", h.status);
|
||||
node.data("tags", h.tags || []);
|
||||
}
|
||||
});
|
||||
setHostMap(updatedMap);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
void loadData();
|
||||
// Only the initial load per visibility change; hostMap stays live via the
|
||||
// liveHosts effect above.
|
||||
}, [isVisible]);
|
||||
|
||||
const debouncedSave = useCallback(() => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => saveCurrentLayout(), 1000);
|
||||
}, []);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const saveCurrentLayout = async () => {
|
||||
const saveCurrentLayout = useCallback(async () => {
|
||||
if (!cyRef.current) return;
|
||||
try {
|
||||
const nodes = cyRef.current.nodes().map((n) => ({
|
||||
@@ -409,11 +419,16 @@ export function NetworkGraphCard({
|
||||
target: e.data("target"),
|
||||
},
|
||||
}));
|
||||
await saveNetworkTopology({ nodes, edges });
|
||||
await api.save({ nodes, edges } as NetworkTopologyData);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
const debouncedSave = useCallback(() => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => void saveCurrentLayout(), 1000);
|
||||
}, [saveCurrentLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cyRef.current || loading || elements.length === 0) return;
|
||||
@@ -553,7 +568,7 @@ export function NetworkGraphCard({
|
||||
[applyStyle, debouncedSave, embedded],
|
||||
);
|
||||
|
||||
// Zoom centered on the viewport midpoint — no panning
|
||||
// Zoom centered on the viewport midpoint, no panning
|
||||
const zoomIn = useCallback(() => {
|
||||
const cy = cyRef.current;
|
||||
if (!cy || !cyContainerRef.current) return;
|
||||
@@ -623,7 +638,7 @@ export function NetworkGraphCard({
|
||||
fireOpen(contextMenu.targetId, appType);
|
||||
};
|
||||
|
||||
const hasTunnelConnections = (h: SSHHostWithStatus | undefined) => {
|
||||
const hasTunnelConnections = (h: HostWithStatus | undefined) => {
|
||||
if (!h?.tunnelConnections) return false;
|
||||
try {
|
||||
const arr = Array.isArray(h.tunnelConnections)
|
||||
@@ -781,10 +796,11 @@ export function NetworkGraphCard({
|
||||
);
|
||||
|
||||
const availableHostsForAdd = useMemo(() => {
|
||||
if (!cyRef.current) return hosts;
|
||||
const hostList = liveHosts as HostWithStatus[];
|
||||
if (!cyRef.current) return hostList;
|
||||
const existing = new Set(elements.map((e) => e.data.id));
|
||||
return hosts.filter((h) => !existing.has(String(h.id)));
|
||||
}, [hosts, elements]);
|
||||
return hostList.filter((h) => !existing.has(String(h.id)));
|
||||
}, [liveHosts, elements]);
|
||||
|
||||
const btnCls =
|
||||
"h-7 w-7 p-0 rounded-sm border-0 hover:bg-muted/60 transition-colors flex items-center justify-center text-muted-foreground hover:text-foreground";
|
||||
@@ -1043,12 +1059,11 @@ export function NetworkGraphCard({
|
||||
key={c.value}
|
||||
type="button"
|
||||
onClick={() => setNewGroupColor(c.value)}
|
||||
className={cn(
|
||||
"h-9 rounded border-2 transition-all",
|
||||
className={`h-9 rounded border-2 transition-all ${
|
||||
newGroupColor === c.value
|
||||
? "border-accent-brand ring-1 ring-accent-brand"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
: "border-border hover:border-muted-foreground"
|
||||
}`}
|
||||
style={{ backgroundColor: c.value }}
|
||||
title={c.label}
|
||||
/>
|
||||
@@ -1225,7 +1240,7 @@ export function NetworkGraphCard({
|
||||
reader.onload = async (evt) => {
|
||||
try {
|
||||
const json = JSON.parse(evt.target?.result as string);
|
||||
await saveNetworkTopology({
|
||||
await api.save({
|
||||
nodes: json.nodes,
|
||||
edges: json.edges,
|
||||
});
|
||||
@@ -1349,7 +1364,7 @@ export function NetworkGraphCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── embedded card ── */
|
||||
/* embedded card */
|
||||
const nodeCount = elements.filter((e) => !e.data.source).length;
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0 min-h-0">
|
||||
|
||||
@@ -36,6 +36,7 @@ export function activate(app: TermixApp): void {
|
||||
titleKey: "nav.networkGraph",
|
||||
kind: "tab",
|
||||
after: "local-terminal",
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
app.registerDashboardCard({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface NetworkTopologyNode {
|
||||
data: {
|
||||
id: string;
|
||||
label?: string;
|
||||
ip?: string;
|
||||
status?: string;
|
||||
tags?: string[];
|
||||
parent?: string;
|
||||
color?: string;
|
||||
/** Absent on nodes; callers tell nodes from edges by testing these. */
|
||||
source?: undefined;
|
||||
target?: undefined;
|
||||
};
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface NetworkTopologyEdge {
|
||||
data: {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: undefined;
|
||||
ip?: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NetworkTopologyData {
|
||||
nodes: NetworkTopologyNode[];
|
||||
edges: NetworkTopologyEdge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The topology routes, through the plugin's own client. Paths are relative
|
||||
* to /plugin-api/network-topology/, which the client already points at.
|
||||
*/
|
||||
export function createNetworkTopologyApi(api: PluginApiClient) {
|
||||
return {
|
||||
get: async (): Promise<NetworkTopologyData | null> => {
|
||||
const response = await api.get<NetworkTopologyData | null>("/");
|
||||
return response.data;
|
||||
},
|
||||
save: async (
|
||||
topology: NetworkTopologyData,
|
||||
): Promise<{ success: boolean }> => {
|
||||
const response = await api.post<{ success: boolean }>("/", {
|
||||
topology,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type NetworkTopologyApi = ReturnType<typeof createNetworkTopologyApi>;
|
||||
Reference in new issue
Block a user