diff --git a/apps/api/migrations/2026-07-10-vm-host-link-groups.sql b/apps/api/migrations/2026-07-10-vm-host-link-groups.sql new file mode 100644 index 0000000000..6c6616959f --- /dev/null +++ b/apps/api/migrations/2026-07-10-vm-host-link-groups.sql @@ -0,0 +1,27 @@ +-- 2026-07-10: vm_host link groups (#2308) — member-role column on devices. +-- +-- Extends the #2138 device_link_groups mechanism with an asymmetric kind: +-- 'vm_host' groups one host server record with its guest-VM device records so +-- the guests nest under the host in the device list. Unlike multiboot (peer +-- boot profiles, only one online at a time), host + guests are concurrently +-- online — the grouping is hierarchical organization, not offline-noise +-- suppression, and guests stay fully managed endpoints. +-- +-- The 2026-07-09 migration reserved exactly this design: kind='vm_host' on +-- device_link_groups (varchar, no CHECK — app-enforced, same as 'multiboot') +-- plus a member-role column on devices. This adds that column: +-- link_group_role: NULL for unlinked devices and for members of symmetric +-- kinds (multiboot); 'host' | 'guest' for members of a vm_host group +-- (app-enforced values, matching the kind convention). Invariant: +-- link_group_id IS NULL => link_group_role IS NULL — every unlink path +-- (PATCH remove, group delete/dissolve, move-org) clears both together. +-- +-- Tenancy: no RLS changes. `devices` already carries the standard org policies +-- and the composite FK devices(link_group_id, org_id) -> +-- device_link_groups(id, org_id) from 2026-07-09 keeps enforcing the same-org +-- invariant; a role tag on the membership column changes nothing about scope. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS. autoMigrate wraps the file in one +-- transaction — no inner BEGIN/COMMIT. + +ALTER TABLE devices ADD COLUMN IF NOT EXISTS link_group_role varchar(16); diff --git a/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts b/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts index 0803715f08..99acdcfeb1 100644 --- a/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts +++ b/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts @@ -233,6 +233,65 @@ describe('device link-group dissolution', () => { expect(survivor[0]?.linkGroupId).toBeNull(); }); + runDb('vm_host group survives guest loss but dissolves headless when the host unlinks (#2308)', async () => { + const { orgA, deviceA1, deviceA2 } = await seed(); + + // deviceA1 = host, deviceA2 = guest. + const groupId = await withSystemDbAccessContext(async () => { + const [group] = await db + .insert(deviceLinkGroups) + .values({ orgId: orgA.id, kind: 'vm_host', name: 'hv-01' }) + .returning({ id: deviceLinkGroups.id }); + await db + .update(devices) + .set({ linkGroupId: group!.id, linkGroupRole: 'host' }) + .where(eq(devices.id, deviceA1.id)); + await db + .update(devices) + .set({ linkGroupId: group!.id, linkGroupRole: 'guest' }) + .where(eq(devices.id, deviceA2.id)); + return group!.id; + }); + + // Host + guest present: no dissolution. + const keptOpen = await withSystemDbAccessContext(() => dissolveLinkGroupIfBelowMinimum(db, groupId)); + expect(keptOpen).toBe(false); + + // Unlink the HOST (as a PATCH remove / move-org would): the guest remains + // but the group is headless — the dissolve check must remove it and clear + // the guest's membership AND role. + await withSystemDbAccessContext(async () => { + await db + .update(devices) + .set({ linkGroupId: null, linkGroupRole: null }) + .where(eq(devices.id, deviceA1.id)); + // Re-seed a second guest so the member count stays >= 2 and ONLY the + // headless rule (not the below-minimum rule) can dissolve the group. + const extra = await seedDevice(orgA.id, (await db.select({ siteId: devices.siteId }).from(devices).where(eq(devices.id, deviceA2.id)))[0]!.siteId!, `extra-${Date.now()}`); + await db + .update(devices) + .set({ linkGroupId: groupId, linkGroupRole: 'guest' }) + .where(eq(devices.id, extra.id)); + }); + + const dissolved = await withSystemDbAccessContext(() => dissolveLinkGroupIfBelowMinimum(db, groupId)); + expect(dissolved).toBe(true); + + const remaining = await withSystemDbAccessContext(() => + db.select({ id: deviceLinkGroups.id }).from(deviceLinkGroups).where(eq(deviceLinkGroups.id, groupId)), + ); + expect(remaining).toHaveLength(0); + + const exGuest = await withSystemDbAccessContext(() => + db + .select({ linkGroupId: devices.linkGroupId, linkGroupRole: devices.linkGroupRole }) + .from(devices) + .where(eq(devices.id, deviceA2.id)), + ); + expect(exGuest[0]?.linkGroupId).toBeNull(); + expect(exGuest[0]?.linkGroupRole).toBeNull(); + }); + runDb('deleteLinkGroup unlinks every member and removes the group row', async () => { const { orgA, deviceA1, deviceA2 } = await seed(); diff --git a/apps/api/src/db/schema/devices.ts b/apps/api/src/db/schema/devices.ts index f5b3168b3c..77c99ae2e4 100644 --- a/apps/api/src/db/schema/devices.ts +++ b/apps/api/src/db/schema/devices.ts @@ -76,6 +76,14 @@ export const devices = pgTable('devices', { // matching the users(org_id, partner_id) composite-FK convention — pins every // member of a group to the group's org (same-org invariant). linkGroupId: uuid('link_group_id'), + // Member role within an ASYMMETRIC link group (#2308). NULL for unlinked + // devices and for members of symmetric kinds (multiboot — all peers). For a + // kind='vm_host' group exactly one member is 'host' (the hypervisor/server + // record) and the rest are 'guest' (its VMs). Values are app-enforced + // ('host' | 'guest'), matching kind's varchar-without-CHECK convention. + // Invariant: link_group_id IS NULL => link_group_role IS NULL (every unlink + // path clears both together). + linkGroupRole: varchar('link_group_role', { length: 16 }), tags: text('tags').array().default([]), customFields: jsonb('custom_fields').default({}), managementPosture: jsonb('management_posture'), @@ -129,10 +137,10 @@ export const devices = pgTable('devices', { export const deviceLinkGroups = pgTable('device_link_groups', { id: uuid('id').primaryKey().defaultRandom(), orgId: uuid('org_id').notNull().references(() => organizations.id), - // What the link MEANS. 'multiboot' (v1): members are peer boot profiles of - // one physical machine. Reserved future value: 'vm_host' (VM guests nested - // under their host server) — schema accommodation only; a future asymmetric - // kind adds a member-role column on devices (multiboot members are peers). + // What the link MEANS. 'multiboot' (v1, #2138): members are peer boot + // profiles of one physical machine. 'vm_host' (#2308): asymmetric — one + // member is the host server (devices.link_group_role = 'host') and the rest + // are its guest VMs ('guest'), nested under the host in the device list. kind: varchar('kind', { length: 32 }).notNull().default('multiboot'), name: varchar('name', { length: 255 }), createdBy: uuid('created_by').references(() => users.id), diff --git a/apps/api/src/routes/devices/core.list-response-shape.test.ts b/apps/api/src/routes/devices/core.list-response-shape.test.ts index 6a73d5445d..5b65113d19 100644 --- a/apps/api/src/routes/devices/core.list-response-shape.test.ts +++ b/apps/api/src/routes/devices/core.list-response-shape.test.ts @@ -15,6 +15,12 @@ import { Hono } from 'hono'; // failure mode — `pendingReboot` is selected from the DB in core.ts but was // omitted by the same response mapper, so the list/grid badge never rendered // (the device-detail page worked because it returns the full row). +// +// Extended for #2138/#2308: the device-list link-group scalars +// (`linkGroupId`, `linkGroupRole`) drive the client-side multiboot grouping +// and vm_host guest nesting. If the mapper drops either, every linked device +// silently renders ungrouped while all other tests stay green — the exact +// dropped-field failure mode this file exists to catch. vi.mock('../../db', () => ({ runOutsideDbContext: vi.fn((fn) => fn()), @@ -140,6 +146,8 @@ describe('GET /devices — response shape', () => { lastUser: null, uptimeSeconds: null, isHeadless: false, + linkGroupId: '44444444-4444-4444-8444-444444444444', + linkGroupRole: 'host', createdAt: new Date('2026-05-26T19:39:57.519Z'), updatedAt: new Date('2026-05-26T19:41:26.390Z'), cpuModel: null, @@ -170,6 +178,11 @@ describe('GET /devices — response shape', () => { // #1720 — reliability score + trend surfaced for the list column. expect(row).toHaveProperty('reliabilityScore', 42); expect(row).toHaveProperty('reliabilityTrend', 'degrading'); + // #2138/#2308 — link-group scalars must survive the mapper: the web list + // groups multiboot rows by linkGroupId and nests vm_host guests by + // linkGroupRole; dropping either silently un-groups every linked device. + expect(row).toHaveProperty('linkGroupId', '44444444-4444-4444-8444-444444444444'); + expect(row).toHaveProperty('linkGroupRole', 'host'); }); it('returns null watchdogStatus / mainAgentSilentSince for healthy rows (still present in shape)', async () => { @@ -201,6 +214,8 @@ describe('GET /devices — response shape', () => { lastUser: null, uptimeSeconds: null, isHeadless: false, + linkGroupId: null, + linkGroupRole: null, createdAt: new Date(), updatedAt: new Date(), cpuModel: null, @@ -239,5 +254,11 @@ describe('GET /devices — response shape', () => { expect(row.mainAgentSilentSince).toBeNull(); expect(row.watchdogVersion).toBeNull(); expect(row.pendingReboot).toBe(false); + + // #2138/#2308 — link-group keys present (null) even for unlinked devices. + expect(Object.prototype.hasOwnProperty.call(row, 'linkGroupId')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(row, 'linkGroupRole')).toBe(true); + expect(row.linkGroupId).toBeNull(); + expect(row.linkGroupRole).toBeNull(); }); }); diff --git a/apps/api/src/routes/devices/core.ts b/apps/api/src/routes/devices/core.ts index ae6074e8da..fe35bc9c4e 100644 --- a/apps/api/src/routes/devices/core.ts +++ b/apps/api/src/routes/devices/core.ts @@ -568,6 +568,10 @@ coreRoutes.get( // Linked multi-boot profiles (#2138): null => unlinked. The web list // groups rows client-side by this id (inactive strips / group bar). linkGroupId: devices.linkGroupId, + // vm_host member role (#2308): 'host' | 'guest' | null. Lets the web + // list nest guest rows under their host without joining the group + // table — a non-null role implies the group's kind is 'vm_host'. + linkGroupRole: devices.linkGroupRole, createdAt: devices.createdAt, updatedAt: devices.updatedAt, // Hardware summary @@ -688,6 +692,7 @@ coreRoutes.get( batteryStatus: d.batteryStatus ?? null, activeVpns: d.activeVpns ?? null, linkGroupId: d.linkGroupId ?? null, + linkGroupRole: d.linkGroupRole ?? null, createdAt: d.createdAt, updatedAt: d.updatedAt, cpuPercent: latestMetrics?.cpuPercent ?? 0, @@ -1356,6 +1361,12 @@ coreRoutes.delete( } } + // #2138/#2308 — whether deleting this device dissolved its link group + // (lone multiboot survivor unlinked, or a vm_host group left headless and + // its guests unlinked). Recorded in the audit details: an unexplained + // "why did this whole VM group un-group?" must be traceable to this event. + let linkGroupDissolved = false; + // Cascade: remove all FK-referencing records in a transaction. // Uses raw SQL to cover all child tables without importing each schema. // When adding new tables with device_id FK, add them here too. @@ -1388,10 +1399,11 @@ coreRoutes.delete( await tx.delete(devices).where(eq(devices.id, deviceId)); // #2138 — the deleted device's link_group_id went with its row. If it - // was a boot profile and the group now has a single lone survivor, - // dissolve the (meaningless) group. + // was a boot profile and the group now has a single lone survivor — + // or it was a vm_host group's HOST (#2308), leaving the group + // headless — dissolve the group. if (device.linkGroupId) { - await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId); + linkGroupDissolved = await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId); } }); } catch (err: unknown) { @@ -1413,7 +1425,16 @@ coreRoutes.delete( resourceType: 'device', resourceId: deviceId, resourceName: device.hostname ?? device.displayName ?? deviceId, - details: { uninstallCommandSent: uninstallSent } + details: { + uninstallCommandSent: uninstallSent, + // #2138/#2308 — deleting a linked device can dissolve its link group + // (and unlink every remaining member). Without this flag the audit + // trail would show only "device deleted" while sibling devices + // silently lost their grouping. + ...(device.linkGroupId + ? { linkGroupId: device.linkGroupId, linkGroupDissolved } + : {}), + } }); return c.json({ diff --git a/apps/api/src/routes/devices/links.test.ts b/apps/api/src/routes/devices/links.test.ts index 99b5801877..6a8f137858 100644 --- a/apps/api/src/routes/devices/links.test.ts +++ b/apps/api/src/routes/devices/links.test.ts @@ -261,6 +261,219 @@ describe('device link-group routes (create success + PATCH branches)', () => { }); }); +describe('vm_host link groups (#2308)', () => { + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + authState.canAccessSite = () => true; + app = new Hono(); + app.route('/devices', linksRoutes); + }); + + /** + * tx.update chain that records every .set() payload and resolves the rows + * `rowsFor` chooses for that payload. The where() result is both awaitable + * (remove path has no .returning) and .returning-capable (claim paths). + */ + function txUpdateChain(sets: unknown[], rowsFor: (s: Record) => unknown[]) { + return () => ({ + set: (s: Record) => { + sets.push(s); + return { + where: () => ({ + returning: () => Promise.resolve(rowsFor(s)), + then: (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(undefined).then(res, rej), + }), + }; + }, + }); + } + + it('400s a vm_host create without hostDeviceId', async () => { + const res = await app.request( + jsonReq('/devices/link-groups', 'POST', { kind: 'vm_host', deviceIds: [UUID_A, UUID_B] }), + ); + expect(res.status).toBe(400); + }); + + it('400s a vm_host create whose hostDeviceId is not one of deviceIds', async () => { + const res = await app.request( + jsonReq('/devices/link-groups', 'POST', { + kind: 'vm_host', + hostDeviceId: '33333333-3333-3333-3333-333333333333', + deviceIds: [UUID_A, UUID_B], + }), + ); + expect(res.status).toBe(400); + }); + + it('400s a multiboot create that supplies hostDeviceId (peers have no host)', async () => { + const res = await app.request( + jsonReq('/devices/link-groups', 'POST', { hostDeviceId: UUID_A, deviceIds: [UUID_A, UUID_B] }), + ); + expect(res.status).toBe(400); + }); + + it('creates a vm_host group: kind persisted, host claimed as host, others as guests', async () => { + vi.mocked(getDeviceWithOrgAndSiteCheck) + .mockResolvedValueOnce(mockDevice({ id: UUID_A }) as any) + .mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any); + + let insertValues: Record | undefined; + const updateSets: Record[] = []; + dbTransactionMock.mockImplementation((async (cb: any) => + cb({ + insert: () => ({ + values: (v: Record) => { + insertValues = v; + return { returning: () => Promise.resolve([{ id: 'grp-vm' }]) }; + }, + }), + update: txUpdateChain(updateSets as unknown[], (s) => + s.linkGroupRole === 'host' ? [{ id: UUID_A }] : [{ id: UUID_B }], + ), + })) as any); + // loadMembers for the response body. + dbSelectMock.mockReturnValueOnce( + selectChain([ + { deviceId: UUID_A, linkGroupId: 'grp-vm', siteId: 's', hostname: 'hv-01', displayName: null, osType: 'windows', osVersion: '2022', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'host' }, + { deviceId: UUID_B, linkGroupId: 'grp-vm', siteId: 's', hostname: 'vm-01', displayName: null, osType: 'linux', osVersion: '22', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'guest' }, + ]) as any, + ); + + const res = await app.request( + jsonReq('/devices/link-groups', 'POST', { + kind: 'vm_host', + hostDeviceId: UUID_A, + deviceIds: [UUID_A, UUID_B], + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { kind: string; members: Array<{ deviceId: string; role: string | null }> }; + expect(body.kind).toBe('vm_host'); + expect(insertValues?.kind).toBe('vm_host'); + // Host batch first (role 'host', exactly the host id), then the guests. + expect(updateSets).toHaveLength(2); + expect(updateSets[0]).toMatchObject({ linkGroupId: 'grp-vm', linkGroupRole: 'host' }); + expect(updateSets[1]).toMatchObject({ linkGroupId: 'grp-vm', linkGroupRole: 'guest' }); + // Roles surfaced on members so the UI can nest without a second fetch. + expect(body.members.find((m) => m.deviceId === UUID_A)?.role).toBe('host'); + expect(body.members.find((m) => m.deviceId === UUID_B)?.role).toBe('guest'); + // Audit trail records the kind and the host decision. + const { writeRouteAudit } = await import('../../services/auditEvents'); + expect(vi.mocked(writeRouteAudit)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: 'device_link_group.create', + details: expect.objectContaining({ kind: 'vm_host', hostDeviceId: UUID_A }), + }), + ); + }); + + it('creates a multiboot group with a single peer claim (role NULL)', async () => { + vi.mocked(getDeviceWithOrgAndSiteCheck) + .mockResolvedValueOnce(mockDevice({ id: UUID_A }) as any) + .mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any); + + const updateSets: Record[] = []; + dbTransactionMock.mockImplementation((async (cb: any) => + cb({ + insert: () => ({ values: () => ({ returning: () => Promise.resolve([{ id: 'grp-1' }]) }) }), + update: txUpdateChain(updateSets as unknown[], () => [{ id: UUID_A }, { id: UUID_B }]), + })) as any); + dbSelectMock.mockReturnValueOnce(selectChain([]) as any); + + const res = await app.request(jsonReq('/devices/link-groups', 'POST', { deviceIds: [UUID_A, UUID_B] })); + expect(res.status).toBe(201); + expect((await res.json() as { kind: string }).kind).toBe('multiboot'); + expect(updateSets).toHaveLength(1); + // Explicit NULL self-heals any stale role; peers never carry one. + expect(updateSets[0]).toMatchObject({ linkGroupRole: null }); + }); + + it('409s a vm_host create when the guest claim races (host batch alone is not enough)', async () => { + vi.mocked(getDeviceWithOrgAndSiteCheck) + .mockResolvedValueOnce(mockDevice({ id: UUID_A }) as any) + .mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any); + dbTransactionMock.mockImplementation((async (cb: any) => + cb({ + insert: () => ({ values: () => ({ returning: () => Promise.resolve([{ id: 'grp-vm' }]) }) }), + // Host batch claims its row; the guest batch loses UUID_B to a + // concurrent link (returns no rows) — the whole create must 409. + update: txUpdateChain([], (s) => (s.linkGroupRole === 'host' ? [{ id: UUID_A }] : [])), + })) as any); + + const res = await app.request( + jsonReq('/devices/link-groups', 'POST', { + kind: 'vm_host', + hostDeviceId: UUID_A, + deviceIds: [UUID_A, UUID_B], + }), + ); + expect(res.status).toBe(409); + }); + + it('PATCH add to a vm_host group links newcomers as guests and never rewrites existing member roles', async () => { + // getGroupWithOrgCheck → a vm_host group. + dbSelectMock.mockReturnValueOnce( + selectChain([{ id: 'grp-vm', orgId: 'org-123', kind: 'vm_host', name: null }]) as any, + ); + // The device being added is unlinked. + vi.mocked(getDeviceWithOrgAndSiteCheck).mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any); + // Current membership (size-ceiling check). + dbSelectMock.mockReturnValueOnce(selectChain([{ id: UUID_A }]) as any); + + const updateSets: Record[] = []; + dbTransactionMock.mockImplementation((async (cb: any) => + cb({ + update: txUpdateChain(updateSets as unknown[], (s) => + s.linkGroupRole === 'guest' ? [{ id: UUID_B }] : [], + ), + })) as any); + // loadMembers for the response body. + dbSelectMock.mockReturnValueOnce(selectChain([]) as any); + + const res = await app.request( + jsonReq('/devices/link-groups/grp-vm', 'PATCH', { addDeviceIds: [UUID_B] }), + ); + expect(res.status).toBe(200); + + // Sets in order: group updatedAt touch, re-add batch, newcomer claim. + const reAddSet = updateSets.find((s) => 'linkGroupId' in s && !('linkGroupRole' in s)); + const claimSet = updateSets.find((s) => s.linkGroupRole === 'guest'); + // The re-add batch must NOT touch linkGroupRole — overwriting would + // demote the group's host to guest and dissolve the group. + expect(reAddSet).toBeDefined(); + expect(claimSet).toMatchObject({ linkGroupId: 'grp-vm', linkGroupRole: 'guest' }); + }); + + it('PATCH remove clears the member role together with the membership', async () => { + dbSelectMock.mockReturnValueOnce( + selectChain([{ id: 'grp-vm', orgId: 'org-123', kind: 'vm_host', name: null }]) as any, + ); + vi.mocked(getDeviceWithOrgAndSiteCheck).mockResolvedValueOnce( + mockDevice({ id: UUID_B, linkGroupId: 'grp-vm' }) as any, + ); + dbSelectMock.mockReturnValueOnce(selectChain([{ id: UUID_A }, { id: UUID_B }]) as any); + + const updateSets: Record[] = []; + dbTransactionMock.mockImplementation((async (cb: any) => + cb({ + update: txUpdateChain(updateSets as unknown[], () => []), + })) as any); + dbSelectMock.mockReturnValueOnce(selectChain([]) as any); + + const res = await app.request( + jsonReq('/devices/link-groups/grp-vm', 'PATCH', { removeDeviceIds: [UUID_B] }), + ); + expect(res.status).toBe(200); + const unlinkSet = updateSets.find((s) => s.linkGroupId === null); + expect(unlinkSet).toMatchObject({ linkGroupId: null, linkGroupRole: null }); + }); +}); + describe('device link-group routes (site-scope member filtering)', () => { let app: Hono; diff --git a/apps/api/src/routes/devices/links.ts b/apps/api/src/routes/devices/links.ts index ff0a8cac2e..81ce058443 100644 --- a/apps/api/src/routes/devices/links.ts +++ b/apps/api/src/routes/devices/links.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { zValidator } from '../../lib/validation'; -import { and, eq, inArray, isNull, or } from 'drizzle-orm'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '../../db'; import { deviceLinkGroups, devices } from '../../db/schema'; import { authMiddleware, requireMfa, requirePermission, requireScope } from '../../middleware/auth'; @@ -52,7 +52,7 @@ class LinkRaceError extends Error { } } -/** Client-facing shape of one boot profile in a link group. */ +/** Client-facing shape of one member (boot profile / host / guest) in a link group. */ interface LinkGroupMember { deviceId: string; hostname: string; @@ -62,6 +62,8 @@ interface LinkGroupMember { agentVersion: string; status: string; lastSeenAt: Date | null; + /** 'host' | 'guest' within a vm_host group (#2308); null for multiboot peers. */ + role: string | null; } /** @@ -89,6 +91,7 @@ async function loadMembers( agentVersion: devices.agentVersion, status: devices.status, lastSeenAt: devices.lastSeenAt, + role: devices.linkGroupRole, }) .from(devices) .where(inArray(devices.linkGroupId, groupIds)); @@ -106,6 +109,7 @@ async function loadMembers( agentVersion: r.agentVersion, status: r.status, lastSeenAt: r.lastSeenAt, + role: r.role ?? null, }); byGroup.set(r.linkGroupId, list); } @@ -144,7 +148,9 @@ linksRoutes.get( }, ); -// POST /devices/link-groups — link 2+ devices as boot profiles of one machine. +// POST /devices/link-groups — link 2+ devices as boot profiles of one machine +// (kind='multiboot') or as one host server plus its guest VMs (kind='vm_host', +// #2308: hostDeviceId names the host; every other member becomes a guest). linksRoutes.post( '/link-groups', requireScope('organization', 'partner', 'system'), @@ -153,7 +159,7 @@ linksRoutes.post( zValidator('json', createLinkGroupSchema), async (c) => { const auth = c.get('auth'); - const { name, deviceIds } = c.req.valid('json'); + const { kind, name, deviceIds, hostDeviceId } = c.req.valid('json'); const uniqueIds = [...new Set(deviceIds)]; if (uniqueIds.length < 2) { @@ -190,7 +196,7 @@ linksRoutes.post( await db.transaction(async (tx) => { const [group] = await tx .insert(deviceLinkGroups) - .values({ orgId, name: name ?? null, createdBy: auth.user.id }) + .values({ orgId, kind, name: name ?? null, createdBy: auth.user.id }) .returning({ id: deviceLinkGroups.id }); groupId = group!.id; // Self-guarding claim: only devices STILL unlinked take the group id. @@ -198,13 +204,28 @@ linksRoutes.post( // link could have claimed a device in between (TOCTOU) — without the // guard the winner's group would silently lose the device. A row-count // mismatch aborts the whole link with a 409 instead. - const claimed = await tx - .update(devices) - .set({ linkGroupId: groupId, updatedAt: new Date() }) - .where(and(inArray(devices.id, uniqueIds), isNull(devices.linkGroupId))) - .returning({ id: devices.id }); - if (claimed.length !== uniqueIds.length) { - throw new LinkRaceError(); + // + // vm_host (#2308) claims in two batches purely because the role value + // differs per member (host vs guests); the guard semantics are the + // same — total claimed rows must equal the requested membership. + // Multiboot members are peers, so role stays NULL (set explicitly to + // self-heal any stale value, though unlink paths always clear it). + const batches: Array<{ ids: string[]; role: string | null }> = + kind === 'vm_host' + ? [ + { ids: [hostDeviceId!], role: 'host' }, + { ids: uniqueIds.filter((id) => id !== hostDeviceId), role: 'guest' }, + ] + : [{ ids: uniqueIds, role: null }]; + for (const batch of batches) { + const claimed = await tx + .update(devices) + .set({ linkGroupId: groupId, linkGroupRole: batch.role, updatedAt: new Date() }) + .where(and(inArray(devices.id, batch.ids), isNull(devices.linkGroupId))) + .returning({ id: devices.id }); + if (claimed.length !== batch.ids.length) { + throw new LinkRaceError(); + } } }); } catch (err) { @@ -220,14 +241,12 @@ linksRoutes.post( resourceType: 'device_link_group', resourceId: groupId!, resourceName: name ?? null, - details: { deviceIds: uniqueIds }, + details: { kind, deviceIds: uniqueIds, ...(hostDeviceId ? { hostDeviceId } : {}) }, }); const members = await loadMembers([groupId!], auth); return c.json( - // kind is 'multiboot' for every group creatable today; surfaced so the - // client shape is stable when future kinds (e.g. vm_host) land. - { id: groupId!, orgId, kind: 'multiboot', name: name ?? null, members: members.get(groupId!) ?? [] }, + { id: groupId!, orgId, kind, name: name ?? null, members: members.get(groupId!) ?? [] }, 201, ); }, @@ -347,10 +366,11 @@ linksRoutes.patch( .where(eq(deviceLinkGroups.id, groupId)); } if (toRemove.length > 0) { - // Only unlink devices actually in THIS group. + // Only unlink devices actually in THIS group. Role is cleared with + // the membership — it is meaningless outside a group (#2308). await tx .update(devices) - .set({ linkGroupId: null, updatedAt: new Date() }) + .set({ linkGroupId: null, linkGroupRole: null, updatedAt: new Date() }) .where(and(inArray(devices.id, toRemove), eq(devices.linkGroupId, groupId))); } if (toAdd.length > 0) { @@ -358,20 +378,36 @@ linksRoutes.patch( // only devices still unlinked — or already in this group — take the // group id. A concurrent link stealing one of them aborts with 409 // instead of silently succeeding with fewer members. - const claimed = await tx + // + // Two batches so a no-op RE-add of an existing member keeps its role + // (#2308: overwriting would demote a vm_host group's host to guest + // and dissolve the group). Batch 1 touches only rows already in this + // group; batch 2 only still-unlinked rows — disjoint by WHERE, so + // the combined row count is the same guard as the single update. + const reAdded = await tx .update(devices) .set({ linkGroupId: groupId, updatedAt: new Date() }) - .where(and( - inArray(devices.id, toAdd), - or(isNull(devices.linkGroupId), eq(devices.linkGroupId, groupId)), - )) + .where(and(inArray(devices.id, toAdd), eq(devices.linkGroupId, groupId))) + .returning({ id: devices.id }); + // vm_host (#2308): a group's host is fixed at create time, so every + // newly linked member is a guest. Multiboot members are peers (NULL). + const claimed = await tx + .update(devices) + .set({ + linkGroupId: groupId, + linkGroupRole: group.kind === 'vm_host' ? 'guest' : null, + updatedAt: new Date(), + }) + .where(and(inArray(devices.id, toAdd), isNull(devices.linkGroupId))) .returning({ id: devices.id }); - if (claimed.length !== toAdd.length) { + if (reAdded.length + claimed.length !== toAdd.length) { throw new LinkRaceError(); } } // A group that fell below the minimum after removals is meaningless — - // dissolve it (unlink the lone survivor, delete the row). + // dissolve it (unlink the lone survivor, delete the row). For vm_host + // this also dissolves a group whose HOST was just removed: guests + // without their nesting anchor are a headless group (#2308). dissolved = await dissolveLinkGroupIfBelowMinimum(tx, groupId); }); } catch (err) { diff --git a/apps/api/src/routes/devices/moveOrg.test.ts b/apps/api/src/routes/devices/moveOrg.test.ts index e40a5b0145..e141d59710 100644 --- a/apps/api/src/routes/devices/moveOrg.test.ts +++ b/apps/api/src/routes/devices/moveOrg.test.ts @@ -246,6 +246,9 @@ describe('POST /devices/:id/move-org', () => { orgId: TARGET_ORG, siteId: TARGET_SITE, linkGroupId: null, + // #2308 - role travels with membership: a stale host/guest value + // left behind would poison the device's next link in the new org. + linkGroupRole: null, }); // Two audit events, one per org diff --git a/apps/api/src/routes/devices/moveOrg.ts b/apps/api/src/routes/devices/moveOrg.ts index d6df7cbce7..a5e2a6f0f6 100644 --- a/apps/api/src/routes/devices/moveOrg.ts +++ b/apps/api/src/routes/devices/moveOrg.ts @@ -130,6 +130,11 @@ moveOrgRoutes.post( // ----------- the actual move ----------- let updated: typeof devices.$inferSelect | undefined; + // #2138/#2308 — whether the move dissolved the device's old link group + // (lone multiboot survivor unlinked, or a vm_host group left headless + // when its HOST moved, unlinking every guest). Recorded in the audit + // details so an un-grouped fleet is traceable to this move. + let linkGroupDissolved = false; try { await db.transaction(async (tx) => { // Flip the device row first so any concurrent agent heartbeat @@ -143,8 +148,11 @@ moveOrgRoutes.post( // of a machine in the OLD org. Unlink it here; the composite FK // (link_group_id, org_id) -> device_link_groups(id, org_id) would // otherwise fail the org flip. The source group is dissolved below - // if it drops below the two-profile minimum. + // if it drops below the two-profile minimum (or, for vm_host + // groups, if this device WAS the host — #2308). Role travels with + // membership, so it clears too. linkGroupId: null, + linkGroupRole: null, updatedAt: new Date(), }) .where(eq(devices.id, deviceId)) @@ -152,9 +160,10 @@ moveOrgRoutes.post( updated = row; // #2138 — if the moved device left a link group with a single lone - // profile behind, that group is no longer meaningful: dissolve it. + // profile behind — or it was a vm_host group's HOST (#2308), leaving + // the group headless — that group is no longer meaningful: dissolve it. if (device.linkGroupId) { - await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId); + linkGroupDissolved = await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId); } // Rewrite the denormalized org_id on every device-scoped table. @@ -229,6 +238,13 @@ moveOrgRoutes.post( targetOrgId, sourceSiteId: device.siteId, targetSiteId, + // #2138/#2308 — a move can dissolve the device's old link group and + // unlink every remaining member (all guests, when a vm_host group's + // host moves). Without this the audit trail shows only "device moved" + // while sibling devices silently lost their grouping. + ...(device.linkGroupId + ? { linkGroupId: device.linkGroupId, linkGroupDissolved } + : {}), } as const; writeRouteAudit(c, { diff --git a/apps/api/src/routes/devices/schemas.ts b/apps/api/src/routes/devices/schemas.ts index 6d5b82848f..cfbdfc8bdf 100644 --- a/apps/api/src/routes/devices/schemas.ts +++ b/apps/api/src/routes/devices/schemas.ts @@ -184,13 +184,33 @@ export const createGroupSchema = z.object({ export const updateGroupSchema = createGroupSchema.partial().omit({ orgId: true }); -// Linked device profiles for multi-boot systems (#2138). A link group ties 2+ -// device records together as boot profiles of one physical machine. Sizes track +// Device link groups (#2138 multiboot, #2308 vm_host). A link group ties 2+ +// device records together — as peer boot profiles of one physical machine +// (multiboot) or as one host server plus its guest VMs (vm_host). Sizes track // MIN_LINK_GROUP_SIZE / MAX_LINK_GROUP_SIZE in services/deviceLinkGroups.ts. -export const createLinkGroupSchema = z.object({ - name: z.string().min(1).max(255).optional(), - deviceIds: z.array(z.string().guid()).min(2).max(10), -}); +export const LINK_GROUP_KINDS = ['multiboot', 'vm_host'] as const; + +export const createLinkGroupSchema = z + .object({ + // Defaults to 'multiboot' so pre-#2308 clients keep working unchanged. + kind: z.enum(LINK_GROUP_KINDS).default('multiboot'), + name: z.string().min(1).max(255).optional(), + deviceIds: z.array(z.string().guid()).min(2).max(10), + // vm_host only: which member is the host server. Required for vm_host + // (the asymmetry is the whole point), rejected for multiboot (peers). + hostDeviceId: z.string().guid().optional(), + }) + .superRefine((d, ctx) => { + if (d.kind === 'vm_host') { + if (!d.hostDeviceId) { + ctx.addIssue({ code: 'custom', path: ['hostDeviceId'], message: 'A vm_host group requires hostDeviceId' }); + } else if (!d.deviceIds.includes(d.hostDeviceId)) { + ctx.addIssue({ code: 'custom', path: ['hostDeviceId'], message: 'hostDeviceId must be one of deviceIds' }); + } + } else if (d.hostDeviceId !== undefined) { + ctx.addIssue({ code: 'custom', path: ['hostDeviceId'], message: 'hostDeviceId only applies to vm_host groups' }); + } + }); export const updateLinkGroupSchema = z .object({ diff --git a/apps/api/src/services/deviceLinkGroups.test.ts b/apps/api/src/services/deviceLinkGroups.test.ts new file mode 100644 index 0000000000..595d4a2033 --- /dev/null +++ b/apps/api/src/services/deviceLinkGroups.test.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for the link-group service helpers (#2138 multiboot, #2308 + * vm_host). The route suites mock this module entirely, so the kind-aware + * dissolve rules — the load-bearing #2308 behavior — are proven here against a + * fake DbExecutor. Real-DB coverage of the composite-FK ordering lives in + * deviceLinkGroupsRls.integration.test.ts. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../db', () => ({ db: {} })); +vi.mock('./sentry', () => ({ captureException: vi.fn() })); + +import { captureException } from './sentry'; + +import { + dissolveLinkGroupIfBelowMinimum, + unlinkDevices, + type DbExecutor, +} from './deviceLinkGroups'; + +interface ExecCalls { + updateSets: Record[]; + deletes: number; + selects: number; +} + +/** + * Fake DbExecutor. Select #1 is the members query (awaited bare, projection + * {id, role}); select #2 is the group-kind lookup (.limit(1)). Updates record + * their .set() payloads; deletes are counted. + */ +function makeExec( + members: Array<{ id: string; role: string | null }>, + group: { kind: string } | undefined, +): { exec: DbExecutor; calls: ExecCalls } { + const calls: ExecCalls = { updateSets: [], deletes: 0, selects: 0 }; + const exec = { + select: () => { + calls.selects += 1; + const rows = calls.selects === 1 ? members : group ? [group] : []; + const chain = { + from: () => chain, + where: () => chain, + limit: () => Promise.resolve(rows), + then: (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(rows).then(res, rej), + }; + return chain; + }, + update: () => ({ + set: (s: Record) => { + calls.updateSets.push(s); + return { where: () => Promise.resolve(undefined) }; + }, + }), + delete: () => { + calls.deletes += 1; + return { where: () => Promise.resolve(undefined) }; + }, + } as unknown as DbExecutor; + return { exec, calls }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('unlinkDevices', () => { + it('clears link_group_role together with link_group_id (#2308)', async () => { + const { exec, calls } = makeExec([], undefined); + await unlinkDevices(exec, ['dev-1', 'dev-2']); + expect(calls.updateSets).toHaveLength(2); + for (const set of calls.updateSets) { + expect(set).toMatchObject({ linkGroupId: null, linkGroupRole: null }); + } + }); + + it('no-ops on an empty id list', async () => { + const { exec, calls } = makeExec([], undefined); + await unlinkDevices(exec, []); + expect(calls.updateSets).toHaveLength(0); + }); +}); + +describe('dissolveLinkGroupIfBelowMinimum', () => { + it('leaves a multiboot group at the minimum untouched (no group lookup needed beyond kind)', async () => { + const { exec, calls } = makeExec( + [ + { id: 'dev-1', role: null }, + { id: 'dev-2', role: null }, + ], + { kind: 'multiboot' }, + ); + const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1'); + expect(dissolved).toBe(false); + expect(calls.updateSets).toHaveLength(0); + expect(calls.deletes).toBe(0); + }); + + it('dissolves ANY kind that falls below the two-member minimum', async () => { + const { exec, calls } = makeExec([{ id: 'dev-1', role: 'guest' }], { kind: 'vm_host' }); + const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1'); + expect(dissolved).toBe(true); + // The lone survivor is unlinked (role cleared too) before the group row + // is deleted — the composite FK forbids the reverse order. + expect(calls.updateSets).toHaveLength(1); + expect(calls.updateSets[0]).toMatchObject({ linkGroupId: null, linkGroupRole: null }); + expect(calls.deletes).toBe(1); + }); + + it('keeps a vm_host group whose host is still a member (#2308)', async () => { + const { exec, calls } = makeExec( + [ + { id: 'dev-host', role: 'host' }, + { id: 'dev-vm1', role: 'guest' }, + { id: 'dev-vm2', role: 'guest' }, + ], + { kind: 'vm_host' }, + ); + const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-vm'); + expect(dissolved).toBe(false); + expect(calls.deletes).toBe(0); + }); + + it('dissolves a HEADLESS vm_host group — guests remain but the host is gone (#2308)', async () => { + const { exec, calls } = makeExec( + [ + { id: 'dev-vm1', role: 'guest' }, + { id: 'dev-vm2', role: 'guest' }, + ], + { kind: 'vm_host' }, + ); + const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-vm'); + expect(dissolved).toBe(true); + // Both guests unlinked, then the group row deleted. + expect(calls.updateSets).toHaveLength(2); + for (const set of calls.updateSets) { + expect(set).toMatchObject({ linkGroupId: null, linkGroupRole: null }); + } + expect(calls.deletes).toBe(1); + }); + + it('does NOT apply the headless rule to multiboot groups (peers never have a host)', async () => { + const { exec, calls } = makeExec( + [ + { id: 'dev-1', role: null }, + { id: 'dev-2', role: null }, + { id: 'dev-3', role: null }, + ], + { kind: 'multiboot' }, + ); + const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1'); + expect(dissolved).toBe(false); + expect(calls.deletes).toBe(0); + }); + + it('reports a missing group row LOUDLY and does not invent a dissolve', async () => { + // 2+ devices reference the group but its row is invisible. The composite + // FK makes "deleted while referenced" unreachable, so this is corruption + // or an RLS policy filtering the group row — surface it (console.error + + // Sentry), return false, touch nothing. + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { exec, calls } = makeExec( + [ + { id: 'dev-1', role: null }, + { id: 'dev-2', role: null }, + ], + undefined, + ); + const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1'); + expect(dissolved).toBe(false); + expect(calls.deletes).toBe(0); + expect(calls.updateSets).toHaveLength(0); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('grp-1')); + expect(vi.mocked(captureException)).toHaveBeenCalledTimes(1); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/services/deviceLinkGroups.ts b/apps/api/src/services/deviceLinkGroups.ts index d4bbb5f8e4..25f560b311 100644 --- a/apps/api/src/services/deviceLinkGroups.ts +++ b/apps/api/src/services/deviceLinkGroups.ts @@ -1,13 +1,16 @@ /** - * Linked device profiles for multi-boot systems (#2138). + * Device link groups (#2138 multiboot, #2308 vm_host). * * A physical machine that dual/multi-boots runs one Breeze agent per OS and so * appears as several device records — only one online at a time. A * `device_link_groups` row plus the `devices.link_group_id` column groups those - * records as boot profiles of one machine. This is a NON-destructive overlay: - * device records stay fully separate. Membership is the column on `devices` - * (one group per device), so there is no child membership table — a group is - * dissolved by nulling its members and deleting the group row. + * records as boot profiles of one machine (kind='multiboot'). kind='vm_host' + * (#2308) reuses the same mechanism asymmetrically: one member is the host + * server (`devices.link_group_role`='host') and the rest are its guest VMs + * ('guest'), all concurrently online. Either way this is a NON-destructive + * overlay: device records stay fully separate. Membership is the column on + * `devices` (one group per device), so there is no child membership table — a + * group is dissolved by nulling its members and deleting the group row. * * These helpers run through a `DbExecutor` (the request `db` OR a transaction * handle) so callers — the link routes and the move-org path — can compose them @@ -19,6 +22,7 @@ import { eq } from 'drizzle-orm'; import { db } from '../db'; import { deviceLinkGroups, devices } from '../db/schema'; +import { captureException } from './sentry'; /** db instance or an open transaction — both expose the query builders used here. */ export type DbExecutor = typeof db | Parameters[0]>[0]; @@ -29,32 +33,62 @@ export const MIN_LINK_GROUP_SIZE = 2; /** Upper bound on members in one physical-machine group (generous headroom). */ export const MAX_LINK_GROUP_SIZE = 10; -/** Clear `link_group_id` on the given devices (unlink), leaving the group row. */ +/** + * Clear `link_group_id` on the given devices (unlink), leaving the group row. + * Also clears `link_group_role` (#2308) — role is meaningless outside a group, + * and a stale 'host'/'guest' left behind would poison the device's NEXT link. + */ export async function unlinkDevices(exec: DbExecutor, deviceIds: string[]): Promise { if (deviceIds.length === 0) return; for (const id of deviceIds) { await exec .update(devices) - .set({ linkGroupId: null, updatedAt: new Date() }) + .set({ linkGroupId: null, linkGroupRole: null, updatedAt: new Date() }) .where(eq(devices.id, id)); } } /** - * If a group has fallen below {@link MIN_LINK_GROUP_SIZE} members, unlink any - * survivor and delete the (now-meaningless) group row. Returns true when the - * group was dissolved. A group at or above the minimum is left untouched. + * Dissolve a group that no longer makes sense: unlink any survivors and delete + * the group row. Returns true when the group was dissolved. + * + * - Any kind: fewer than {@link MIN_LINK_GROUP_SIZE} members. + * - vm_host (#2308) additionally: no member with role 'host' remains — guests + * without their host server have lost their nesting anchor (the host was + * unlinked, moved org, or hard-deleted), so the group is headless and gone. */ export async function dissolveLinkGroupIfBelowMinimum( exec: DbExecutor, groupId: string, ): Promise { const members = await exec - .select({ id: devices.id }) + .select({ id: devices.id, role: devices.linkGroupRole }) .from(devices) .where(eq(devices.linkGroupId, groupId)); - if (members.length >= MIN_LINK_GROUP_SIZE) return false; + if (members.length >= MIN_LINK_GROUP_SIZE) { + const [group] = await exec + .select({ kind: deviceLinkGroups.kind }) + .from(deviceLinkGroups) + .where(eq(deviceLinkGroups.id, groupId)) + .limit(1); + if (!group) { + // Members reference the group but its row is not visible/present. The + // composite FK makes "deleted while still referenced" unreachable, so + // this is corruption or an RLS policy filtering the group row while the + // device rows stay visible — the same class the /:id/link-group route + // reports LOUDLY. Don't invent a dissolve on it, but never swallow it. + console.error( + `link group ${groupId} has ${members.length} members but no visible group row — possible RLS filtering or data corruption`, + ); + captureException( + new Error(`device link group dangling membership: group ${groupId} invisible with ${members.length} members`), + ); + return false; + } + const headlessVmHost = group.kind === 'vm_host' && !members.some((m) => m.role === 'host'); + if (!headlessVmHost) return false; + } // Null any lone survivor first — the composite FK forbids deleting the group // while a device still references it. diff --git a/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx b/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx index 9301c3cdd6..794c681c92 100644 --- a/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx +++ b/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx @@ -122,4 +122,55 @@ describe('DeviceLinkedProfilesTab', () => { expect(deleteCall[0]).toBe('/devices/link-groups/g1'); expect(deleteCall[1]).toMatchObject({ method: 'DELETE' }); }); + + describe('vm_host groups (#2308)', () => { + const vmPayload = { + group: { id: 'g-vm', kind: 'vm_host', name: null }, + members: [ + { deviceId: 'dev-vm1', hostname: 'vm-web', displayName: null, osType: 'linux', osVersion: '22', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'guest' }, + { deviceId: 'dev-host', hostname: 'hv-01', displayName: null, osType: 'windows', osVersion: '2022', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'host' }, + ], + }; + + it('shows the vm_host heading, guest count, and a Role column with the host sorted first', async () => { + fetchWithAuthMock.mockResolvedValue(jsonResponse(vmPayload)); + render(); + await waitFor(() => expect(screen.getByTestId('linked-profiles-tab')).toBeInTheDocument()); + + expect(screen.getByText('VM host + guests')).toBeInTheDocument(); + expect(screen.getByText('1 guests')).toBeInTheDocument(); + expect(screen.getByTestId('linked-profile-dev-host-role')).toHaveTextContent('Host'); + expect(screen.getByTestId('linked-profile-dev-vm1-role')).toHaveTextContent('Guest'); + // Host row sorted above the guest despite arriving second from the API. + const rows = screen.getAllByTestId(/^linked-profile-dev-(host|vm1)$/); + expect(rows[0]!.getAttribute('data-testid')).toBe('linked-profile-dev-host'); + }); + + it('renders no Role column for multiboot groups', async () => { + fetchWithAuthMock.mockResolvedValue( + jsonResponse({ + group: { id: 'g1', kind: 'multiboot', name: null }, + members: [ + { deviceId: 'dev-1', hostname: 'a', displayName: null, osType: 'windows', osVersion: '11', agentVersion: '1', status: 'online', lastSeenAt: null, role: null }, + { deviceId: 'dev-2', hostname: 'b', displayName: null, osType: 'linux', osVersion: '22', agentVersion: '1', status: 'offline', lastSeenAt: null, role: null }, + ], + }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('linked-profiles-tab')).toBeInTheDocument()); + + expect(screen.queryByText('Role')).not.toBeInTheDocument(); + expect(screen.queryByTestId('linked-profile-dev-1-role')).not.toBeInTheDocument(); + expect(screen.getByText('2 profiles')).toBeInTheDocument(); + }); + + it('warns on the unlink button when the current device is the host', async () => { + fetchWithAuthMock.mockResolvedValue(jsonResponse(vmPayload)); + render(); + await waitFor(() => expect(screen.getByTestId('linked-profiles-tab')).toBeInTheDocument()); + + const unlink = screen.getByTestId('linked-profiles-unlink-self'); + expect(unlink.getAttribute('title')).toContain('host'); + }); + }); }); diff --git a/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx b/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx index d25a12c6eb..c258850442 100644 --- a/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx +++ b/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx @@ -5,7 +5,7 @@ import { runAction, handleActionError } from "../../lib/runAction"; import { useTranslation } from "react-i18next"; import "../../lib/i18n"; -/** One boot profile (device record) in a linked multi-boot group. */ +/** One member (boot profile, host server, or guest VM) of a link group. */ export interface LinkedProfile { deviceId: string; hostname: string; @@ -15,10 +15,12 @@ export interface LinkedProfile { agentVersion: string; status: string; lastSeenAt: string | null; + /** vm_host groups (#2308): 'host' | 'guest'. null for multiboot peers. */ + role?: string | null; } interface LinkGroupResponse { - group: { id: string; name: string | null } | null; + group: { id: string; kind?: string; name: string | null } | null; members: LinkedProfile[]; } @@ -81,7 +83,11 @@ export default function DeviceLinkedProfilesTab({ }, [load]); const group = data?.group ?? null; - const members = data?.members ?? []; + const isVmHost = group?.kind === 'vm_host'; + // vm_host (#2308): host first, then guests, preserving API order within each. + const members = isVmHost + ? [...(data?.members ?? [])].sort((a, b) => (a.role === 'host' ? -1 : 0) - (b.role === 'host' ? -1 : 0)) + : data?.members ?? []; const unlinkThisDevice = async () => { if (!group) return; @@ -184,6 +190,11 @@ export default function DeviceLinkedProfilesTab({ {" "} {t("deviceLinkedProfilesTab.toGroupThemWhenOnlyOne")}{" "}

+

+ For a virtualization host and its guest VMs, choose + Link as VM host + guests instead — the guests nest under + the host server's row in the device list while remaining fully managed endpoints. +

); } @@ -194,10 +205,13 @@ export default function DeviceLinkedProfilesTab({

- {group.name || "Linked boot profiles"} + {group.name || + (isVmHost ? "VM host + guests" : "Linked boot profiles")}

- {members.length} {t("deviceLinkedProfilesTab.profiles")}{" "} + {isVmHost + ? `${members.filter((m) => m.role === "guest").length} guests` + : `${members.length} ${t("deviceLinkedProfilesTab.profiles")}`}
@@ -206,6 +220,11 @@ export default function DeviceLinkedProfilesTab({ disabled={busy} onClick={() => void unlinkThisDevice()} data-testid="linked-profiles-unlink-self" + title={ + isVmHost && members.find((m) => m.deviceId === deviceId)?.role === 'host' + ? 'This device is the host — unlinking it removes the whole group (guests are unlinked too).' + : undefined + } className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50" > @@ -228,8 +247,9 @@ export default function DeviceLinkedProfilesTab({ - {t("deviceLinkedProfilesTab.profile")} + {isVmHost ? "Device" : t("deviceLinkedProfilesTab.profile")} + {isVmHost && Role} {t("deviceLinkedProfilesTab.os")} @@ -267,6 +287,20 @@ export default function DeviceLinkedProfilesTab({ {m.hostname}
+ {isVmHost && ( + + + {/* Only assert a role the data actually carries — a + null/unknown role (invariant violation, stale + payload) renders a neutral dash, not "Guest". */} + {m.role === 'host' ? 'Host' : m.role === 'guest' ? 'Guest' : '—'} + + + )} {m.osType}{" "} diff --git a/apps/web/src/components/devices/DeviceList.test.tsx b/apps/web/src/components/devices/DeviceList.test.tsx index 5d4678318d..48176b9912 100644 --- a/apps/web/src/components/devices/DeviceList.test.tsx +++ b/apps/web/src/components/devices/DeviceList.test.tsx @@ -960,3 +960,102 @@ describe('DeviceList — hidden-decommissioned hint (#2251)', () => { expect(screen.queryByTestId('decommissioned-hidden-hint')).toBeNull(); }); }); + +describe('DeviceList — vm_host guest nesting (#2308)', () => { + beforeEach(() => { + window.localStorage?.clear(); + }); + + const hostId = '71111111-1111-1111-1111-111111111111'; + const vm1Id = '72222222-2222-2222-2222-222222222222'; + const vm2Id = '73333333-3333-3333-3333-333333333333'; + + const mkVm = (over: Partial): Device => ({ + ...baseDevice, + linkGroupId: 'vm-group-1', + status: 'online', + ...over, + }); + + const hostDev = () => + mkVm({ id: hostId, hostname: 'hv-01', linkGroupRole: 'host' }); + const guest1 = () => + mkVm({ id: vm1Id, hostname: 'vm-web', os: 'linux', linkGroupRole: 'guest' }); + const guest2 = () => + mkVm({ id: vm2Id, hostname: 'vm-db', os: 'linux', linkGroupRole: 'guest' }); + + it('renders guests as full selectable rows nested beneath the host', () => { + render(); + + // Guests keep their own checkboxes — fully managed rows, not strips. + expect(screen.getByLabelText('Select hv-01')).toBeInTheDocument(); + expect(screen.getByLabelText('Select vm-web')).toBeInTheDocument(); + expect(screen.getByLabelText('Select vm-db')).toBeInTheDocument(); + // Nesting affordances: toggle on the host, glyphs on the guests. + expect(screen.getByTestId(`device-${hostId}-vm-toggle`)).toBeInTheDocument(); + expect(screen.getByTestId(`device-${vm1Id}-vm-guest-glyph`)).toBeInTheDocument(); + expect(screen.getByTestId(`device-${vm2Id}-vm-guest-glyph`)).toBeInTheDocument(); + // No multiboot treatment leaks in. + expect(screen.queryByTestId(`device-${vm1Id}-inactive-strip`)).toBeNull(); + expect(screen.queryByTestId(`device-${hostId}-group-bar`)).toBeNull(); + }); + + it('collapses guests behind a summary strip and expands them again', () => { + render(); + + const toggle = screen.getByTestId(`device-${hostId}-vm-toggle`); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(toggle); + // Guests are hidden; a summary strip stands in. + expect(screen.queryByLabelText('Select vm-web')).toBeNull(); + expect(screen.queryByLabelText('Select vm-db')).toBeNull(); + const strip = screen.getByTestId(`device-${hostId}-vm-collapsed-strip`); + expect(strip.textContent).toContain('2 guest VMs hidden'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + // Clicking the strip expands again. + fireEvent.click(strip); + expect(screen.getByLabelText('Select vm-web')).toBeInTheDocument(); + expect(screen.queryByTestId(`device-${hostId}-vm-collapsed-strip`)).toBeNull(); + }); + + it('excludes collapsed (hidden) guests from select-all', () => { + const onBulkAction = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId(`device-${hostId}-vm-toggle`)); + fireEvent.click(screen.getByLabelText('Select all devices on this page')); + fireEvent.click(screen.getByRole('button', { name: /bulk actions/i })); + fireEvent.click(screen.getByText('Reboot Selected')); + + expect(onBulkAction).toHaveBeenCalledTimes(1); + const selected = onBulkAction.mock.calls[0]![1] as Device[]; + expect(selected.map((d) => d.id)).toEqual([hostId]); + }); + + it('offers the "Link as VM host + guests" bulk action when 2+ devices are selected', () => { + const onBulkAction = vi.fn(); + const plain = { ...baseDevice, id: '74444444-4444-4444-4444-444444444444', hostname: 'plain-b' }; + render(); + + fireEvent.click(screen.getByLabelText('Select all devices on this page')); + fireEvent.click(screen.getByRole('button', { name: /bulk actions/i })); + fireEvent.click(screen.getByTestId('bulk-link-vm-host')); + + expect(onBulkAction).toHaveBeenCalledTimes(1); + expect(onBulkAction.mock.calls[0]![0]).toBe('link-vm-host'); + expect((onBulkAction.mock.calls[0]![1] as Device[]).map((d) => d.id).sort()).toEqual( + [baseDevice.id, plain.id].sort(), + ); + }); + + it('renders guests as plain ungrouped rows when the host is not on the page', () => { + render(); + + expect(screen.queryByTestId(`device-${vm1Id}-vm-guest-glyph`)).toBeNull(); + expect(screen.queryByTestId(`device-${vm2Id}-vm-guest-glyph`)).toBeNull(); + expect(screen.getByLabelText('Select vm-web')).toBeInTheDocument(); + expect(screen.getByLabelText('Select vm-db')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/devices/DeviceList.tsx b/apps/web/src/components/devices/DeviceList.tsx index 0d7e8a1b23..0f8655e3e6 100644 --- a/apps/web/src/components/devices/DeviceList.tsx +++ b/apps/web/src/components/devices/DeviceList.tsx @@ -6,6 +6,7 @@ import { ChevronUp, ChevronDown, ArrowUpDown, + CornerDownRight, MoreHorizontal, MoreVertical, Filter, @@ -201,6 +202,13 @@ export type Device = { * groupLinkedDevices in linkedDevices.ts. */ linkGroupId?: string | null; + /** + * vm_host link groups (#2308). 'host' = this record is the host server of a + * vm_host group; 'guest' = a guest VM nested under that host. null/undefined + * for unlinked devices and multiboot members (peers). A non-null role + * implies the group's kind is 'vm_host' — no group fetch needed. + */ + linkGroupRole?: 'host' | 'guest' | null; }; // Columns that only make sense for the network arm (#1322); hidden unless @@ -510,6 +518,10 @@ export default function DeviceList({ ); useEffect(() => subscribeLinkedProfileCollapse(setLinkedCollapse), []); const [selectedIds, setSelectedIds] = useState>(new Set()); + // vm_host nesting (#2308): link-group ids whose guest rows are collapsed + // beneath their host. Transient per-visit presentation state (default: + // expanded, guests visible) — deliberately NOT persisted/hash-encoded. + const [collapsedVmGroups, setCollapsedVmGroups] = useState>(new Set()); const [bulkMenuOpen, setBulkMenuOpen] = useState(false); const [rowMenuOpenId, setRowMenuOpenId] = useState(null); // Flip the row dropdown direction when the click happens close to the @@ -798,22 +810,35 @@ export default function DeviceList({ startIndex + effectivePageSize, ); - // Linked multi-boot presentation (#2138): computed client-side WITHIN the - // current page. Exactly one online member → offline siblings render as thin - // strips beneath it; all offline → full rows with a left-edge group bar; - // 2+ online → all normal rows. Toggle off → flat list. + // Linked-device presentation, computed client-side WITHIN the current page. + // Multiboot (#2138): exactly one online member → offline siblings render as + // thin strips beneath it; all offline → full rows with a left-edge group + // bar; 2+ online → all normal rows. The collapse toggle gates ONLY these + // multiboot heuristics (off → flat multiboot rows). vm_host nesting (#2308) + // is hierarchical organization, not offline-noise suppression, so guests + // nest under their host regardless of the toggle. const displayRows = useMemo( () => groupLinkedDevices(paginatedDevices, linkedCollapse === "on"), [paginatedDevices, linkedCollapse], ); + // vm_host nesting (#2308): drop guest rows whose group is collapsed. The + // host row renders a "N guests hidden" strip in their place. + const visibleRows = useMemo( + () => displayRows.filter(r => !(r.vmRole === 'guest' && r.vmGroupId && collapsedVmGroups.has(r.vmGroupId))), + [displayRows, collapsedVmGroups] + ); // Strips are NOT selectable rows — bulk selection only sees real full rows. + // Collapsed (hidden) vm_host guests are likewise excluded: select-all must + // never silently pick up rows the user cannot see. const selectablePageDevices = useMemo( - () => displayRows.map((r) => r.device), - [displayRows], + () => visibleRows.map((r) => r.device), + [visibleRows], ); - // Only offer the toggle when the fleet actually has linked profiles. + // Only offer the collapse toggle when the fleet actually has MULTIBOOT + // linked profiles — it doesn't govern vm_host nesting (#2308), so a fleet + // with only vm_host groups gets no dead toggle. const hasLinkedDevices = useMemo( - () => devices.some((d) => d.linkGroupId), + () => devices.some((d) => d.linkGroupId && !d.linkGroupRole), [devices], ); @@ -1953,6 +1978,16 @@ export default function DeviceList({ {t("deviceList.linkAsMultiBoot")}{" "} )} + {selectedIds.size >= 2 && ( + + )}
+ )} + {/* vm_host (#2308): nesting glyph on guest rows. */} + {vmRole === "guest" && ( + + )} + e.stopPropagation()} + onChange={(e) => + handleSelectOne(device.id, e.target.checked) + } + className="h-4 w-4 rounded border-border" + /> + {renderedColumns.map((id) => columnDefs[id].cell(device))} + {/* vm_host (#2308): when a host's guests are collapsed, a thin + strip stands in for them — click to expand. */} + {vmRole === "host" && + vmGroupId && + collapsedVmGroups.has(vmGroupId) && ( + + setCollapsedVmGroups((prev) => { + const next = new Set(prev); + next.delete(vmGroupId); + return next; + }) + } + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setCollapsedVmGroups((prev) => { + const next = new Set(prev); + next.delete(vmGroupId); + return next; + }); + } + }} + className="cursor-pointer bg-muted/30 transition hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-hidden" + > + +
+ + + {vmGuestCount} guest VM{vmGuestCount === 1 ? "" : "s"} hidden — click to expand + +
+ + + )} {/* Linked multi-boot: expected-offline boot profiles tucked beneath their online sibling as thin muted strips (#2138). Clickable through to the device's own detail page; NOT diff --git a/apps/web/src/components/devices/DevicesPage.test.tsx b/apps/web/src/components/devices/DevicesPage.test.tsx index d19acdd722..8454bf6326 100644 --- a/apps/web/src/components/devices/DevicesPage.test.tsx +++ b/apps/web/src/components/devices/DevicesPage.test.tsx @@ -51,6 +51,8 @@ vi.mock('../../services/deviceActions', () => ({ watchWakeOutcome: vi.fn(), WakeCommandError: class WakeCommandError extends Error { code = 'x'; }, wakeFriendlyErrorMessage: vi.fn(() => null), + linkDevicesMultiboot: vi.fn(), + linkDevicesVmHost: vi.fn(), })); vi.mock('@/lib/navigation', () => ({ @@ -138,7 +140,7 @@ vi.mock('./DeviceList', () => ({ data-display-names={devices.map(d => d.displayName ?? '').join(',')} data-watchdog-versions={devices.map(d => d.watchdogVersion ?? '').join(',')} > - {['maintenance-on', 'maintenance-off', 'decommission', 'reboot', 'run-script'].map(action => ( + {['maintenance-on', 'maintenance-off', 'decommission', 'reboot', 'run-script', 'link-vm-host'].map(action => ( + + + + + ); +} diff --git a/apps/web/src/components/devices/linkedDevices.test.ts b/apps/web/src/components/devices/linkedDevices.test.ts index bdb069b409..35c56ad9b2 100644 --- a/apps/web/src/components/devices/linkedDevices.test.ts +++ b/apps/web/src/components/devices/linkedDevices.test.ts @@ -119,3 +119,87 @@ describe('groupLinkedDevices', () => { expect(out.find((r) => r.device.id === 'b-lin')!.offlineGroup).toBe(true); }); }); + +describe('groupLinkedDevices — vm_host nesting (#2308)', () => { + it('nests guests directly beneath their host, in page order, as full marked rows', () => { + const before = mk('before'); + const vm1 = mk('vm1', { os: 'linux', linkGroupId: 'gVM', linkGroupRole: 'guest', status: 'online' }); + const host = mk('hv', { linkGroupId: 'gVM', linkGroupRole: 'host', status: 'online' }); + const vm2 = mk('vm2', { linkGroupId: 'gVM', linkGroupRole: 'guest', status: 'offline' }); + const after = mk('after'); + + const out = groupLinkedDevices([before, vm1, host, vm2, after], true); + + // Guests are reordered to follow the host; nothing is hidden or stripped. + expect(out.map((r) => r.device.id)).toEqual(['before', 'hv', 'vm1', 'vm2', 'after']); + const hostRow = out.find((r) => r.device.id === 'hv')!; + expect(hostRow.vmRole).toBe('host'); + expect(hostRow.vmGroupId).toBe('gVM'); + expect(hostRow.vmGuestCount).toBe(2); + for (const id of ['vm1', 'vm2']) { + const guestRow = out.find((r) => r.device.id === id)!; + expect(guestRow.vmRole).toBe('guest'); + expect(guestRow.vmGroupId).toBe('gVM'); + expect(guestRow.inactiveSiblings).toHaveLength(0); + expect(guestRow.offlineGroup).toBe(false); + } + }); + + it('never applies the multiboot offline heuristics to a vm_host group', () => { + // One online member + offline guests would strip-collapse under the + // multiboot rules — vm_host members must stay full rows regardless. + const host = mk('hv', { linkGroupId: 'gVM', linkGroupRole: 'host', status: 'online' }); + const vm1 = mk('vm1', { linkGroupId: 'gVM', linkGroupRole: 'guest', status: 'offline' }); + + const out = groupLinkedDevices([host, vm1], true); + expect(out.map((r) => r.device.id)).toEqual(['hv', 'vm1']); + expect(out.every((r) => r.inactiveSiblings.length === 0 && !r.offlineGroup)).toBe(true); + }); + + it('renders guests ungrouped when the host is not on this page (pagination caveat)', () => { + const vm1 = mk('vm1', { linkGroupId: 'gVM', linkGroupRole: 'guest' }); + const vm2 = mk('vm2', { linkGroupId: 'gVM', linkGroupRole: 'guest' }); + + const out = groupLinkedDevices([vm1, vm2], true); + expect(out.map((r) => r.device.id)).toEqual(['vm1', 'vm2']); + expect(out.every((r) => r.vmRole === undefined)).toBe(true); + }); + + it('leaves a lone host row unmarked when its guests are on another page', () => { + const host = mk('hv', { linkGroupId: 'gVM', linkGroupRole: 'host' }); + const out = groupLinkedDevices([host], true); + expect(out).toHaveLength(1); + expect(out[0]!.vmRole).toBeUndefined(); + }); + + it('keeps vm_host nesting when the multiboot collapse toggle is off (toggle gates multiboot only)', () => { + // The "Collapse linked inactive profiles" preference is offline-noise + // suppression for multiboot machines; vm_host nesting is hierarchical + // organization and must not be coupled to it. + const host = mk('hv', { linkGroupId: 'gVM', linkGroupRole: 'host' }); + const vm1 = mk('vm1', { linkGroupId: 'gVM', linkGroupRole: 'guest' }); + const mbWin = mk('mb-win', { linkGroupId: 'gMB', status: 'online' }); + const mbLin = mk('mb-lin', { os: 'linux', linkGroupId: 'gMB', status: 'offline' }); + + const out = groupLinkedDevices([mbWin, host, mbLin, vm1], false); + // Multiboot members: flat plain rows. vm_host: still nested + marked. + expect(out.map((r) => r.device.id)).toEqual(['mb-win', 'hv', 'vm1', 'mb-lin']); + expect(out.find((r) => r.device.id === 'mb-win')!.inactiveSiblings).toHaveLength(0); + expect(out.find((r) => r.device.id === 'mb-lin')!.offlineGroup).toBe(false); + expect(out.find((r) => r.device.id === 'hv')!.vmRole).toBe('host'); + expect(out.find((r) => r.device.id === 'vm1')!.vmRole).toBe('guest'); + }); + + it('handles a vm_host group and a multiboot group on the same page independently', () => { + const host = mk('hv', { linkGroupId: 'gVM', linkGroupRole: 'host', status: 'online' }); + const vm1 = mk('vm1', { linkGroupId: 'gVM', linkGroupRole: 'guest', status: 'online' }); + const mbWin = mk('mb-win', { linkGroupId: 'gMB', status: 'online' }); + const mbLin = mk('mb-lin', { os: 'linux', linkGroupId: 'gMB', status: 'offline' }); + + const out = groupLinkedDevices([mbWin, host, mbLin, vm1], true); + expect(out.map((r) => r.device.id)).toEqual(['mb-win', 'hv', 'vm1']); + expect(out.find((r) => r.device.id === 'mb-win')!.inactiveSiblings.map((d) => d.id)).toEqual(['mb-lin']); + expect(out.find((r) => r.device.id === 'hv')!.vmRole).toBe('host'); + expect(out.find((r) => r.device.id === 'vm1')!.vmRole).toBe('guest'); + }); +}); diff --git a/apps/web/src/components/devices/linkedDevices.ts b/apps/web/src/components/devices/linkedDevices.ts index 140bd5b919..58338b5702 100644 --- a/apps/web/src/components/devices/linkedDevices.ts +++ b/apps/web/src/components/devices/linkedDevices.ts @@ -18,6 +18,18 @@ import type { Device } from './DeviceList'; * - TWO OR MORE members online → all render as normal full rows (no conflict * state; deliberately designed out). * + * VM-host groups (#2308) are the ASYMMETRIC sibling of the above: one member + * is the host server (`linkGroupRole === 'host'`), the rest are its guest VMs + * (`'guest'`). Host + guests are concurrently online, so none of the multiboot + * expected-offline treatment applies. Instead, guest rows are reordered to sit + * directly beneath their host and marked (`vmRole`/`vmGroupId`) so DeviceList + * can indent them and offer an expand/collapse affordance on the host row. + * Guests stay FULL rows — selectable, bulk-op visible, every column rendered. + * A non-null role implies the group kind is 'vm_host' (the list API sends the + * role scalar precisely so no group-table join/fetch is needed here). When the + * host is not on this page (pagination split / filtered out), its guests + * render as normal ungrouped rows — same caveat as multiboot. + * * Grouping is computed CLIENT-SIDE within the given page slice. Accepted * caveat: siblings split across a pagination boundary render ungrouped on that * page (a group needs 2+ members present to group at all). @@ -31,15 +43,25 @@ export interface DeviceListRow { inactiveSiblings: Device[]; /** True when this row belongs to an all-offline link group (left-edge bar). */ offlineGroup: boolean; + /** vm_host (#2308): this row is the host server or a nested guest VM. */ + vmRole?: 'host' | 'guest'; + /** vm_host (#2308): the link group id — collapse state keys on this. */ + vmGroupId?: string; + /** vm_host (#2308): host rows only — number of guests nested on this page. */ + vmGuestCount?: number; } -function toRow(device: Device): DeviceListRow { - return { device, inactiveSiblings: [], offlineGroup: false }; -} - -export function groupLinkedDevices(pageDevices: Device[], enabled: boolean): DeviceListRow[] { - if (!enabled) return pageDevices.map(toRow); - +/** + * @param multibootCollapseEnabled Gates ONLY the multiboot strip/bar + * heuristics (the per-user "Collapse linked inactive profiles" preference — + * an offline-noise-suppression concern). vm_host nesting (#2308) is + * hierarchical organization, unrelated to that preference, and applies + * regardless of the toggle. + */ +export function groupLinkedDevices( + pageDevices: Device[], + multibootCollapseEnabled: boolean, +): DeviceListRow[] { // Members of each link group present on THIS page, in page order. const membersByGroup = new Map(); for (const d of pageDevices) { @@ -54,12 +76,36 @@ export function groupLinkedDevices(pageDevices: Device[], enabled: boolean): Dev const stripDeviceIds = new Set(); const stripsByAnchor = new Map(); const offlineGroupIds = new Set(); + // vm_host (#2308): guests reordered beneath their host row. + const guestsByHost = new Map(); + const nestedGuestIds = new Set(); + const vmHostIds = new Set(); for (const [groupId, members] of membersByGroup) { // A lone member on this page (sibling filtered out or on another page) // renders as a normal ungrouped row. if (members.length < 2) continue; + // vm_host (#2308): any member carrying a role marks the group asymmetric. + // The multiboot online/offline heuristics below deliberately do NOT apply — + // host + guests are concurrently online by design. + if (members.some((m) => m.linkGroupRole === 'host' || m.linkGroupRole === 'guest')) { + const host = members.find((m) => m.linkGroupRole === 'host'); + // Host off-page/filtered out → guests render as normal ungrouped rows + // (no anchor to nest under), same caveat as a split multiboot group. + if (!host) continue; + const guests = members.filter((m) => m.id !== host.id && m.linkGroupRole === 'guest'); + if (guests.length === 0) continue; + vmHostIds.add(host.id); + guestsByHost.set(host.id, guests); + for (const g of guests) nestedGuestIds.add(g.id); + continue; + } + + // Multiboot strip/bar heuristics respect the collapse preference: off → + // multiboot members render as a flat list of plain rows. + if (!multibootCollapseEnabled) continue; + const online = members.filter((m) => m.status === 'online'); if (online.length === 1) { const anchor = online[0]!; @@ -80,11 +126,31 @@ export function groupLinkedDevices(pageDevices: Device[], enabled: boolean): Dev const out: DeviceListRow[] = []; for (const device of pageDevices) { if (stripDeviceIds.has(device.id)) continue; // renders as a strip, not a row - out.push({ + if (nestedGuestIds.has(device.id)) continue; // emitted right after its host below + const row: DeviceListRow = { device, inactiveSiblings: stripsByAnchor.get(device.id) ?? [], offlineGroup: device.linkGroupId ? offlineGroupIds.has(device.linkGroupId) : false, - }); + }; + if (vmHostIds.has(device.id) && device.linkGroupId) { + const guests = guestsByHost.get(device.id) ?? []; + row.vmRole = 'host'; + row.vmGroupId = device.linkGroupId; + row.vmGuestCount = guests.length; + out.push(row); + // Guests nest directly beneath their host, in page order. + for (const guest of guests) { + out.push({ + device: guest, + inactiveSiblings: [], + offlineGroup: false, + vmRole: 'guest', + vmGroupId: device.linkGroupId, + }); + } + continue; + } + out.push(row); } return out; } diff --git a/apps/web/src/services/deviceActions.test.ts b/apps/web/src/services/deviceActions.test.ts index b18e267ec9..b2141afa7d 100644 --- a/apps/web/src/services/deviceActions.test.ts +++ b/apps/web/src/services/deviceActions.test.ts @@ -78,3 +78,36 @@ describe('sendDeviceCommand error extraction', () => { expect(thrownMessage).toBe('Failed to send device command'); }); }); + +describe('linkDevicesVmHost wire shape (#2308)', () => { + it('POSTs kind, hostDeviceId, and deviceIds to /devices/link-groups', async () => { + const { linkDevicesVmHost } = await import('./deviceActions'); + fetchMock.mockResolvedValue(makeJsonResponse({ id: 'grp-vm' })); + + const result = await linkDevicesVmHost('dev-host', ['dev-host', 'dev-vm1', 'dev-vm2']); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('/devices/link-groups'); + expect(init?.method).toBe('POST'); + // The exact body contract the API's createLinkGroupSchema validates — + // a drifted key here means every vm_host link 400s. + expect(JSON.parse(init?.body as string)).toEqual({ + kind: 'vm_host', + hostDeviceId: 'dev-host', + deviceIds: ['dev-host', 'dev-vm1', 'dev-vm2'], + }); + expect(result).toEqual({ id: 'grp-vm' }); + }); + + it('throws the API error message on failure', async () => { + const { linkDevicesVmHost } = await import('./deviceActions'); + fetchMock.mockResolvedValue( + makeJsonResponse({ error: 'A vm_host group requires hostDeviceId' }, false, 400), + ); + + await expect(linkDevicesVmHost('dev-host', ['dev-host', 'dev-vm1'])).rejects.toThrow( + 'A vm_host group requires hostDeviceId', + ); + }); +}); diff --git a/apps/web/src/services/deviceActions.ts b/apps/web/src/services/deviceActions.ts index 2bd8439808..01f4cf1642 100644 --- a/apps/web/src/services/deviceActions.ts +++ b/apps/web/src/services/deviceActions.ts @@ -417,6 +417,31 @@ export async function linkDevicesMultiboot( return data.data ?? data; } +/** + * Create a vm_host link group (#2308): `hostDeviceId` becomes the host server, + * every other member of `deviceIds` becomes a guest VM nested under it in the + * device list. `hostDeviceId` must be included in `deviceIds`; same-org and + * not-already-linked rules match the multiboot path (the API enforces all). + */ +export async function linkDevicesVmHost( + hostDeviceId: string, + deviceIds: string[], + name?: string, +): Promise<{ id: string }> { + const response = await fetchWithAuth('/devices/link-groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'vm_host', hostDeviceId, deviceIds, ...(name ? { name } : {}) }), + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Failed to link devices')); + } + + const data = await response.json(); + return data.data ?? data; +} + export async function restoreDevice(deviceId: string): Promise<{ success: boolean }> { const response = await fetchWithAuth(`/devices/${deviceId}/restore`, { method: 'POST'