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