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

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

No files matched your search

-24
View File
@@ -1745,30 +1745,6 @@ const migrateSchema = () => {
}
}
try {
sqlite
.prepare("SELECT id FROM network_topology LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS network_topology (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
topology TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create network_topology table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
-14
View File
@@ -718,20 +718,6 @@ export const commandHistory = mysqlTable(
],
);
export const networkTopology = mysqlTable("network_topology", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const hostAccess = mysqlTable(
"host_access",
{
-14
View File
@@ -719,20 +719,6 @@ export const commandHistory = pgTable(
],
);
export const networkTopology = pgTable("network_topology", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = pgTable(
"host_access",
{
-14
View File
@@ -711,20 +711,6 @@ export const commandHistory = sqliteTable(
],
);
export const networkTopology = sqliteTable("network_topology", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topology: text("topology"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = sqliteTable(
"host_access",
{
@@ -29,7 +29,6 @@ import { HostResolutionRepository } from "./host-resolution-repository.js";
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { NetworkTopologyRepository } from "./network-topology-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
@@ -352,13 +351,6 @@ export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
);
}
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
return new NetworkTopologyRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("network_topology_repository_write"),
);
}
export function createCurrentOpenTabRepository(): OpenTabRepository {
return new OpenTabRepository(
createCurrentRepositoryContext(),
@@ -1,63 +0,0 @@
import { eq } from "drizzle-orm";
import { networkTopology } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
export class NetworkTopologyRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserId(userId: string): Promise<NetworkTopologyRecord | null> {
const rows = await this.context.drizzle
.select()
.from(networkTopology)
.where(eq(networkTopology.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async upsertForUser(
userId: string,
topology: string,
updatedAt = new Date().toISOString(),
): Promise<void> {
const existing = await this.findByUserId(userId);
if (existing) {
await this.context.drizzle
.update(networkTopology)
.set({ topology, updatedAt })
.where(eq(networkTopology.userId, userId));
await this.afterWrite();
return;
}
await this.context.drizzle.insert(networkTopology).values({
userId,
topology,
updatedAt,
});
await this.afterWrite();
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(networkTopology)
.where(eq(networkTopology.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -19,7 +19,6 @@ import {
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentNetworkTopologyRepository,
createCurrentOpksshTokenRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
@@ -110,7 +109,6 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
await createCurrentNetworkTopologyRepository().deleteByUserId(userId);
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
await createCurrentHomepageItemRepository().deleteByUserId(userId);
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
+3 -36
View File
@@ -4,12 +4,11 @@
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
* the tombstone repository, a REFERENCES map in sync-references.ts and a
* frozen array in electron/. Four lists that had to agree and had no test
* saying they did - which is how networkTopology came to be missing from one
* of them. Now there is one registration per entity and the rest is derived.
* saying they did. Now there is one registration per entity and the rest is
* derived.
*
* The order values reproduce the dependency order the Electron array encoded:
* anything referenced by another entity syncs first, and networkTopology
* syncs last because it holds host ids inside a JSON blob.
* anything referenced by another entity syncs first.
*/
import {
@@ -22,14 +21,12 @@ import {
dashboardServiceLinks,
homepageItems,
userPreferences,
networkTopology,
} from "../db/schema.js";
import {
CORE_OWNER,
registerEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import { mapTopologyHostIds } from "./sync-references.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
@@ -140,36 +137,6 @@ export function registerCoreSyncEntities(): void {
singleton: true,
readOnlyFields: ["storageMode"],
});
registerEntity(CORE_OWNER, {
type: "networkTopology",
table: networkTopology,
order: 100,
singleton: true,
// Host ids live inside the topology JSON rather than in a column, so the
// generic reference machinery cannot reach them.
serialize: async (row, resolveSyncId) => ({
...row,
topology: await mapTopologyHostIds(
row.topology as string | null | undefined,
async (id) => {
const numericId = Number(id);
if (!Number.isInteger(numericId)) return null;
return resolveSyncId("hosts", numericId);
},
),
}),
deserialize: async (row, resolveId) => ({
...row,
topology: await mapTopologyHostIds(
row.topology as string | null | undefined,
async (syncId) => {
const id = await resolveId("hosts", syncId);
return id === null ? null : String(id);
},
),
}),
});
}
/** The tables a reference may point at, derived from the registrations. */
@@ -12,79 +12,6 @@ import { getEntity } from "../../plugins/sync-registry.js";
*/
export type SyncReferenceEntity = string;
interface TopologyNode {
data?: { id?: string; [key: string]: unknown };
[key: string]: unknown;
}
interface TopologyEdge {
data?: { source?: string; target?: string; [key: string]: unknown };
[key: string]: unknown;
}
interface Topology {
nodes?: TopologyNode[];
edges?: TopologyEdge[];
[key: string]: unknown;
}
/**
* Node/edge ids in a topology are either a host's local numeric `id` (as a
* string) or a client-generated `group-<timestamp>` id. Only the former is
* meaningful across sides, so translate just those through the mapper and
* leave group ids untouched. Nodes/edges that fail to resolve (host deleted,
* or not synced to this side yet) are dropped rather than left dangling.
*/
export async function mapTopologyHostIds(
topologyJson: string | null | undefined,
mapId: (id: string) => Promise<string | null>,
): Promise<string | null> {
if (!topologyJson) return topologyJson ?? null;
let topology: Topology;
try {
topology = JSON.parse(topologyJson);
} catch {
return topologyJson;
}
const isGroupId = (id: string) => id.startsWith("group-");
const idMap = new Map<string, string | null>();
const resolve = async (id: string): Promise<string | null> => {
if (isGroupId(id)) return id;
if (!idMap.has(id)) idMap.set(id, await mapId(id));
return idMap.get(id) ?? null;
};
const nodes: TopologyNode[] = [];
for (const node of topology.nodes ?? []) {
const id = node.data?.id;
if (typeof id !== "string") continue;
const mapped = await resolve(id);
if (mapped === null) continue;
nodes.push({ ...node, data: { ...node.data, id: mapped } });
}
const nodeIds = new Set(nodes.map((n) => n.data?.id));
const edges: TopologyEdge[] = [];
for (const edge of topology.edges ?? []) {
const source = edge.data?.source;
const target = edge.data?.target;
if (typeof source !== "string" || typeof target !== "string") continue;
const mappedSource = await resolve(source);
const mappedTarget = await resolve(target);
if (mappedSource === null || mappedTarget === null) continue;
if (!nodeIds.has(mappedSource) || !nodeIds.has(mappedTarget)) continue;
edges.push({
...edge,
data: { ...edge.data, source: mappedSource, target: mappedTarget },
});
}
return JSON.stringify({ ...topology, nodes, edges });
}
interface SyncReference {
field: string;
syncField: string;
+13 -16
View File
@@ -96,16 +96,20 @@ describe("core sync entities", () => {
registerCoreSyncEntities();
});
it("registers every entity the Electron client knows about", () => {
// Nothing asserted this before, which is how networkTopology came to be
// missing from the list in sync.test.ts.
expect([...listEntityTypes()].sort()).toEqual(
[...SYNCED_ENTITY_TYPES].sort(),
);
it("registers every core-owned entity the Electron client knows about", () => {
// The frozen array also lists networkTopology, which is now registered
// by the network-topology plugin rather than core.
const coreTypes = new Set(listEntityTypes());
for (const type of SYNCED_ENTITY_TYPES) {
if (type === "networkTopology") continue;
expect(coreTypes.has(type), type).toBe(true);
}
});
it("keeps the dependency order the frozen Electron array encodes", () => {
expect(listEntityTypes()).toEqual([...SYNCED_ENTITY_TYPES]);
expect(listEntityTypes()).toEqual(
[...SYNCED_ENTITY_TYPES].filter((type) => type !== "networkTopology"),
);
});
it("owns all of them as core", () => {
@@ -131,12 +135,12 @@ describe("core sync entities", () => {
}
});
it("marks the two singletons and nothing else", () => {
it("marks the one singleton and nothing else", () => {
const singletons = listEntities()
.filter((entity) => entity.singleton)
.map((entity) => entity.type);
expect(singletons.sort()).toEqual(["networkTopology", "userPreferences"]);
expect(singletons.sort()).toEqual(["userPreferences"]);
});
it("keeps the read-only fields that must survive a sync payload", () => {
@@ -146,13 +150,6 @@ describe("core sync entities", () => {
]);
});
it("gives networkTopology the custom serializer its JSON blob needs", () => {
const topology = getEntity("networkTopology");
expect(topology?.serialize).toBeTypeOf("function");
expect(topology?.deserialize).toBeTypeOf("function");
});
it("is idempotent, because several modules prime it", () => {
const before = listEntityTypes();
registerCoreSyncEntities();
-53
View File
@@ -1,36 +1,5 @@
import { authApi, handleApiError } from "@/main-axios";
export interface NetworkTopologyNode {
data: {
id: string;
label?: string;
ip?: string;
status?: string;
tags?: string[];
parent?: string;
color?: string;
/** Absent on nodes; callers tell nodes from edges by testing these. */
source?: undefined;
target?: undefined;
};
position?: { x: number; y: number };
}
export interface NetworkTopologyEdge {
data: {
id?: string;
source: string;
target: string;
label?: undefined;
ip?: undefined;
};
}
export interface NetworkTopologyData {
nodes: NetworkTopologyNode[];
edges: NetworkTopologyEdge[];
}
/**
* A snippet row as the list endpoint returns it. Callers that need the full
* shape narrow it themselves; only the id is relied on across the app.
@@ -100,28 +69,6 @@ export async function executeSnippet(
}
}
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
try {
const response = await authApi.get("/plugin-api/network-topology/");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch network topology");
}
}
export async function saveNetworkTopology(
topology: NetworkTopologyData,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/plugin-api/network-topology/", {
topology,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save network topology");
}
}
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets/folders");
-7
View File
@@ -2151,19 +2151,12 @@ export {
// SNIPPETS API
// ============================================================================
export type {
NetworkTopologyNode,
NetworkTopologyEdge,
NetworkTopologyData,
} from "@/api/snippets-api";
export {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
executeSnippet,
getNetworkTopology,
saveNetworkTopology,
getSnippetFolders,
createSnippetFolder,
updateSnippetFolderMetadata,