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

+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 });
}