mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
This commit is contained in:
1 parent
d0c25cf183
commit
0b7e616419
4 files changed
+111
-2
No files matched your search
@@ -10,6 +10,9 @@ const SYNCED_ENTITY_TYPES = Object.freeze([
|
|||||||
"dashboardServiceLinks",
|
"dashboardServiceLinks",
|
||||||
"homepageItems",
|
"homepageItems",
|
||||||
"userPreferences",
|
"userPreferences",
|
||||||
|
// networkTopology stores host references by their local numeric id inside
|
||||||
|
// the topology JSON, so it must sync after hosts have been reconciled.
|
||||||
|
"networkTopology",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
module.exports = { SYNCED_ENTITY_TYPES };
|
module.exports = { SYNCED_ENTITY_TYPES };
|
||||||
@@ -14,7 +14,8 @@ export type SyncEntityType =
|
|||||||
| "vaultProfiles"
|
| "vaultProfiles"
|
||||||
| "dashboardServiceLinks"
|
| "dashboardServiceLinks"
|
||||||
| "homepageItems"
|
| "homepageItems"
|
||||||
| "userPreferences";
|
| "userPreferences"
|
||||||
|
| "networkTopology";
|
||||||
|
|
||||||
export class SyncTombstoneRepository {
|
export class SyncTombstoneRepository {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -2,6 +2,79 @@ import type { SyncEntityType } from "../repositories/sync-tombstone-repository.j
|
|||||||
|
|
||||||
export type SyncReferenceEntity = "hosts" | "sshCredentials" | "vaultProfiles";
|
export type SyncReferenceEntity = "hosts" | "sshCredentials" | "vaultProfiles";
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
@@ -92,6 +165,19 @@ export async function serializeSyncReferences(
|
|||||||
) => Promise<string | null>,
|
) => Promise<string | null>,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const serialized = { ...row };
|
const serialized = { ...row };
|
||||||
|
|
||||||
|
if (entityType === "networkTopology") {
|
||||||
|
serialized.topology = await mapTopologyHostIds(
|
||||||
|
serialized.topology as string | null | undefined,
|
||||||
|
async (id) => {
|
||||||
|
const numericId = Number(id);
|
||||||
|
if (!Number.isInteger(numericId)) return null;
|
||||||
|
return resolveSyncId("hosts", numericId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
for (const reference of REFERENCES[entityType] ?? []) {
|
for (const reference of REFERENCES[entityType] ?? []) {
|
||||||
const id = serialized[reference.field];
|
const id = serialized[reference.field];
|
||||||
serialized[reference.syncField] =
|
serialized[reference.syncField] =
|
||||||
@@ -112,6 +198,18 @@ export async function deserializeSyncReferences(
|
|||||||
) => Promise<number | null>,
|
) => Promise<number | null>,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const deserialized = { ...row };
|
const deserialized = { ...row };
|
||||||
|
|
||||||
|
if (entityType === "networkTopology") {
|
||||||
|
deserialized.topology = await mapTopologyHostIds(
|
||||||
|
deserialized.topology as string | null | undefined,
|
||||||
|
async (syncId) => {
|
||||||
|
const id = await resolveId("hosts", syncId);
|
||||||
|
return id === null ? null : String(id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return deserialized;
|
||||||
|
}
|
||||||
|
|
||||||
for (const reference of REFERENCES[entityType] ?? []) {
|
for (const reference of REFERENCES[entityType] ?? []) {
|
||||||
const syncId = deserialized[reference.syncField];
|
const syncId = deserialized[reference.syncField];
|
||||||
delete deserialized[reference.syncField];
|
delete deserialized[reference.syncField];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
dashboardServiceLinks,
|
dashboardServiceLinks,
|
||||||
homepageItems,
|
homepageItems,
|
||||||
userPreferences,
|
userPreferences,
|
||||||
|
networkTopology,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
@@ -52,7 +53,8 @@ interface EntityConfig {
|
|||||||
| typeof vaultProfiles
|
| typeof vaultProfiles
|
||||||
| typeof dashboardServiceLinks
|
| typeof dashboardServiceLinks
|
||||||
| typeof homepageItems
|
| typeof homepageItems
|
||||||
| typeof userPreferences;
|
| typeof userPreferences
|
||||||
|
| typeof networkTopology;
|
||||||
// Fields that only make sense on the device that created the row, or
|
// Fields that only make sense on the device that created the row, or
|
||||||
// that are managed elsewhere and must never be overwritten by a sync
|
// that are managed elsewhere and must never be overwritten by a sync
|
||||||
// payload from the other side.
|
// payload from the other side.
|
||||||
@@ -77,6 +79,11 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
|
|||||||
readOnlyFields: ["storageMode"],
|
readOnlyFields: ["storageMode"],
|
||||||
singleton: true,
|
singleton: true,
|
||||||
},
|
},
|
||||||
|
networkTopology: {
|
||||||
|
table: networkTopology,
|
||||||
|
readOnlyFields: [],
|
||||||
|
singleton: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
||||||
|
|||||||
Reference in new issue
Block a user