mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: convert network-topology to a full plugin (B1)
This commit is contained in:
1 parent
95abe24798
commit
fdedca39fd
51 files changed
+30321
-814
No files matched your search
@@ -1745,30 +1745,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM network_topology LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS network_topology (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
topology TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create network_topology table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
|
||||
@@ -718,20 +718,6 @@ export const commandHistory = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const networkTopology = mysqlTable("network_topology", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
topology: text("topology"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const hostAccess = mysqlTable(
|
||||
"host_access",
|
||||
{
|
||||
|
||||
@@ -719,20 +719,6 @@ export const commandHistory = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const networkTopology = pgTable("network_topology", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
topology: text("topology"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostAccess = pgTable(
|
||||
"host_access",
|
||||
{
|
||||
|
||||
@@ -711,20 +711,6 @@ export const commandHistory = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const networkTopology = sqliteTable("network_topology", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
topology: text("topology"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostAccess = sqliteTable(
|
||||
"host_access",
|
||||
{
|
||||
|
||||
@@ -29,7 +29,6 @@ import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
|
||||
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
||||
import { UiPreferenceRepository } from "./ui-preference-repository.js";
|
||||
import { NetworkTopologyRepository } from "./network-topology-repository.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
@@ -352,13 +351,6 @@ export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
|
||||
return new NetworkTopologyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("network_topology_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
return new OpenTabRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { networkTopology } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
|
||||
|
||||
export class NetworkTopologyRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<NetworkTopologyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
topology: string,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(networkTopology)
|
||||
.set({ topology, updatedAt })
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(networkTopology).values({
|
||||
userId,
|
||||
topology,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
createCurrentCredentialSidebarPreferenceRepository,
|
||||
createCurrentUiPreferenceRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
@@ -110,7 +109,6 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentNetworkTopologyRepository().deleteByUserId(userId);
|
||||
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
|
||||
await createCurrentHomepageItemRepository().deleteByUserId(userId);
|
||||
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
|
||||
* the tombstone repository, a REFERENCES map in sync-references.ts and a
|
||||
* frozen array in electron/. Four lists that had to agree and had no test
|
||||
* saying they did - which is how networkTopology came to be missing from one
|
||||
* of them. Now there is one registration per entity and the rest is derived.
|
||||
* saying they did. Now there is one registration per entity and the rest is
|
||||
* derived.
|
||||
*
|
||||
* The order values reproduce the dependency order the Electron array encoded:
|
||||
* anything referenced by another entity syncs first, and networkTopology
|
||||
* syncs last because it holds host ids inside a JSON blob.
|
||||
* anything referenced by another entity syncs first.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -22,14 +21,12 @@ import {
|
||||
dashboardServiceLinks,
|
||||
homepageItems,
|
||||
userPreferences,
|
||||
networkTopology,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
CORE_OWNER,
|
||||
registerEntity,
|
||||
listEntities,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { mapTopologyHostIds } from "./sync-references.js";
|
||||
|
||||
const CREDENTIAL_REFERENCE = {
|
||||
field: "credentialId",
|
||||
@@ -140,36 +137,6 @@ export function registerCoreSyncEntities(): void {
|
||||
singleton: true,
|
||||
readOnlyFields: ["storageMode"],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "networkTopology",
|
||||
table: networkTopology,
|
||||
order: 100,
|
||||
singleton: true,
|
||||
// Host ids live inside the topology JSON rather than in a column, so the
|
||||
// generic reference machinery cannot reach them.
|
||||
serialize: async (row, resolveSyncId) => ({
|
||||
...row,
|
||||
topology: await mapTopologyHostIds(
|
||||
row.topology as string | null | undefined,
|
||||
async (id) => {
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId)) return null;
|
||||
return resolveSyncId("hosts", numericId);
|
||||
},
|
||||
),
|
||||
}),
|
||||
deserialize: async (row, resolveId) => ({
|
||||
...row,
|
||||
topology: await mapTopologyHostIds(
|
||||
row.topology as string | null | undefined,
|
||||
async (syncId) => {
|
||||
const id = await resolveId("hosts", syncId);
|
||||
return id === null ? null : String(id);
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The tables a reference may point at, derived from the registrations. */
|
||||
|
||||
@@ -12,79 +12,6 @@ import { getEntity } from "../../plugins/sync-registry.js";
|
||||
*/
|
||||
export type SyncReferenceEntity = string;
|
||||
|
||||
interface TopologyNode {
|
||||
data?: { id?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TopologyEdge {
|
||||
data?: { source?: string; target?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Topology {
|
||||
nodes?: TopologyNode[];
|
||||
edges?: TopologyEdge[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node/edge ids in a topology are either a host's local numeric `id` (as a
|
||||
* string) or a client-generated `group-<timestamp>` id. Only the former is
|
||||
* meaningful across sides, so translate just those through the mapper and
|
||||
* leave group ids untouched. Nodes/edges that fail to resolve (host deleted,
|
||||
* or not synced to this side yet) are dropped rather than left dangling.
|
||||
*/
|
||||
export async function mapTopologyHostIds(
|
||||
topologyJson: string | null | undefined,
|
||||
mapId: (id: string) => Promise<string | null>,
|
||||
): Promise<string | null> {
|
||||
if (!topologyJson) return topologyJson ?? null;
|
||||
|
||||
let topology: Topology;
|
||||
try {
|
||||
topology = JSON.parse(topologyJson);
|
||||
} catch {
|
||||
return topologyJson;
|
||||
}
|
||||
|
||||
const isGroupId = (id: string) => id.startsWith("group-");
|
||||
const idMap = new Map<string, string | null>();
|
||||
|
||||
const resolve = async (id: string): Promise<string | null> => {
|
||||
if (isGroupId(id)) return id;
|
||||
if (!idMap.has(id)) idMap.set(id, await mapId(id));
|
||||
return idMap.get(id) ?? null;
|
||||
};
|
||||
|
||||
const nodes: TopologyNode[] = [];
|
||||
for (const node of topology.nodes ?? []) {
|
||||
const id = node.data?.id;
|
||||
if (typeof id !== "string") continue;
|
||||
const mapped = await resolve(id);
|
||||
if (mapped === null) continue;
|
||||
nodes.push({ ...node, data: { ...node.data, id: mapped } });
|
||||
}
|
||||
|
||||
const nodeIds = new Set(nodes.map((n) => n.data?.id));
|
||||
const edges: TopologyEdge[] = [];
|
||||
for (const edge of topology.edges ?? []) {
|
||||
const source = edge.data?.source;
|
||||
const target = edge.data?.target;
|
||||
if (typeof source !== "string" || typeof target !== "string") continue;
|
||||
const mappedSource = await resolve(source);
|
||||
const mappedTarget = await resolve(target);
|
||||
if (mappedSource === null || mappedTarget === null) continue;
|
||||
if (!nodeIds.has(mappedSource) || !nodeIds.has(mappedTarget)) continue;
|
||||
edges.push({
|
||||
...edge,
|
||||
data: { ...edge.data, source: mappedSource, target: mappedTarget },
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...topology, nodes, edges });
|
||||
}
|
||||
|
||||
interface SyncReference {
|
||||
field: string;
|
||||
syncField: string;
|
||||
|
||||
Reference in new issue
Block a user