diff --git a/plugins/network-topology/backend/index.mjs b/plugins/network-topology/backend/index.mjs new file mode 100644 index 000000000..d9aaa8da8 --- /dev/null +++ b/plugins/network-topology/backend/index.mjs @@ -0,0 +1,60 @@ +/** + * Network Topology - first-party, in-process plugin. + * + * Unlike the other first-party plugins (see src/backend/plugins/first-party.ts), + * this one does not own a transport or touch the shared SSH pool -- its two + * routes are plain per-user JSON CRUD against a single repository. It runs + * in-process anyway to keep its existing /network-topology URL and its + * existing dedicated table (network_topology, keyed by userId) rather than + * moving onto /plugin-api/network-topology/* and the generic key-value + * ctx.storage the worker tier would require, matching how fleets/proxmox/ + * automations were migrated in place rather than reworked around the worker + * ctx boundary. + * + * Like fleets and proxmox, this plugin does not get its own port -- it + * registers its Express router with the always-mounted /network-topology + * dispatcher in src/backend/database/routes/network-topology-dispatch.ts. + * + * routes.ts is what used to be + * src/backend/database/routes/network-topology.ts, moved here and rewritten + * from `export default router` into + * startNetworkTopologyService()/stopNetworkTopologyService(), called from + * activate()/deactivate() below. The network_topology table and its + * repository stay in core (src/backend/database/db/schema.ts, + * src/backend/database/repositories/network-topology-repository.ts) -- this + * plugin system has no owned-table migration mechanism of its own, and the + * table carries an FK into core's users table. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled + * .js for routes.js sits right next to this .mjs. Under vitest/dev nothing + * imports this plugin directly (the loader loads in-process plugins with a + * plain dynamic import, no tsx loader involved), so the only layout that + * needs to resolve here is the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startNetworkTopologyService(); + ctx.log.info("Network Topology router registered at /network-topology"); +} + +export async function deactivate() { + if (routesModule) { + routesModule.stopNetworkTopologyService(); + routesModule = null; + } +} diff --git a/src/backend/database/routes/network-topology.ts b/plugins/network-topology/backend/routes.ts similarity index 88% rename from src/backend/database/routes/network-topology.ts rename to plugins/network-topology/backend/routes.ts index 95167f23b..e4ddbb94a 100644 --- a/src/backend/database/routes/network-topology.ts +++ b/plugins/network-topology/backend/routes.ts @@ -1,10 +1,15 @@ import express from "express"; -import { AuthManager } from "../../utils/auth-manager.js"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { databaseLogger } from "../../utils/logger.js"; -import { createCurrentNetworkTopologyRepository } from "../repositories/factory.js"; +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 { + registerNetworkTopologyRouter, + unregisterNetworkTopologyRouter, +} from "../../../src/backend/database/routes/network-topology-dispatch.js"; + +export const router = express.Router(); -const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); @@ -174,4 +179,10 @@ router.post( }, ); -export default router; +export function startNetworkTopologyService(): void { + registerNetworkTopologyRouter(router); +} + +export function stopNetworkTopologyService(): void { + unregisterNetworkTopologyRouter(); +} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/plugins/network-topology/frontend/NetworkGraphCard.tsx similarity index 100% rename from src/ui/dashboard/cards/NetworkGraphCard.tsx rename to plugins/network-topology/frontend/NetworkGraphCard.tsx diff --git a/plugins/network-topology/frontend/index.mjs b/plugins/network-topology/frontend/index.mjs new file mode 100644 index 000000000..670c4e1fd --- /dev/null +++ b/plugins/network-topology/frontend/index.mjs @@ -0,0 +1,37 @@ +/** + * Frontend half of the network-topology plugin. + * + * NetworkGraphCard renders in two modes: as a full tab (network_graph) and + * embedded as a dashboard card. Both usages are wired today through the + * existing tabUtils.tsx / DashboardTab.tsx switch statements the same way + * fleet-inventory's tab is, not through register() below -- per + * src/ui/shell/pluginLoader.ts's own comment, "there is no plugin loader + * yet" for the frontend half. + * + * register()/unregister() still follow the same shape every other plugin's + * frontend entry does, so this is ready the moment a real frontend plugin + * loader exists. + */ + +export const id = "network-topology"; +export const tabId = "network_graph"; + +export async function register({ registerRailItem, registerTabComponent, icons }) { + registerTabComponent?.(tabId, () => + import("./NetworkGraphCard.tsx").then((m) => ({ + default: m.NetworkGraphCard, + })), + ); + + registerRailItem?.({ + id: tabId, + icon: icons.Network, + labelKey: "nav.networkGraph", + kind: "tab", + }); +} + +export async function unregister({ unregisterRailItem, unregisterTabComponent }) { + unregisterRailItem?.(tabId); + unregisterTabComponent?.(tabId); +} diff --git a/plugins/network-topology/manifest.json b/plugins/network-topology/manifest.json new file mode 100644 index 000000000..f3475224a --- /dev/null +++ b/plugins/network-topology/manifest.json @@ -0,0 +1,33 @@ +{ + "id": "network-topology", + "name": "Network Topology", + "version": "1.0.0", + "description": "Visualize hosts and their connections as an interactive graph, saved per user.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Infrastructure", + "icon": "Network", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "contributes": { + "tabs": [ + { + "id": "network_graph", + "titleKey": "nav.networkGraph", + "icon": "Network", + "openFrom": ["rail", "palette"] + } + ], + "dashboardCards": [ + { "id": "network_graph", "titleKey": "dashboard.networkGraph" } + ] + }, + "sidecars": [] +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 7ddbacbdd..139a78c70 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -18,7 +18,7 @@ import sessionLogRoutes from "./routes/session-log-routes.js"; import guacamoleDispatch from "./routes/guacamole-dispatch.js"; import sessionSharingRoutes from "../hosts/session-sharing/routes.js"; import collabRoutes from "../hosts/collab/routes.js"; -import networkTopologyRoutes from "./routes/network-topology.js"; +import networkTopologyDispatch from "./routes/network-topology-dispatch.js"; import rbacRoutes from "./routes/rbac.js"; import openTabsRoutes from "./routes/open-tabs.js"; import userPreferencesRoutes from "./routes/user-preferences.js"; @@ -1761,7 +1761,7 @@ app.use("/session_logs", sessionLogRoutes); app.use("/guacamole", guacamoleDispatch); app.use("/session-sharing", sessionSharingRoutes); app.use("/collab", collabRoutes); -app.use("/network-topology", networkTopologyRoutes); +app.use("/network-topology", networkTopologyDispatch); app.use("/rbac", rbacRoutes); app.use("/open-tabs", openTabsRoutes); app.use("/user-preferences", userPreferencesRoutes); diff --git a/src/backend/database/routes/network-topology-dispatch.ts b/src/backend/database/routes/network-topology-dispatch.ts new file mode 100644 index 000000000..10db70dba --- /dev/null +++ b/src/backend/database/routes/network-topology-dispatch.ts @@ -0,0 +1,47 @@ +// Mounted at /network-topology. Network Topology ships as a first-party +// in-process plugin (plugins/network-topology) rather than a route file +// imported directly here, so this module exists to keep /network-topology/* +// working at its original path without handing the plugin a reference to the +// shared app instance. The plugin runtime registers its router via +// registerNetworkTopologyRouter when it activates, and clears it on +// deactivate, so a disabled plugin falls back to the 404 below. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the Network Topology plugin's own), and +// every route in that router already applies authenticateJWT itself, the same +// as every other router mounted alongside it here (/users, /host, /snippets, +// ...). /plugin-api gates at the mount point because it forwards to arbitrary +// third-party plugin code that cannot be assumed to gate itself; that +// reasoning does not apply to a single first-party router that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { databaseLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerNetworkTopologyRouter(router: Router): void { + activeRouter = router; + databaseLogger.info("Registered Network Topology plugin router", { + operation: "network_topology_dispatch_register", + }); +} + +export function unregisterNetworkTopologyRouter(): void { + if (!activeRouter) return; + activeRouter = null; + databaseLogger.info("Unregistered Network Topology plugin router", { + operation: "network_topology_dispatch_register", + }); +} + +export default function networkTopologyDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "Network Topology is not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 1d89c9b47..196177207 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -83,6 +83,15 @@ * direct repository access plus subscription to the cross-plugin event bus * (host metrics, status and health-check events) that the worker's read-only * ctx.hosts and single-postMessage-reply ctx.http.route cannot support. + * + * network-topology is here for a different reason than the rest of this + * list: its two routes are plain per-user JSON CRUD against a single + * repository, and would fit the worker ctx fine on their own. It stays + * in-process to keep its existing /network-topology URL and its existing + * dedicated table (network_topology, keyed by userId) rather than moving + * onto /plugin-api/network-topology/* and the generic key-value + * ctx.storage the worker tier would require -- a URL and storage-model + * change, not a capability gap. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", @@ -93,6 +102,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "remote-desktop", "fleets", "automations", + "network-topology", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index c29ac95cc..f4c6f783a 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -295,15 +295,16 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // ssh-terminal, docker and host-metrics plugins start their own servers, // so disabling any of them stops its WS/HTTP server. See // plugins/ssh-terminal, plugins/docker and plugins/host-metrics. - // AI, Proxmox, Remote Desktop, Fleets and Automations are also absent for - // the same reason: each plugin registers its router with its own - // dispatcher in database.ts on activate (/ai, /proxmox, /guacamole, - // /fleets, /automations), so disabling any of them makes its routes 404 - // instead of leaving a dead import here. Automations' scheduler (due - // schedules, dwell rechecks, docker-event polling, history pruning) also - // starts from its own activate() rather than here. See plugins/ai, - // plugins/proxmox, plugins/remote-desktop, plugins/fleets and - // plugins/automations. + // AI, Proxmox, Remote Desktop, Fleets, Automations and Network Topology + // are also absent for the same reason: each plugin registers its router + // with its own dispatcher in database.ts on activate (/ai, /proxmox, + // /guacamole, /fleets, /automations, /network-topology), so disabling + // any of them makes its routes 404 instead of leaving a dead import + // here. Automations' scheduler (due schedules, dwell rechecks, + // docker-event polling, history pruning) also starts from its own + // activate() rather than here. See plugins/ai, plugins/proxmox, + // plugins/remote-desktop, plugins/fleets, plugins/automations and + // plugins/network-topology. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index bb4687520..dd9404ccb 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -324,3 +324,53 @@ describe("bundled automations plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled network-topology plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "network-topology"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest( + readBundledManifest("network-topology"), + ); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("network-topology"); + expect(manifest?.category).toBe("Infrastructure"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("network-topology")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("network-topology")).toBe(true); + expect(runsInProcess("network-topology", manifest!.permissions)).toBe(true); + }); + + it("contributes the network_graph tab the shell registers", () => { + const { manifest } = parseManifest(readBundledManifest("network-topology")); + const tab = manifest?.contributes?.tabs?.[0]; + + // The shell keys tab content off this id, so it has to stay "network_graph". + expect(tab?.id).toBe("network_graph"); + expect(tab?.openFrom).toContain("rail"); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "network-topology", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index f6d0e22b3..3ec42d906 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,20 +39,21 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations and network-topology", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", "automations", "docker", "fleets", "host-metrics", + "network-topology", "proxmox", "remote-desktop", "ssh-terminal", ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations and network-topology and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); @@ -61,6 +62,7 @@ describe("first-party allowlist", () => { expect(isFirstParty("remote-desktop")).toBe(true); expect(isFirstParty("fleets")).toBe(true); expect(isFirstParty("automations")).toBe(true); + expect(isFirstParty("network-topology")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -85,6 +87,9 @@ describe("runsInProcess", () => { expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe( true, ); + expect( + runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]), + ).toBe(true); // On the list but not asking for it: stays in a worker, so the manifest // remains an honest description of what the plugin does. @@ -96,6 +101,7 @@ describe("runsInProcess", () => { expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false); expect(runsInProcess("fleets", ["hosts.read"])).toBe(false); expect(runsInProcess("automations", ["hosts.read"])).toBe(false); + expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( @@ -199,6 +205,18 @@ describe("manifest gate on the reserved capability", () => { expect(parsed?.id).toBe("automations"); }); + it("allows it for network-topology", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "network-topology", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("network-topology"); + }); + it("still accepts an ordinary manifest that never mentions it", () => { const { manifest: parsed, errors } = parseManifest( manifest({ permissions: ["hosts.read"] }), diff --git a/src/backend/tests/plugins/network-topology/routes.test.ts b/src/backend/tests/plugins/network-topology/routes.test.ts new file mode 100644 index 000000000..1c390354f --- /dev/null +++ b/src/backend/tests/plugins/network-topology/routes.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response, Router } from "express"; + +const state = vi.hoisted(() => ({ + currentUserId: "user-1" as string | undefined, + records: new Map(), +})); + +vi.mock("../../../database/db/index.js", () => ({ db: {} })); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + (req: Record, _res: unknown, next: () => void) => { + req.userId = state.currentUserId; + next(); + }, + }), + }, +})); + +vi.mock("../../../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("../../../database/routes/network-topology-dispatch.js", () => ({ + registerNetworkTopologyRouter: vi.fn(), + unregisterNetworkTopologyRouter: vi.fn(), +})); + +const { router } = + await import("../../../../../plugins/network-topology/backend/routes.js"); + +function findLayer(method: string, path: string) { + const stack = (router as unknown as Router).stack as Array<{ + route?: { + path: string; + methods: Record; + 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 }) { + 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 } = {}, +) { + const handler = findLayer(method, path); + const { req, res } = makeReqRes(overrides); + await handler(req, res); + return res as unknown as { + statusCode: number; + jsonBody: Record | null; + }; +} + +beforeEach(() => { + state.currentUserId = "user-1"; + state.records = new Map(); +}); + +describe("GET /network-topology", () => { + it("returns null when the user has no saved topology", async () => { + const res = await invoke("get", "/"); + expect(res.jsonBody).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: [] }), + }); + + const res = await invoke("get", "/"); + expect(res.jsonBody).toEqual({ nodes: [{ id: "host-1" }], edges: [] }); + }); + + it("rejects an unauthenticated request", async () => { + state.currentUserId = undefined; + const res = await invoke("get", "/"); + expect(res.statusCode).toBe(401); + }); +}); + +describe("POST /network-topology", () => { + it("saves the topology and returns success", async () => { + const res = await invoke("post", "/", { + body: { topology: { nodes: [], edges: [] } }, + }); + + expect(res.jsonBody).toEqual({ success: true }); + expect(state.records.get("user-1")?.topology).toBe( + JSON.stringify({ nodes: [], edges: [] }), + ); + }); + + it("rejects a request with no topology", async () => { + const res = await invoke("post", "/", { body: {} }); + expect(res.statusCode).toBe(400); + }); + + it("rejects an unauthenticated request", async () => { + state.currentUserId = undefined; + const res = await invoke("post", "/", { body: { topology: {} } }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 7db248b7a..31fcfc3b7 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -50,7 +50,7 @@ import { } from "@/main-axios"; import type { RecentActivityItem, ServiceLink } from "@/main-axios"; import { useTranslation } from "react-i18next"; -import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; +import { NetworkGraphCard } from "../../../plugins/network-topology/frontend/NetworkGraphCard"; import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard"; import { HomepageCanvas } from "@/features/homepage/HomepageCanvas"; diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts index eee9a333d..e04e22c79 100644 --- a/src/ui/shell/pluginLoader.ts +++ b/src/ui/shell/pluginLoader.ts @@ -43,6 +43,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record = { "remote-desktop": ["rdp", "vnc", "telnet"], ai: ["ai"], fleets: ["fleet-inventory"], + "network-topology": ["network_graph"], }; let pluginEnabled = new Map(); diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 881dcd50f..7ec33ea40 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -134,9 +134,11 @@ const SftpTransferTab = lazy(() => })), ); const NetworkGraphCard = lazy(() => - import("@/dashboard/cards/NetworkGraphCard").then((m) => ({ - default: m.NetworkGraphCard, - })), + import("../../../plugins/network-topology/frontend/NetworkGraphCard").then( + (m) => ({ + default: m.NetworkGraphCard, + }), + ), ); const Serial = lazy(() => import("@/features/serial/Serial").then((m) => ({ diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index bbb2c4a9a..c1aa00d61 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -33,7 +33,8 @@ "plugins/proxmox/backend/**/*.ts", "plugins/remote-desktop/backend/**/*.ts", "plugins/fleets/backend/**/*.ts", - "plugins/automations/backend/**/*.ts" + "plugins/automations/backend/**/*.ts", + "plugins/network-topology/backend/**/*.ts" ], "exclude": ["src/**/*.test.ts"] }