feat: convert network-topology to a full plugin (B1)

This commit is contained in:
LukeGus committed 2026-09-23 07:36:39 -05:00
1 parent 95abe24798
commit fdedca39fd
51 files changed
+30321 -814

No files matched your search

+1 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-AOzhY2+y5QtkFA+y4ASX7ouU+zPyffKUNFrJiRrW6nw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-QkuxsYffmY3dh9W+bFkdt12Ku27IheHoKtf6Nwc2rFE='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-AOzhY2+y5QtkFA+y4ASX7ouU+zPyffKUNFrJiRrW6nw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-QkuxsYffmY3dh9W+bFkdt12Ku27IheHoKtf6Nwc2rFE='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+4
View File
@@ -0,0 +1,4 @@
-- network_topology moved to the network-topology plugin, which adopts (renames)
-- this table in its own migration before this drizzle migration would run.
-- No DROP here: it would delete every row before the plugin's rename applies.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -225,6 +225,13 @@
"when": 1790164291881,
"tag": "0031_nebulous_diamondback",
"breakpoints": true
},
{
"idx": 32,
"version": "5",
"when": 1790166644943,
"tag": "0032_lethal_marrow",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- network_topology moved to the network-topology plugin, which adopts (renames)
-- this table in its own migration before this drizzle migration would run.
-- No DROP here: it would delete every row before the plugin's rename applies.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -232,6 +232,13 @@
"when": 1790164291317,
"tag": "0032_nifty_hedge_knight",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1790166644445,
"tag": "0033_bent_trish_tilby",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- network_topology moved to the network-topology plugin, which adopts (renames)
-- this table in its own migration before this drizzle migration would run.
-- No DROP here: it would delete every row before the plugin's rename applies.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -204,6 +204,13 @@
"when": 1790164290763,
"tag": "0028_public_miracleman",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1790166643921,
"tag": "0029_numerous_aaron_stack",
"breakpoints": true
}
]
}
+12 -8
View File
@@ -124,7 +124,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^10.0.5",
"cytoscape": "^3.34.3",
"drizzle-kit": "^0.31.10",
"electron": "^43.4.1",
"electron-builder": "^26.15.3",
@@ -143,7 +142,6 @@
"prettier": "3.9.8",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.8",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.84.0",
@@ -9864,7 +9862,6 @@
"version": "3.34.3",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz",
"integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10"
@@ -12321,7 +12318,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -13005,7 +13001,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -15263,7 +15258,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -15648,7 +15642,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-cytoscapejs/-/react-cytoscapejs-2.0.0.tgz",
"integrity": "sha512-t3SSl1DQy7+JQjN+8QHi1anEJlM3i3aAeydHTsJwmjo/isyKK7Rs7oCvU6kZsB9NwZidzZQR21Vm2PcBLG/Tjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
@@ -15748,7 +15741,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-markdown": {
@@ -18511,6 +18503,14 @@
},
"bin": {
"termix-plugin": "cli/index.mjs"
},
"peerDependencies": {
"drizzle-orm": "*"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
}
},
"plugins/ai": {
@@ -18551,6 +18551,10 @@
"plugins/network-topology": {
"name": "@termix-plugin/network-topology",
"version": "1.0.0",
"dependencies": {
"cytoscape": "^3.34.3",
"react-cytoscapejs": "^2.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
-2
View File
@@ -166,7 +166,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^10.0.5",
"cytoscape": "^3.34.3",
"drizzle-kit": "^0.31.10",
"electron": "^43.4.1",
"electron-builder": "^26.15.3",
@@ -185,7 +184,6 @@
"prettier": "3.9.8",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.8",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.84.0",
+2 -2
View File
@@ -1510,8 +1510,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 106 files | D1 |
| A plugin importing core by relative path | Warning | 68 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 105 files | D1 |
| A plugin importing core by relative path | Warning | 67 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by
+7 -1
View File
@@ -30,13 +30,19 @@
"automations": "^1.0.0"
},
"optionalDependencies": {
"workspaces": "^1.0.0"
"workspaces": "^1.0.0",
"network-topology": "^1.0.0"
},
"requires": [
{
"service": "workspaces.saved",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "network-topology.graph",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
+23
View File
@@ -28,3 +28,26 @@ export async function listSavedWorkspaces(
return null;
}
}
/** Another plugin's graph, as ctx.services.get("network-topology.graph") returns it. */
interface NetworkTopologyGraph {
get: () => Promise<unknown | null>;
}
/**
* The user's saved network topology, or null when the network-topology
* plugin is off or the user may not use it. Optional: the tool disappears
* without it.
*/
export async function getNetworkTopology(
userId: string,
): Promise<unknown | null> {
if (!current) return null;
try {
return await current
.get<NetworkTopologyGraph>("network-topology.graph", { userId })
.get();
} catch {
return null;
}
}
+4 -8
View File
@@ -5,11 +5,10 @@ import {
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
createCurrentNetworkTopologyRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
import { listSavedWorkspaces } from "../services.js";
import { listSavedWorkspaces, getNetworkTopology } from "../services.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -260,12 +259,9 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const topology =
await createCurrentNetworkTopologyRepository().findByUserId(
context.userId,
);
if (!topology) return { topology: null };
return { topology: (topology as any).data ?? null };
const topology = await getNetworkTopology(context.userId);
if (topology === null) return { topology: null, unavailable: true };
return { topology };
},
},
];
+6
View File
@@ -56,5 +56,11 @@
},
"dashboardTab": {
"nodes": "{{count}} nodes"
},
"permissions": {
"use": {
"title": "Use network graph",
"description": "View and edit the network topology graph."
}
}
}
+15
View File
@@ -15,6 +15,13 @@
"api": "1"
},
"capabilities": ["db:own", "network:serve", "ui:surface"],
"provides": [
{
"service": "network-topology.graph",
"version": "1.0.0",
"permission": "network-topology.use"
}
],
"contributes": {
"tabs": [
{
@@ -30,6 +37,14 @@
"titleKey": "dashboard.networkGraph",
"icon": "Network"
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
},
"locales": "locales",
@@ -0,0 +1,12 @@
-- network-topology 0001: adopt_network_topology
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `network_topology` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) 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
);
RENAME TABLE `network_topology` TO `p_network_topology_graphs`;
@@ -0,0 +1,12 @@
-- network-topology 0001: adopt_network_topology
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "network_topology" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) 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
);
ALTER TABLE "network_topology" RENAME TO "p_network_topology_graphs";
@@ -0,0 +1,34 @@
{
"version": 1,
"tables": {
"graphs": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"topology": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "network_topology"
}
}
}
@@ -0,0 +1,12 @@
-- network-topology 0001: adopt_network_topology
-- Generated by termix-plugin migrations. Review before committing.
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
);
ALTER TABLE "network_topology" RENAME TO "p_network_topology_graphs";
+4
View File
@@ -9,6 +9,10 @@
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"cytoscape": "^3.34.3",
"react-cytoscapejs": "^2.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
+62 -6
View File
@@ -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();
},
};
}
+92 -170
View File
@@ -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>;
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("network-topology activate", () => {
it("registers a singleton sync entity with the JSON blob serializer", async () => {
server = await startServer();
const [entity] = server.mock.syncEntities;
expect(entity.type).toBe("networkTopology");
expect(entity.singleton).toBe(true);
expect(entity.serialize).toBeTypeOf("function");
expect(entity.deserialize).toBeTypeOf("function");
});
it("offers the user's saved graph as a service", async () => {
server = await startServer();
await server.request("POST", "/", {
body: { topology: { nodes: [{ data: { id: "1" } }], edges: [] } },
});
const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>;
};
const result = await server.mock.ctx.asUser("user-1", () => service.get());
expect(result).toEqual({ nodes: [{ data: { id: "1" } }], edges: [] });
});
it("returns null from the service when the user has no saved graph", async () => {
server = await startServer();
const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>;
};
const result = await server.mock.ctx.asUser("user-1", () => service.get());
expect(result).toBeNull();
});
it("fails closed without db:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["network:serve", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("fails closed without network:serve", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["db:own", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
});
@@ -0,0 +1,91 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: { permissions?: string[]; users?: string[] } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["network-topology.use"],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// network_topology as core's SQLite bootstrap created it before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE network_topology (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
topology TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting network_topology", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO network_topology (user_id, topology, updated_at) VALUES (?, ?, ?)",
)
.run(
"user-1",
'{"nodes":[{"data":{"id":"1"}}],"edges":[]}',
"2026-01-01T00:00:00.000Z",
);
},
});
expect(db.applied).toEqual(["0001_adopt_network_topology"]);
expect(tableExists("network_topology")).toBe(false);
expect(tableExists("p_network_topology_graphs")).toBe(true);
const rows = db.sqlite
.prepare(
"SELECT user_id, topology, updated_at FROM p_network_topology_graphs ORDER BY id",
)
.all();
expect(rows).toEqual([
{
user_id: "user-1",
topology: '{"nodes":[{"data":{"id":"1"}}],"edges":[]}',
updated_at: "2026-01-01T00:00:00.000Z",
},
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("network_topology")).toBe(false);
expect(tableExists("p_network_topology_graphs")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_network_topology_graphs").all(),
).toEqual([]);
});
it("removes a user's topology when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
const insert = db.sqlite.prepare(
"INSERT INTO p_network_topology_graphs (user_id, topology) VALUES (?, ?)",
);
insert.run("user-1", "{}");
insert.run("user-2", "{}");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const remaining = (
db.sqlite
.prepare("SELECT user_id FROM p_network_topology_graphs")
.all() as { user_id: string }[]
).map((row) => row.user_id);
expect(remaining).toEqual(["user-2"]);
});
});
@@ -1,73 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { NetworkTopologyRepository } from "../../../../../src/backend/database/repositories/network-topology-repository.js";
describe("NetworkTopologyRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<NetworkTopologyRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new NetworkTopologyRepository(context, onWrite);
}
it("finds, creates, and updates topology by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
"2026-06-27T00:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
"2026-06-27T01:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("deletes topology and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"nodes":[]}');
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(await repo.findByUserId("user-1")).toBeNull();
});
});
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { graphs } from "../../src/backend/tables.js";
import {
createGraphRepository,
type GraphRepository,
} from "../../src/backend/repository.js";
import { pluginDir } from "./helpers";
let db: TestDb;
let repo: GraphRepository;
beforeEach(async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) =>
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')"),
});
repo = createGraphRepository(db.database, await db.database.define(graphs));
});
afterEach(() => db.close());
describe("graph repository", () => {
it("finds, creates, and updates topology by user id", async () => {
expect(await repo.findByUserId("user-1")).toBeNull();
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
});
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
});
});
it("keeps rows for different users separate", async () => {
await repo.upsertForUser("user-1", '{"nodes":[]}');
await repo.upsertForUser("user-2", '{"nodes":["a"]}');
expect((await repo.findByUserId("user-1"))?.topology).toBe('{"nodes":[]}');
expect((await repo.findByUserId("user-2"))?.topology).toBe(
'{"nodes":["a"]}',
);
});
});
@@ -1,154 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
const state = vi.hoisted(() => ({
currentUserId: "user-1" as string | undefined,
records: new Map<string, { userId: string; topology: string | null }>(),
}));
let server: TestServer;
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
}),
},
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentNetworkTopologyRepository: () => ({
findByUserId: async (userId: string) => state.records.get(userId) ?? null,
upsertForUser: async (userId: string, topology: string) => {
state.records.set(userId, { userId, topology });
},
}),
}));
vi.mock(
"../../../../src/backend/database/routes/network-topology-dispatch.js",
() => ({
registerNetworkTopologyRouter: vi.fn(),
unregisterNetworkTopologyRouter: vi.fn(),
}),
);
const { router } = await import("../../src/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: { body?: Record<string, unknown> }) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: { body?: Record<string, unknown> } = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.records = new Map();
beforeEach(async () => {
server = await startServer();
});
describe("GET /network-topology", () => {
afterEach(async () => {
await server.close();
});
describe("GET /", () => {
it("returns null when the user has no saved topology", async () => {
const res = await invoke("get", "/");
expect(res.jsonBody).toBeNull();
const res = await server.request("GET", "/");
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
it("returns the parsed topology when one exists", async () => {
state.records.set("user-1", {
userId: "user-1",
topology: JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
it("returns the saved topology", async () => {
await server.request("POST", "/", {
body: { topology: { nodes: [{ data: { id: "host-1" } }], edges: [] } },
});
const res = await invoke("get", "/");
expect(res.jsonBody).toEqual({ nodes: [{ id: "host-1" }], edges: [] });
const res = await server.request("GET", "/");
expect(res.body).toEqual({
nodes: [{ data: { id: "host-1" } }],
edges: [],
});
});
it("rejects an unauthenticated request", async () => {
state.currentUserId = undefined;
const res = await invoke("get", "/");
expect(res.statusCode).toBe(401);
it("keeps each user's topology separate", async () => {
await server.request("POST", "/", {
user: "user-1",
body: { topology: { nodes: [{ data: { id: "a" } }], edges: [] } },
});
const res = await server.request("GET", "/", { user: "user-2" });
expect(res.body).toBeNull();
});
});
describe("POST /network-topology", () => {
it("saves the topology and returns success", async () => {
const res = await invoke("post", "/", {
describe("POST /", () => {
it("saves and overwrites the topology", async () => {
const first = await server.request("POST", "/", {
body: { topology: { nodes: [], edges: [] } },
});
expect(first.body).toEqual({ success: true });
expect(res.jsonBody).toEqual({ success: true });
expect(state.records.get("user-1")?.topology).toBe(
JSON.stringify({ nodes: [], edges: [] }),
);
await server.request("POST", "/", {
body: { topology: { nodes: [{ data: { id: "a" } }], edges: [] } },
});
const res = await server.request("GET", "/");
expect(res.body).toEqual({ nodes: [{ data: { id: "a" } }], edges: [] });
});
it("rejects a request with no topology", async () => {
const res = await invoke("post", "/", { body: {} });
expect(res.statusCode).toBe(400);
const res = await server.request("POST", "/", { body: {} });
expect(res.status).toBe(400);
});
});
it("rejects an unauthenticated request", async () => {
state.currentUserId = undefined;
const res = await invoke("post", "/", { body: { topology: {} } });
expect(res.statusCode).toBe(401);
describe("permissions", () => {
it("refuses every route without network-topology.use", async () => {
const denied = await startServer({ permissions: [] });
try {
expect((await denied.request("GET", "/")).status).toBe(403);
expect(
(
await denied.request("POST", "/", {
body: { topology: { nodes: [], edges: [] } },
})
).status,
).toBe(403);
} finally {
await denied.close();
}
});
});
-2
View File
@@ -79,7 +79,6 @@
"plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts",
"plugins/host-metrics/src/frontend/temperature-preference.ts",
"plugins/network-topology/src/frontend/NetworkGraphCard.tsx",
"plugins/proxmox/src/frontend/HostProxmoxTab.tsx",
"plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",
"plugins/proxmox/src/frontend/index.tsx",
@@ -169,7 +168,6 @@
"plugins/host-metrics/src/backend/widgets/system-collector.ts",
"plugins/host-metrics/src/backend/widgets/temperature-collector.ts",
"plugins/host-metrics/src/backend/widgets/uptime-collector.ts",
"plugins/network-topology/src/backend/routes.ts",
"plugins/proxmox/src/backend/routes.ts",
"plugins/remote-desktop/src/backend/guacamole-server.ts",
"plugins/remote-desktop/src/backend/routes.ts",
-24
View File
@@ -1745,30 +1745,6 @@ const migrateSchema = () => {
}
}
try {
sqlite
.prepare("SELECT id FROM network_topology LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS network_topology (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
topology TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create network_topology table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
-14
View File
@@ -718,20 +718,6 @@ export const commandHistory = mysqlTable(
],
);
export const networkTopology = mysqlTable("network_topology", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const hostAccess = mysqlTable(
"host_access",
{
-14
View File
@@ -719,20 +719,6 @@ export const commandHistory = pgTable(
],
);
export const networkTopology = pgTable("network_topology", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = pgTable(
"host_access",
{
-14
View File
@@ -711,20 +711,6 @@ export const commandHistory = sqliteTable(
],
);
export const networkTopology = sqliteTable("network_topology", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = sqliteTable(
"host_access",
{
@@ -29,7 +29,6 @@ import { HostResolutionRepository } from "./host-resolution-repository.js";
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { NetworkTopologyRepository } from "./network-topology-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
@@ -352,13 +351,6 @@ export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
);
}
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
return new NetworkTopologyRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("network_topology_repository_write"),
);
}
export function createCurrentOpenTabRepository(): OpenTabRepository {
return new OpenTabRepository(
createCurrentRepositoryContext(),
@@ -1,63 +0,0 @@
import { eq } from "drizzle-orm";
import { networkTopology } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
export class NetworkTopologyRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserId(userId: string): Promise<NetworkTopologyRecord | null> {
const rows = await this.context.drizzle
.select()
.from(networkTopology)
.where(eq(networkTopology.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async upsertForUser(
userId: string,
topology: string,
updatedAt = new Date().toISOString(),
): Promise<void> {
const existing = await this.findByUserId(userId);
if (existing) {
await this.context.drizzle
.update(networkTopology)
.set({ topology, updatedAt })
.where(eq(networkTopology.userId, userId));
await this.afterWrite();
return;
}
await this.context.drizzle.insert(networkTopology).values({
userId,
topology,
updatedAt,
});
await this.afterWrite();
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(networkTopology)
.where(eq(networkTopology.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -19,7 +19,6 @@ import {
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentNetworkTopologyRepository,
createCurrentOpksshTokenRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
@@ -110,7 +109,6 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
await createCurrentNetworkTopologyRepository().deleteByUserId(userId);
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
await createCurrentHomepageItemRepository().deleteByUserId(userId);
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
+3 -36
View File
@@ -4,12 +4,11 @@
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
* the tombstone repository, a REFERENCES map in sync-references.ts and a
* frozen array in electron/. Four lists that had to agree and had no test
* saying they did - which is how networkTopology came to be missing from one
* of them. Now there is one registration per entity and the rest is derived.
* saying they did. Now there is one registration per entity and the rest is
* derived.
*
* The order values reproduce the dependency order the Electron array encoded:
* anything referenced by another entity syncs first, and networkTopology
* syncs last because it holds host ids inside a JSON blob.
* anything referenced by another entity syncs first.
*/
import {
@@ -22,14 +21,12 @@ import {
dashboardServiceLinks,
homepageItems,
userPreferences,
networkTopology,
} from "../db/schema.js";
import {
CORE_OWNER,
registerEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import { mapTopologyHostIds } from "./sync-references.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
@@ -140,36 +137,6 @@ export function registerCoreSyncEntities(): void {
singleton: true,
readOnlyFields: ["storageMode"],
});
registerEntity(CORE_OWNER, {
type: "networkTopology",
table: networkTopology,
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);
},
),
}),
});
}
/** The tables a reference may point at, derived from the registrations. */
@@ -12,79 +12,6 @@ import { getEntity } from "../../plugins/sync-registry.js";
*/
export type SyncReferenceEntity = string;
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 });
}
interface SyncReference {
field: string;
syncField: string;
+13 -16
View File
@@ -96,16 +96,20 @@ describe("core sync entities", () => {
registerCoreSyncEntities();
});
it("registers every entity the Electron client knows about", () => {
// Nothing asserted this before, which is how networkTopology came to be
// missing from the list in sync.test.ts.
expect([...listEntityTypes()].sort()).toEqual(
[...SYNCED_ENTITY_TYPES].sort(),
);
it("registers every core-owned entity the Electron client knows about", () => {
// The frozen array also lists networkTopology, which is now registered
// by the network-topology plugin rather than core.
const coreTypes = new Set(listEntityTypes());
for (const type of SYNCED_ENTITY_TYPES) {
if (type === "networkTopology") continue;
expect(coreTypes.has(type), type).toBe(true);
}
});
it("keeps the dependency order the frozen Electron array encodes", () => {
expect(listEntityTypes()).toEqual([...SYNCED_ENTITY_TYPES]);
expect(listEntityTypes()).toEqual(
[...SYNCED_ENTITY_TYPES].filter((type) => type !== "networkTopology"),
);
});
it("owns all of them as core", () => {
@@ -131,12 +135,12 @@ describe("core sync entities", () => {
}
});
it("marks the two singletons and nothing else", () => {
it("marks the one singleton and nothing else", () => {
const singletons = listEntities()
.filter((entity) => entity.singleton)
.map((entity) => entity.type);
expect(singletons.sort()).toEqual(["networkTopology", "userPreferences"]);
expect(singletons.sort()).toEqual(["userPreferences"]);
});
it("keeps the read-only fields that must survive a sync payload", () => {
@@ -146,13 +150,6 @@ describe("core sync entities", () => {
]);
});
it("gives networkTopology the custom serializer its JSON blob needs", () => {
const topology = getEntity("networkTopology");
expect(topology?.serialize).toBeTypeOf("function");
expect(topology?.deserialize).toBeTypeOf("function");
});
it("is idempotent, because several modules prime it", () => {
const before = listEntityTypes();
registerCoreSyncEntities();
-53
View File
@@ -1,36 +1,5 @@
import { authApi, handleApiError } from "@/main-axios";
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[];
}
/**
* A snippet row as the list endpoint returns it. Callers that need the full
* shape narrow it themselves; only the id is relied on across the app.
@@ -100,28 +69,6 @@ export async function executeSnippet(
}
}
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
try {
const response = await authApi.get("/plugin-api/network-topology/");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch network topology");
}
}
export async function saveNetworkTopology(
topology: NetworkTopologyData,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/plugin-api/network-topology/", {
topology,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save network topology");
}
}
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets/folders");
-7
View File
@@ -2151,19 +2151,12 @@ export {
// SNIPPETS API
// ============================================================================
export type {
NetworkTopologyNode,
NetworkTopologyEdge,
NetworkTopologyData,
} from "@/api/snippets-api";
export {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
executeSnippet,
getNetworkTopology,
saveNetworkTopology,
getSnippetFolders,
createSnippetFolder,
updateSnippetFolderMetadata,