/** * T9 (v0.10) — grid of `preview-client` cards (the manage-page moved CLIENT-SIDE under E2E). * * Renders one read-only, locally-decrypted preview per authorized host. A host the account can't * reach → `loadReplay` rejects → the card shows "unavailable", never another tenant's screen * (INV1). Disposes all preview clients on unmount so no xterms/sockets leak (EXPLORE §6 preview-cost * discipline); `hostContentSecret` is never written to `localStorage`/`console`. */ import type { ApiClient } from './api-client' import type { HostRecord } from './api-schemas' import { mountPreviewClient, type PreviewClient, type PreviewDeps, type ReplaySource, } from './preview-client' const DEFAULT_DIMS = { cols: 80, rows: 24 } as const export interface PreviewGridOpts { readonly dims?: { cols: number; rows: number } } export function mountPreviewGrid( root: HTMLElement, api: ApiClient, loadReplay: ( hostId: string, ) => Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }>, deps: PreviewDeps, opts: PreviewGridOpts = {}, ): { dispose(): void } { const dims = opts.dims ?? DEFAULT_DIMS const clients: PreviewClient[] = [] let disposed = false function unavailableCard(host: HostRecord): HTMLElement { const card = document.createElement('div') card.className = 'preview-card' card.dataset['hostId'] = host.hostId const msg = document.createElement('p') msg.className = 'preview-unavailable' msg.textContent = 'unavailable' card.append(msg) return card } async function build(): Promise { let hosts: readonly HostRecord[] try { hosts = await api.listHosts() } catch { const err = document.createElement('p') err.className = 'preview-grid-error' err.textContent = 'Could not load previews.' root.append(err) return } if (disposed) return for (const host of hosts) { const card = document.createElement('div') card.className = 'preview-card' card.dataset['hostId'] = host.hostId root.append(card) try { const { replay, hostContentSecret } = await loadReplay(host.hostId) if (disposed) return const client = mountPreviewClient(card, replay, hostContentSecret, dims, deps) clients.push(client) await client.render() } catch { // A host the account can't reach (403) or a decrypt failure → "unavailable" (INV1). card.replaceWith(unavailableCard(host)) } } } void build() return { dispose(): void { disposed = true for (const c of clients) c.dispose() // no leaked xterms/sockets }, } }