mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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,
|
"when": 1790164291881,
|
||||||
"tag": "0031_nebulous_diamondback",
|
"tag": "0031_nebulous_diamondback",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1790164291317,
|
||||||
"tag": "0032_nifty_hedge_knight",
|
"tag": "0032_nifty_hedge_knight",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1790164290763,
|
||||||
"tag": "0028_public_miracleman",
|
"tag": "0028_public_miracleman",
|
||||||
"breakpoints": true
|
"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",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"cytoscape": "^3.34.3",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"electron": "^43.4.1",
|
"electron": "^43.4.1",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
@@ -143,7 +142,6 @@
|
|||||||
"prettier": "3.9.8",
|
"prettier": "3.9.8",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-h5-audio-player": "^3.10.2",
|
"react-h5-audio-player": "^3.10.2",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
@@ -9864,7 +9862,6 @@
|
|||||||
"version": "3.34.3",
|
"version": "3.34.3",
|
||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz",
|
||||||
"integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==",
|
"integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
@@ -12321,7 +12318,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -13005,7 +13001,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -15263,7 +15258,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -15648,7 +15642,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-cytoscapejs/-/react-cytoscapejs-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-cytoscapejs/-/react-cytoscapejs-2.0.0.tgz",
|
||||||
"integrity": "sha512-t3SSl1DQy7+JQjN+8QHi1anEJlM3i3aAeydHTsJwmjo/isyKK7Rs7oCvU6kZsB9NwZidzZQR21Vm2PcBLG/Tjg==",
|
"integrity": "sha512-t3SSl1DQy7+JQjN+8QHi1anEJlM3i3aAeydHTsJwmjo/isyKK7Rs7oCvU6kZsB9NwZidzZQR21Vm2PcBLG/Tjg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
@@ -15748,7 +15741,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
@@ -18511,6 +18503,14 @@
|
|||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"termix-plugin": "cli/index.mjs"
|
"termix-plugin": "cli/index.mjs"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"drizzle-orm": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"drizzle-orm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins/ai": {
|
"plugins/ai": {
|
||||||
@@ -18551,6 +18551,10 @@
|
|||||||
"plugins/network-topology": {
|
"plugins/network-topology": {
|
||||||
"name": "@termix-plugin/network-topology",
|
"name": "@termix-plugin/network-topology",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"cytoscape": "^3.34.3",
|
||||||
|
"react-cytoscapejs": "^2.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@termix/plugin-sdk": "*"
|
"@termix/plugin-sdk": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"cytoscape": "^3.34.3",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"electron": "^43.4.1",
|
"electron": "^43.4.1",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
@@ -185,7 +184,6 @@
|
|||||||
"prettier": "3.9.8",
|
"prettier": "3.9.8",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-h5-audio-player": "^3.10.2",
|
"react-h5-audio-player": "^3.10.2",
|
||||||
"react-hook-form": "^7.84.0",
|
"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 | - |
|
| Core importing a plugin backend | **Error** | 0 | - |
|
||||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||||
| The shell importing plugin code | **Error** | 0 | - |
|
| The shell importing plugin code | **Error** | 0 | - |
|
||||||
| A plugin frontend importing core through `@/` | Warning | 106 files | D1 |
|
| A plugin frontend importing core through `@/` | Warning | 105 files | D1 |
|
||||||
| A plugin importing core by relative path | Warning | 68 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 plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||||
|
|
||||||
A warning does not fail a build, so the counts are held by
|
A warning does not fail a build, so the counts are held by
|
||||||
|
|||||||
@@ -30,13 +30,19 @@
|
|||||||
"automations": "^1.0.0"
|
"automations": "^1.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"workspaces": "^1.0.0"
|
"workspaces": "^1.0.0",
|
||||||
|
"network-topology": "^1.0.0"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
{
|
{
|
||||||
"service": "workspaces.saved",
|
"service": "workspaces.saved",
|
||||||
"versionRange": "^1.0.0",
|
"versionRange": "^1.0.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "network-topology.graph",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"providesSecret": [
|
"providesSecret": [
|
||||||
|
|||||||
@@ -28,3 +28,26 @@ export async function listSavedWorkspaces(
|
|||||||
return null;
|
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,
|
createCurrentFleetRepository,
|
||||||
createCurrentHomepageItemRepository,
|
createCurrentHomepageItemRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentNetworkTopologyRepository,
|
|
||||||
createCurrentSnippetRepository,
|
createCurrentSnippetRepository,
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||||
import { num, objectSchema, type AiTool } from "./types.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
|
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||||
@@ -260,12 +259,9 @@ export const readTools: AiTool[] = [
|
|||||||
category: "read",
|
category: "read",
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const topology =
|
const topology = await getNetworkTopology(context.userId);
|
||||||
await createCurrentNetworkTopologyRepository().findByUserId(
|
if (topology === null) return { topology: null, unavailable: true };
|
||||||
context.userId,
|
return { topology };
|
||||||
);
|
|
||||||
if (!topology) return { topology: null };
|
|
||||||
return { topology: (topology as any).data ?? null };
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -56,5 +56,11 @@
|
|||||||
},
|
},
|
||||||
"dashboardTab": {
|
"dashboardTab": {
|
||||||
"nodes": "{{count}} nodes"
|
"nodes": "{{count}} nodes"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"use": {
|
||||||
|
"title": "Use network graph",
|
||||||
|
"description": "View and edit the network topology graph."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"api": "1"
|
"api": "1"
|
||||||
},
|
},
|
||||||
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"service": "network-topology.graph",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"permission": "network-topology.use"
|
||||||
|
}
|
||||||
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"tabs": [
|
"tabs": [
|
||||||
{
|
{
|
||||||
@@ -30,6 +37,14 @@
|
|||||||
"titleKey": "dashboard.networkGraph",
|
"titleKey": "dashboard.networkGraph",
|
||||||
"icon": "Network"
|
"icon": "Network"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"name": "use",
|
||||||
|
"titleKey": "permissions.use.title",
|
||||||
|
"descriptionKey": "permissions.use.description",
|
||||||
|
"defaultRoles": ["admin", "user"]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"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",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cytoscape": "^3.34.3",
|
||||||
|
"react-cytoscapejs": "^2.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@termix/plugin-sdk": "*"
|
"@termix/plugin-sdk": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,72 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import {
|
import { graphs } from "./tables.js";
|
||||||
startNetworkTopologyService,
|
import { createGraphRepository } from "./repository.js";
|
||||||
stopNetworkTopologyService,
|
import { registerGraphRoutes } from "./routes.js";
|
||||||
} 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) {
|
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(
|
ctx.log.info(
|
||||||
"Network Topology routes mounted at /plugin-api/network-topology",
|
"Network Topology routes mounted at /plugin-api/network-topology",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
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 type { Request, Response, Router } from "express";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import type { GraphRepository } from "./repository.js";
|
||||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
|
||||||
import { createCurrentNetworkTopologyRepository } from "../../../../src/backend/database/repositories/factory.js";
|
|
||||||
|
|
||||||
export const router = express.Router();
|
function actor(ctx: PluginContext): string {
|
||||||
|
// Core's plugin router authenticates every request and runs it as that user.
|
||||||
const authManager = AuthManager.getInstance();
|
return ctx.currentActor() as string;
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* Mounts the graph routes on the plugin's router, which core serves at
|
||||||
* /network-topology:
|
* /plugin-api/network-topology with auth in front.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
router.get(
|
export function registerGraphRoutes(
|
||||||
"/",
|
router: Router,
|
||||||
authenticateJWT,
|
repo: GraphRepository,
|
||||||
async (req: express.Request, res: express.Response) => {
|
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 {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const record = await repo.findByUserId(userId);
|
||||||
if (!userId) {
|
const topology = record?.topology ? JSON.parse(record.topology) : null;
|
||||||
return res.status(401).json({ error: "User not authenticated" });
|
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);
|
* @openapi
|
||||||
|
* /plugin-api/network-topology:
|
||||||
if (record) {
|
* post:
|
||||||
const topologyStr = record.topology;
|
* summary: Save network topology for authenticated user
|
||||||
const topology = topologyStr ? JSON.parse(topologyStr) : null;
|
* description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise.
|
||||||
return res.json(topology);
|
* tags:
|
||||||
} else {
|
* - Network Topology
|
||||||
return res.json(null);
|
* requestBody:
|
||||||
}
|
* required: true
|
||||||
} catch (error) {
|
* content:
|
||||||
databaseLogger.error("Failed to fetch network topology", error, {
|
* application/json:
|
||||||
operation: "network_topology_fetch",
|
* schema:
|
||||||
userId: (req as AuthenticatedRequest).userId,
|
* type: object
|
||||||
});
|
* required:
|
||||||
return res.status(500).json({
|
* - topology
|
||||||
error: "Failed to fetch network topology",
|
* properties:
|
||||||
details: (error as Error).message,
|
* 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 {
|
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 =
|
const topologyStr =
|
||||||
typeof topology === "string" ? topology : JSON.stringify(topology);
|
typeof topology === "string" ? topology : JSON.stringify(topology);
|
||||||
|
await repo.upsertForUser(userId, topologyStr);
|
||||||
await createCurrentNetworkTopologyRepository().upsertForUser(
|
res.json({ success: true });
|
||||||
userId,
|
} catch (err) {
|
||||||
topologyStr,
|
ctx.log.error(
|
||||||
|
"Failed to save network topology",
|
||||||
|
err instanceof Error ? err : new Error(String(err)),
|
||||||
);
|
);
|
||||||
|
res.status(500).json({ error: "Failed to save network topology" });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
useMemo,
|
||||||
useReducer,
|
useReducer,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Card } from "@/components/card";
|
|
||||||
import CytoscapeComponent from "react-cytoscapejs";
|
import CytoscapeComponent from "react-cytoscapejs";
|
||||||
import cytoscape from "cytoscape";
|
import cytoscape from "cytoscape";
|
||||||
import {
|
import {
|
||||||
getSSHHosts,
|
Card,
|
||||||
getNetworkTopology,
|
Button,
|
||||||
saveNetworkTopology,
|
Badge,
|
||||||
type SSHHostWithStatus,
|
|
||||||
type NetworkTopologyEdge,
|
|
||||||
type NetworkTopologyNode,
|
|
||||||
} from "@/main-axios";
|
|
||||||
import { Button } from "@/components/button";
|
|
||||||
import { Badge } from "@/components/badge";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
} from "@/components/alert-dialog";
|
|
||||||
import {
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/dialog";
|
Input,
|
||||||
import { Input } from "@/components/input";
|
Label,
|
||||||
import { Label } from "@/components/label";
|
Select2,
|
||||||
|
useTabsSafe,
|
||||||
|
} from "@termix/plugin-sdk/ui";
|
||||||
|
import {
|
||||||
|
useTranslation,
|
||||||
|
usePluginApi,
|
||||||
|
useHosts,
|
||||||
|
type PluginHostRecord,
|
||||||
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -59,11 +57,12 @@ import {
|
|||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import {
|
||||||
import { useTabsSafe } from "@/shell/TabContext";
|
createNetworkTopologyApi,
|
||||||
import { cn } from "@/lib/utils";
|
type NetworkTopologyData,
|
||||||
import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
|
type NetworkTopologyNode,
|
||||||
import { Select2 } from "@/components/select2";
|
type NetworkTopologyEdge,
|
||||||
|
} from "./network-topology-api";
|
||||||
|
|
||||||
const AVAILABLE_COLORS = [
|
const AVAILABLE_COLORS = [
|
||||||
{ value: "#ef4444", label: "Red" },
|
{ value: "#ef4444", label: "Red" },
|
||||||
@@ -76,8 +75,19 @@ const AVAILABLE_COLORS = [
|
|||||||
{ value: "#6b7280", label: "Gray" },
|
{ 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 {
|
interface HostMap {
|
||||||
[key: string]: SSHHostWithStatus;
|
[key: string]: HostWithStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
@@ -100,6 +110,24 @@ interface NetworkGraphCardProps {
|
|||||||
|
|
||||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
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 {
|
function resolveCssVar(varName: string, fallback: string): string {
|
||||||
const raw = getComputedStyle(document.documentElement)
|
const raw = getComputedStyle(document.documentElement)
|
||||||
.getPropertyValue(varName)
|
.getPropertyValue(varName)
|
||||||
@@ -205,15 +233,16 @@ export function NetworkGraphCard({
|
|||||||
}: NetworkGraphCardProps): React.ReactElement {
|
}: NetworkGraphCardProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addTab } = useTabsSafe();
|
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.
|
// 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.
|
// This avoids the "bb is undefined" crash when the card is hidden via display:none.
|
||||||
const [containerReady, setContainerReady] = useState(false);
|
const [containerReady, setContainerReady] = useState(false);
|
||||||
|
|
||||||
const [elements, setElements] = useState<NetworkElement[]>([]);
|
const [elements, setElements] = useState<NetworkElement[]>([]);
|
||||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
|
||||||
const [hostMap, setHostMap] = useState<HostMap>({});
|
const [hostMap, setHostMap] = useState<HostMap>({});
|
||||||
const hostMapRef = useRef<HostMap>({});
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -237,7 +266,7 @@ export function NetworkGraphCard({
|
|||||||
const [selectedHostForEdge, setSelectedHostForEdge] = useState("");
|
const [selectedHostForEdge, setSelectedHostForEdge] = useState("");
|
||||||
const [targetHostForEdge, setTargetHostForEdge] = useState("");
|
const [targetHostForEdge, setTargetHostForEdge] = useState("");
|
||||||
const [selectedNodeForDetail, setSelectedNodeForDetail] =
|
const [selectedNodeForDetail, setSelectedNodeForDetail] =
|
||||||
useState<SSHHostWithStatus | null>(null);
|
useState<HostWithStatus | null>(null);
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||||
visible: false,
|
visible: false,
|
||||||
@@ -265,30 +294,29 @@ export function NetworkGraphCard({
|
|||||||
}, [containerReady]);
|
}, [containerReady]);
|
||||||
|
|
||||||
const cyRef = useRef<cytoscape.Core | null>(null);
|
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 contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const cyContainerRef = useRef<HTMLDivElement>(null);
|
const cyContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// hostMap tracks the live host list, so status/tags update without polling.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hostMapRef.current = hostMap;
|
const newMap: HostMap = {};
|
||||||
}, [hostMap]);
|
(liveHosts as HostWithStatus[]).forEach((h) => (newMap[String(h.id)] = h));
|
||||||
|
setHostMap(newMap);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!cyRef.current) return;
|
||||||
if (!isVisible) {
|
cyRef.current.nodes().forEach((node) => {
|
||||||
if (statusIntervalRef.current) {
|
if (node.isParent()) return;
|
||||||
clearInterval(statusIntervalRef.current);
|
const h = newMap[node.data("id")];
|
||||||
statusIntervalRef.current = null;
|
if (h) {
|
||||||
|
node.data("status", h.status ?? "unknown");
|
||||||
|
node.data("tags", h.tags ?? []);
|
||||||
}
|
}
|
||||||
return;
|
});
|
||||||
}
|
}, [liveHosts]);
|
||||||
|
|
||||||
loadData();
|
useEffect(() => {
|
||||||
statusIntervalRef.current = setInterval(() => {
|
if (!isVisible) return;
|
||||||
if (document.visibilityState === "hidden") return;
|
|
||||||
updateHostStatuses();
|
|
||||||
}, 30000);
|
|
||||||
const onClickOutside = (e: MouseEvent) => {
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
contextMenuRef.current &&
|
contextMenuRef.current &&
|
||||||
@@ -307,27 +335,25 @@ export function NetworkGraphCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (statusIntervalRef.current) clearInterval(statusIntervalRef.current);
|
|
||||||
document.removeEventListener("mousedown", onClickOutside, true);
|
document.removeEventListener("mousedown", onClickOutside, true);
|
||||||
themeObserver.disconnect();
|
themeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const hostsData = await getSSHHosts();
|
|
||||||
const hostsArray = Array.isArray(hostsData) ? hostsData : [];
|
|
||||||
setHosts(hostsArray);
|
|
||||||
const newMap: HostMap = {};
|
const newMap: HostMap = {};
|
||||||
hostsArray.forEach((h) => (newMap[String(h.id)] = h));
|
(liveHosts as HostWithStatus[]).forEach(
|
||||||
|
(h) => (newMap[String(h.id)] = h),
|
||||||
|
);
|
||||||
setHostMap(newMap);
|
setHostMap(newMap);
|
||||||
|
|
||||||
let nodes: NetworkTopologyNode[] = [];
|
let nodes: NetworkTopologyNode[] = [];
|
||||||
let edges: NetworkTopologyEdge[] = [];
|
let edges: NetworkTopologyEdge[] = [];
|
||||||
try {
|
try {
|
||||||
const topo = await getNetworkTopology();
|
const topo = await api.get();
|
||||||
if (topo?.nodes && Array.isArray(topo.nodes)) {
|
if (topo?.nodes && Array.isArray(topo.nodes)) {
|
||||||
nodes = topo.nodes.map((node) => {
|
nodes = topo.nodes.map((node) => {
|
||||||
const h = newMap[node.data.id];
|
const h = newMap[node.data.id];
|
||||||
@@ -360,34 +386,18 @@ export function NetworkGraphCard({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [api, liveHosts]);
|
||||||
|
|
||||||
const updateHostStatuses = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!cyRef.current) return;
|
if (!isVisible) return;
|
||||||
try {
|
void loadData();
|
||||||
const updated = await getSSHHosts();
|
// Only the initial load per visibility change; hostMap stays live via the
|
||||||
const updatedMap: HostMap = {};
|
// liveHosts effect above.
|
||||||
updated.forEach((h) => (updatedMap[String(h.id)] = h));
|
}, [isVisible]);
|
||||||
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 */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const debouncedSave = useCallback(() => {
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
||||||
saveTimeoutRef.current = setTimeout(() => saveCurrentLayout(), 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveCurrentLayout = async () => {
|
const saveCurrentLayout = useCallback(async () => {
|
||||||
if (!cyRef.current) return;
|
if (!cyRef.current) return;
|
||||||
try {
|
try {
|
||||||
const nodes = cyRef.current.nodes().map((n) => ({
|
const nodes = cyRef.current.nodes().map((n) => ({
|
||||||
@@ -409,11 +419,16 @@ export function NetworkGraphCard({
|
|||||||
target: e.data("target"),
|
target: e.data("target"),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await saveNetworkTopology({ nodes, edges });
|
await api.save({ nodes, edges } as NetworkTopologyData);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
}, [api]);
|
||||||
|
|
||||||
|
const debouncedSave = useCallback(() => {
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = setTimeout(() => void saveCurrentLayout(), 1000);
|
||||||
|
}, [saveCurrentLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cyRef.current || loading || elements.length === 0) return;
|
if (!cyRef.current || loading || elements.length === 0) return;
|
||||||
@@ -553,7 +568,7 @@ export function NetworkGraphCard({
|
|||||||
[applyStyle, debouncedSave, embedded],
|
[applyStyle, debouncedSave, embedded],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Zoom centered on the viewport midpoint — no panning
|
// Zoom centered on the viewport midpoint, no panning
|
||||||
const zoomIn = useCallback(() => {
|
const zoomIn = useCallback(() => {
|
||||||
const cy = cyRef.current;
|
const cy = cyRef.current;
|
||||||
if (!cy || !cyContainerRef.current) return;
|
if (!cy || !cyContainerRef.current) return;
|
||||||
@@ -623,7 +638,7 @@ export function NetworkGraphCard({
|
|||||||
fireOpen(contextMenu.targetId, appType);
|
fireOpen(contextMenu.targetId, appType);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasTunnelConnections = (h: SSHHostWithStatus | undefined) => {
|
const hasTunnelConnections = (h: HostWithStatus | undefined) => {
|
||||||
if (!h?.tunnelConnections) return false;
|
if (!h?.tunnelConnections) return false;
|
||||||
try {
|
try {
|
||||||
const arr = Array.isArray(h.tunnelConnections)
|
const arr = Array.isArray(h.tunnelConnections)
|
||||||
@@ -781,10 +796,11 @@ export function NetworkGraphCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const availableHostsForAdd = useMemo(() => {
|
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));
|
const existing = new Set(elements.map((e) => e.data.id));
|
||||||
return hosts.filter((h) => !existing.has(String(h.id)));
|
return hostList.filter((h) => !existing.has(String(h.id)));
|
||||||
}, [hosts, elements]);
|
}, [liveHosts, elements]);
|
||||||
|
|
||||||
const btnCls =
|
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";
|
"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}
|
key={c.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewGroupColor(c.value)}
|
onClick={() => setNewGroupColor(c.value)}
|
||||||
className={cn(
|
className={`h-9 rounded border-2 transition-all ${
|
||||||
"h-9 rounded border-2 transition-all",
|
|
||||||
newGroupColor === c.value
|
newGroupColor === c.value
|
||||||
? "border-accent-brand ring-1 ring-accent-brand"
|
? "border-accent-brand ring-1 ring-accent-brand"
|
||||||
: "border-border hover:border-muted-foreground",
|
: "border-border hover:border-muted-foreground"
|
||||||
)}
|
}`}
|
||||||
style={{ backgroundColor: c.value }}
|
style={{ backgroundColor: c.value }}
|
||||||
title={c.label}
|
title={c.label}
|
||||||
/>
|
/>
|
||||||
@@ -1225,7 +1240,7 @@ export function NetworkGraphCard({
|
|||||||
reader.onload = async (evt) => {
|
reader.onload = async (evt) => {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(evt.target?.result as string);
|
const json = JSON.parse(evt.target?.result as string);
|
||||||
await saveNetworkTopology({
|
await api.save({
|
||||||
nodes: json.nodes,
|
nodes: json.nodes,
|
||||||
edges: json.edges,
|
edges: json.edges,
|
||||||
});
|
});
|
||||||
@@ -1349,7 +1364,7 @@ export function NetworkGraphCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── embedded card ── */
|
/* embedded card */
|
||||||
const nodeCount = elements.filter((e) => !e.data.source).length;
|
const nodeCount = elements.filter((e) => !e.data.source).length;
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0 min-h-0">
|
<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",
|
titleKey: "nav.networkGraph",
|
||||||
kind: "tab",
|
kind: "tab",
|
||||||
after: "local-terminal",
|
after: "local-terminal",
|
||||||
|
permission: "use",
|
||||||
});
|
});
|
||||||
|
|
||||||
app.registerDashboardCard({
|
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 { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Request, Response, Router } from "express";
|
import { startServer, type TestServer } from "./helpers";
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
let server: TestServer;
|
||||||
currentUserId: "user-1" as string | undefined,
|
|
||||||
records: new Map<string, { userId: string; topology: string | null }>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
beforeEach(async () => {
|
||||||
|
server = await startServer();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /network-topology", () => {
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /", () => {
|
||||||
it("returns null when the user has no saved topology", async () => {
|
it("returns null when the user has no saved topology", async () => {
|
||||||
const res = await invoke("get", "/");
|
const res = await server.request("GET", "/");
|
||||||
expect(res.jsonBody).toBeNull();
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the parsed topology when one exists", async () => {
|
it("returns the saved topology", async () => {
|
||||||
state.records.set("user-1", {
|
await server.request("POST", "/", {
|
||||||
userId: "user-1",
|
body: { topology: { nodes: [{ data: { id: "host-1" } }], edges: [] } },
|
||||||
topology: JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await invoke("get", "/");
|
const res = await server.request("GET", "/");
|
||||||
expect(res.jsonBody).toEqual({ nodes: [{ id: "host-1" }], edges: [] });
|
expect(res.body).toEqual({
|
||||||
|
nodes: [{ data: { id: "host-1" } }],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unauthenticated request", async () => {
|
it("keeps each user's topology separate", async () => {
|
||||||
state.currentUserId = undefined;
|
await server.request("POST", "/", {
|
||||||
const res = await invoke("get", "/");
|
user: "user-1",
|
||||||
expect(res.statusCode).toBe(401);
|
body: { topology: { nodes: [{ data: { id: "a" } }], edges: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.request("GET", "/", { user: "user-2" });
|
||||||
|
expect(res.body).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /network-topology", () => {
|
describe("POST /", () => {
|
||||||
it("saves the topology and returns success", async () => {
|
it("saves and overwrites the topology", async () => {
|
||||||
const res = await invoke("post", "/", {
|
const first = await server.request("POST", "/", {
|
||||||
body: { topology: { nodes: [], edges: [] } },
|
body: { topology: { nodes: [], edges: [] } },
|
||||||
});
|
});
|
||||||
|
expect(first.body).toEqual({ success: true });
|
||||||
|
|
||||||
expect(res.jsonBody).toEqual({ success: true });
|
await server.request("POST", "/", {
|
||||||
expect(state.records.get("user-1")?.topology).toBe(
|
body: { topology: { nodes: [{ data: { id: "a" } }], edges: [] } },
|
||||||
JSON.stringify({ nodes: [], edges: [] }),
|
});
|
||||||
);
|
|
||||||
|
const res = await server.request("GET", "/");
|
||||||
|
expect(res.body).toEqual({ nodes: [{ data: { id: "a" } }], edges: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a request with no topology", async () => {
|
it("rejects a request with no topology", async () => {
|
||||||
const res = await invoke("post", "/", { body: {} });
|
const res = await server.request("POST", "/", { body: {} });
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an unauthenticated request", async () => {
|
describe("permissions", () => {
|
||||||
state.currentUserId = undefined;
|
it("refuses every route without network-topology.use", async () => {
|
||||||
const res = await invoke("post", "/", { body: { topology: {} } });
|
const denied = await startServer({ permissions: [] });
|
||||||
expect(res.statusCode).toBe(401);
|
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/cards/StoragePoolsCard.tsx",
|
||||||
"plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts",
|
"plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts",
|
||||||
"plugins/host-metrics/src/frontend/temperature-preference.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/HostProxmoxTab.tsx",
|
||||||
"plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",
|
"plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",
|
||||||
"plugins/proxmox/src/frontend/index.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/system-collector.ts",
|
||||||
"plugins/host-metrics/src/backend/widgets/temperature-collector.ts",
|
"plugins/host-metrics/src/backend/widgets/temperature-collector.ts",
|
||||||
"plugins/host-metrics/src/backend/widgets/uptime-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/proxmox/src/backend/routes.ts",
|
||||||
"plugins/remote-desktop/src/backend/guacamole-server.ts",
|
"plugins/remote-desktop/src/backend/guacamole-server.ts",
|
||||||
"plugins/remote-desktop/src/backend/routes.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 {
|
try {
|
||||||
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
||||||
} catch {
|
} 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(
|
export const hostAccess = mysqlTable(
|
||||||
"host_access",
|
"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(
|
export const hostAccess = pgTable(
|
||||||
"host_access",
|
"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(
|
export const hostAccess = sqliteTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { HostResolutionRepository } from "./host-resolution-repository.js";
|
|||||||
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
|
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
|
||||||
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
||||||
import { UiPreferenceRepository } from "./ui-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 { OpenTabRepository } from "./open-tab-repository.js";
|
||||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||||
import { PluginRepository } from "./plugin-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 {
|
export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||||
return new OpenTabRepository(
|
return new OpenTabRepository(
|
||||||
createCurrentRepositoryContext(),
|
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,
|
createCurrentHostSidebarPreferenceRepository,
|
||||||
createCurrentCredentialSidebarPreferenceRepository,
|
createCurrentCredentialSidebarPreferenceRepository,
|
||||||
createCurrentUiPreferenceRepository,
|
createCurrentUiPreferenceRepository,
|
||||||
createCurrentNetworkTopologyRepository,
|
|
||||||
createCurrentOpksshTokenRepository,
|
createCurrentOpksshTokenRepository,
|
||||||
createCurrentOpenTabRepository,
|
createCurrentOpenTabRepository,
|
||||||
createCurrentRecentActivityRepository,
|
createCurrentRecentActivityRepository,
|
||||||
@@ -110,7 +109,6 @@ export async function deleteUserAndRelatedData(
|
|||||||
await createCurrentHostRepository().deleteByUserId(userId);
|
await createCurrentHostRepository().deleteByUserId(userId);
|
||||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||||
|
|
||||||
await createCurrentNetworkTopologyRepository().deleteByUserId(userId);
|
|
||||||
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
|
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
|
||||||
await createCurrentHomepageItemRepository().deleteByUserId(userId);
|
await createCurrentHomepageItemRepository().deleteByUserId(userId);
|
||||||
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
|
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
|
* 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
|
* 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
|
* 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
|
* saying they did. Now there is one registration per entity and the rest is
|
||||||
* of them. Now there is one registration per entity and the rest is derived.
|
* derived.
|
||||||
*
|
*
|
||||||
* The order values reproduce the dependency order the Electron array encoded:
|
* The order values reproduce the dependency order the Electron array encoded:
|
||||||
* anything referenced by another entity syncs first, and networkTopology
|
* anything referenced by another entity syncs first.
|
||||||
* syncs last because it holds host ids inside a JSON blob.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -22,14 +21,12 @@ import {
|
|||||||
dashboardServiceLinks,
|
dashboardServiceLinks,
|
||||||
homepageItems,
|
homepageItems,
|
||||||
userPreferences,
|
userPreferences,
|
||||||
networkTopology,
|
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import {
|
import {
|
||||||
CORE_OWNER,
|
CORE_OWNER,
|
||||||
registerEntity,
|
registerEntity,
|
||||||
listEntities,
|
listEntities,
|
||||||
} from "../../plugins/sync-registry.js";
|
} from "../../plugins/sync-registry.js";
|
||||||
import { mapTopologyHostIds } from "./sync-references.js";
|
|
||||||
|
|
||||||
const CREDENTIAL_REFERENCE = {
|
const CREDENTIAL_REFERENCE = {
|
||||||
field: "credentialId",
|
field: "credentialId",
|
||||||
@@ -140,36 +137,6 @@ export function registerCoreSyncEntities(): void {
|
|||||||
singleton: true,
|
singleton: true,
|
||||||
readOnlyFields: ["storageMode"],
|
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. */
|
/** 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;
|
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 {
|
interface SyncReference {
|
||||||
field: string;
|
field: string;
|
||||||
syncField: string;
|
syncField: string;
|
||||||
|
|||||||
@@ -96,16 +96,20 @@ describe("core sync entities", () => {
|
|||||||
registerCoreSyncEntities();
|
registerCoreSyncEntities();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers every entity the Electron client knows about", () => {
|
it("registers every core-owned entity the Electron client knows about", () => {
|
||||||
// Nothing asserted this before, which is how networkTopology came to be
|
// The frozen array also lists networkTopology, which is now registered
|
||||||
// missing from the list in sync.test.ts.
|
// by the network-topology plugin rather than core.
|
||||||
expect([...listEntityTypes()].sort()).toEqual(
|
const coreTypes = new Set(listEntityTypes());
|
||||||
[...SYNCED_ENTITY_TYPES].sort(),
|
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", () => {
|
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", () => {
|
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()
|
const singletons = listEntities()
|
||||||
.filter((entity) => entity.singleton)
|
.filter((entity) => entity.singleton)
|
||||||
.map((entity) => entity.type);
|
.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", () => {
|
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", () => {
|
it("is idempotent, because several modules prime it", () => {
|
||||||
const before = listEntityTypes();
|
const before = listEntityTypes();
|
||||||
registerCoreSyncEntities();
|
registerCoreSyncEntities();
|
||||||
|
|||||||
@@ -1,36 +1,5 @@
|
|||||||
import { authApi, handleApiError } from "@/main-axios";
|
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
|
* 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.
|
* 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>> {
|
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/snippets/folders");
|
const response = await authApi.get("/snippets/folders");
|
||||||
|
|||||||
@@ -2151,19 +2151,12 @@ export {
|
|||||||
// SNIPPETS API
|
// SNIPPETS API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type {
|
|
||||||
NetworkTopologyNode,
|
|
||||||
NetworkTopologyEdge,
|
|
||||||
NetworkTopologyData,
|
|
||||||
} from "@/api/snippets-api";
|
|
||||||
export {
|
export {
|
||||||
getSnippets,
|
getSnippets,
|
||||||
createSnippet,
|
createSnippet,
|
||||||
updateSnippet,
|
updateSnippet,
|
||||||
deleteSnippet,
|
deleteSnippet,
|
||||||
executeSnippet,
|
executeSnippet,
|
||||||
getNetworkTopology,
|
|
||||||
saveNetworkTopology,
|
|
||||||
getSnippetFolders,
|
getSnippetFolders,
|
||||||
createSnippetFolder,
|
createSnippetFolder,
|
||||||
updateSnippetFolderMetadata,
|
updateSnippetFolderMetadata,
|
||||||
|
|||||||
Reference in new issue
Block a user