From 86852933b8a56f97d1f4f5158bdc593979483aeb Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Tue, 24 Mar 2026 11:12:45 +0530 Subject: [PATCH 1/2] feat: group level of participation --- .../control-panel/config/admin-enames.json | 4 +- .../src/lib/server/evault-graphql.ts | 402 ++++++++++ .../src/lib/services/cacheService.ts | 36 + .../src/lib/services/evaultService.ts | 55 ++ .../control-panel/src/routes/+layout.svelte | 1 + .../control-panel/src/routes/+page.svelte | 20 +- .../src/routes/api/evaults/+server.ts | 194 +---- .../[evaultId]/group-insights/+server.ts | 759 ++++++++++++++++++ .../src/routes/api/evaults/groups/+server.ts | 25 + .../routes/evaults/[evaultId]/+page.svelte | 15 + .../src/routes/groups/+page.svelte | 156 ++++ .../src/routes/groups/[evaultId]/+page.svelte | 166 ++++ 12 files changed, 1639 insertions(+), 194 deletions(-) create mode 100644 infrastructure/control-panel/src/lib/server/evault-graphql.ts create mode 100644 infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts create mode 100644 infrastructure/control-panel/src/routes/groups/+page.svelte create mode 100644 infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte diff --git a/infrastructure/control-panel/config/admin-enames.json b/infrastructure/control-panel/config/admin-enames.json index 1827d693c..bc06288bc 100644 --- a/infrastructure/control-panel/config/admin-enames.json +++ b/infrastructure/control-panel/config/admin-enames.json @@ -4,6 +4,8 @@ "@82f7a77a-f03a-52aa-88fc-1b1e488ad498", "@35a31f0d-dd76-5780-b383-29f219fcae99", "@82f7a77a-f03a-52aa-88fc-1b1e488ad498", - "@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c" + "@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c", + "@6e1bbcd4-1f59-5bd8-aa3c-6f5301c356d7", + "@b995a88a-90d1-56fc-ba42-1e1eb664861c" ] } diff --git a/infrastructure/control-panel/src/lib/server/evault-graphql.ts b/infrastructure/control-panel/src/lib/server/evault-graphql.ts new file mode 100644 index 000000000..ba4967fa2 --- /dev/null +++ b/infrastructure/control-panel/src/lib/server/evault-graphql.ts @@ -0,0 +1,402 @@ +import { PUBLIC_REGISTRY_URL } from '$env/static/public'; +import { registryService, type RegistryVault } from '$lib/services/registry'; + +export const USER_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440000'; +/** Ontology id used when storing group manifests (web3-adapter, Blabsy/Pictique/charter group.mapping.json, MessageNotificationService). */ +export const GROUP_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440003'; +/** Alternate id from services/ontology/schemas/groupManifest.json — try if primary misses. */ +export const GROUP_ONTOLOGY_ID_LEGACY = 'a8bfb7cf-3200-4b25-9ea9-ee41100f212e'; +export const MESSAGE_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440004'; + +const META_ENVELOPES_PARSED_QUERY = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int) { + metaEnvelopes(filter: $filter, first: $first) { + edges { + node { + parsed + } + } + } + } +`; + +const META_ENVELOPE_BY_ID_QUERY = ` + query MetaEnvelopeById($id: ID!) { + metaEnvelope(id: $id) { + id + ontology + parsed + } + } +`; + +const LEGACY_GET_META_ENVELOPE_BY_ID_QUERY = ` + query LegacyGetMetaEnvelopeById($id: String!) { + getMetaEnvelopeById(id: $id) { + id + ontology + parsed + } + } +`; + +/** Chat and GroupManifest share ontology 550e8400-...003; scan enough rows to find the manifest. */ +const GROUP_MANIFEST_SCAN_FIRST = 80; + +function parsedListFromMetaEnvelopesPayload( + payload: Record | null +): Record[] { + if (!payload) { + return []; + } + const data = payload.data as Record | undefined; + const metaEnvelopes = data?.metaEnvelopes as Record | undefined; + const edges = metaEnvelopes?.edges as Array<{ node?: { parsed?: unknown } }> | undefined; + if (!edges?.length) { + return []; + } + const out: Record[] = []; + for (const edge of edges) { + const parsed = edge.node?.parsed; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + out.push(parsed as Record); + } + } + return out; +} + +export async function fetchParsedListByOntology( + vault: RegistryVault, + ontologyId: string, + token: string | undefined, + first: number +): Promise[]> { + const payload = await evaultGraphqlPost({ + uri: vault.uri, + ename: vault.ename, + query: META_ENVELOPES_PARSED_QUERY, + variables: { + filter: { ontologyId }, + first + }, + token, + timeoutMs: 12000 + }); + return parsedListFromMetaEnvelopesPayload(payload); +} + +/** True when `parsed` looks like GroupManifest (not Chat: those use participantIds, not members). */ +export function isGroupManifestParsed(parsed: unknown): parsed is Record { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false; + } + const o = parsed as Record; + return Array.isArray(o.members) && typeof o.owner === 'string'; +} + +export type VaultIdentity = { name: string; type: 'user' | 'group' | 'unknown' }; + +export async function requestPlatformToken(platform: string): Promise { + const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.staging.metastate.foundation'; + const response = await fetch(new URL('/platforms/certification', registryUrl).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ platform }), + signal: AbortSignal.timeout(2500) + }); + + if (!response.ok) { + throw new Error(`Failed to get platform token: HTTP ${response.status}`); + } + + const data = (await response.json()) as { token?: string }; + if (!data.token) { + throw new Error('Failed to get platform token: missing token in response'); + } + + return data.token; +} + +export type EvaultGraphqlPostOptions = { + uri: string; + ename: string; + query: string; + variables?: Record; + token?: string; + timeoutMs?: number; +}; + +/** + * POST GraphQL to a vault. Tries Bearer token first when provided, then X-ENAME-only. + * Returns parsed JSON body or null on failure. + */ +export async function evaultGraphqlPost( + options: EvaultGraphqlPostOptions +): Promise | null> { + const timeoutMs = options.timeoutMs ?? 2500; + const tryRequest = async (withAuth: boolean): Promise | null> => { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-ENAME': options.ename + }; + if (withAuth && options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + + const response = await fetch(`${options.uri}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify({ + query: options.query, + variables: options.variables ?? {} + }), + signal: AbortSignal.timeout(timeoutMs) + }); + + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as Record; + if (Array.isArray(payload?.errors) && (payload.errors as unknown[]).length > 0) { + return null; + } + return payload; + }; + + try { + const withAuth = await tryRequest(Boolean(options.token)); + if (withAuth) { + return withAuth; + } + if (options.token) { + return await tryRequest(false); + } + return null; + } catch { + return null; + } +} + +function firstNonEmptyString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +/** Dashboard-style label: user profile displayName/username, else fallbacks (e.g. ename, evault id). */ +export function displayNameFromUserProfile( + profile: Record | null | undefined, + ...fallbacks: unknown[] +): string { + const fromProfile = firstNonEmptyString(profile?.displayName, profile?.name, profile?.username); + if (fromProfile) { + return fromProfile; + } + return firstNonEmptyString(...fallbacks) ?? 'Unknown'; +} + +export async function fetchFirstParsedByOntology( + vault: RegistryVault, + ontologyId: string, + token?: string +): Promise | null> { + const payload = await evaultGraphqlPost({ + uri: vault.uri, + ename: vault.ename, + query: META_ENVELOPES_PARSED_QUERY, + variables: { + filter: { ontologyId }, + first: 1 + }, + token + }); + + if (!payload) { + return null; + } + const data = payload.data as Record | undefined; + const metaEnvelopes = data?.metaEnvelopes as Record | undefined; + const edges = metaEnvelopes?.edges as Array<{ node?: { parsed?: unknown } }> | undefined; + const parsed = edges?.[0]?.node?.parsed; + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + return parsed as Record; +} + +function readMetaEnvelopeNode( + payload: Record | null +): { ontology: string; parsed: Record } | null { + if (!payload) { + return null; + } + const data = payload.data as Record | undefined; + const node = + (data?.metaEnvelope as Record | undefined | null) ?? + (data?.getMetaEnvelopeById as Record | undefined | null); + if (!node || typeof node !== 'object') { + return null; + } + const ontology = typeof node.ontology === 'string' ? node.ontology : ''; + const parsed = node.parsed; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + return { ontology, parsed: parsed as Record }; +} + +/** + * Load a single MetaEnvelope by id in the given vault context (X-ENAME + optional Bearer). + * Tries idiomatic `metaEnvelope(id)` then legacy `getMetaEnvelopeById`. + */ +export async function fetchMetaEnvelopeById( + vault: RegistryVault, + metaEnvelopeId: string, + token?: string +): Promise<{ ontology: string; parsed: Record } | null> { + const tryIdiomatic = await evaultGraphqlPost({ + uri: vault.uri, + ename: vault.ename, + query: META_ENVELOPE_BY_ID_QUERY, + variables: { id: metaEnvelopeId }, + token, + timeoutMs: 8000 + }); + const fromIdiomatic = readMetaEnvelopeNode(tryIdiomatic); + if (fromIdiomatic) { + return fromIdiomatic; + } + + const tryLegacy = await evaultGraphqlPost({ + uri: vault.uri, + ename: vault.ename, + query: LEGACY_GET_META_ENVELOPE_BY_ID_QUERY, + variables: { id: metaEnvelopeId }, + token, + timeoutMs: 8000 + }); + return readMetaEnvelopeNode(tryLegacy); +} + +export function isUserOntologyId(ontologyField: string): boolean { + return ontologyField.trim().toLowerCase() === USER_ONTOLOGY_ID.toLowerCase(); +} + +/** + * Prefer a full GroupManifest among meta-envelopes with ontology 003 (Chat uses the same id). + * Otherwise return the first matching row (e.g. chat) for name/display fallbacks. + */ +export async function fetchGroupManifestOrFallbackParsed( + vault: RegistryVault, + token?: string +): Promise | null> { + const primaryList = await fetchParsedListByOntology( + vault, + GROUP_ONTOLOGY_ID, + token, + GROUP_MANIFEST_SCAN_FIRST + ); + const manifestHit = primaryList.find((p) => isGroupManifestParsed(p)); + if (manifestHit) { + return manifestHit; + } + if (primaryList.length > 0) { + return primaryList[0]; + } + + const legacyList = await fetchParsedListByOntology( + vault, + GROUP_ONTOLOGY_ID_LEGACY, + token, + GROUP_MANIFEST_SCAN_FIRST + ); + const legacyManifest = legacyList.find((p) => isGroupManifestParsed(p)); + if (legacyManifest) { + return legacyManifest; + } + return legacyList[0] ?? null; +} + +export async function resolveVaultIdentity( + vault: RegistryVault, + token?: string +): Promise { + const defaultName = firstNonEmptyString(vault.ename, vault.evault, 'Unknown') || 'Unknown'; + + const userProfile = await fetchFirstParsedByOntology(vault, USER_ONTOLOGY_ID, token); + if (userProfile) { + return { + type: 'user', + name: + firstNonEmptyString( + userProfile.displayName, + userProfile.name, + userProfile.username, + vault.ename, + vault.evault + ) || defaultName + }; + } + + const groupManifest = await fetchGroupManifestOrFallbackParsed(vault, token); + if (groupManifest) { + return { + type: 'group', + name: + firstNonEmptyString( + groupManifest.name, + groupManifest.displayName, + groupManifest.title, + groupManifest.eName, + groupManifest.ename, + vault.ename, + vault.evault + ) || defaultName + }; + } + + return { + type: 'unknown', + name: defaultName + }; +} + +/** One row in the control-panel eVault list (registry + resolved identity). */ +export interface RegistryEvaultRow { + id: string; + name: string; + type: 'user' | 'group' | 'unknown'; + ename: string; + uri: string; + evault: string; + status: string; + serviceUrl?: string; +} + +export async function fetchRegistryEvaultRows(token?: string): Promise { + const registryVaults = await registryService.getEVaults(); + return Promise.all( + registryVaults.map(async (vault) => { + const evaultId = vault.evault || vault.ename; + const identity = await resolveVaultIdentity(vault, token); + return { + id: evaultId, + name: identity.name, + type: identity.type, + ename: vault.ename, + uri: vault.uri, + evault: vault.evault, + status: 'Unknown', + serviceUrl: vault.uri + }; + }) + ); +} diff --git a/infrastructure/control-panel/src/lib/services/cacheService.ts b/infrastructure/control-panel/src/lib/services/cacheService.ts index fec831ad7..839c5341c 100644 --- a/infrastructure/control-panel/src/lib/services/cacheService.ts +++ b/infrastructure/control-panel/src/lib/services/cacheService.ts @@ -17,9 +17,13 @@ const defaultData: CacheData = { isStale: true }; +const DEFAULT_SENDER_PROFILE_TTL_MS = 10 * 60 * 1000; + class CacheService { private db: any | null = null; private isInitialized = false; + /** Server-only: MetaEnvelope id (message sender global id) → resolved display name; reduces cross-vault probes. */ + private senderProfileDisplayByGlobalId = new Map(); constructor() { // Only initialize on the server side @@ -173,6 +177,38 @@ class CacheService { await this.db.write(); } } + + /** + * In-memory TTL cache (Node only). Returns undefined if missing or expired. + */ + getCachedSenderProfileDisplayName(globalMetaEnvelopeId: string): string | undefined { + if (typeof window !== 'undefined') { + return undefined; + } + const row = this.senderProfileDisplayByGlobalId.get(globalMetaEnvelopeId); + if (!row) { + return undefined; + } + if (Date.now() > row.expiresAt) { + this.senderProfileDisplayByGlobalId.delete(globalMetaEnvelopeId); + return undefined; + } + return row.value; + } + + setCachedSenderProfileDisplayName( + globalMetaEnvelopeId: string, + displayName: string, + ttlMs: number = DEFAULT_SENDER_PROFILE_TTL_MS + ): void { + if (typeof window !== 'undefined' || !displayName.trim()) { + return; + } + this.senderProfileDisplayByGlobalId.set(globalMetaEnvelopeId, { + value: displayName.trim(), + expiresAt: Date.now() + ttlMs + }); + } } // Export a singleton instance diff --git a/infrastructure/control-panel/src/lib/services/evaultService.ts b/infrastructure/control-panel/src/lib/services/evaultService.ts index 4c1f00118..315ceeca8 100644 --- a/infrastructure/control-panel/src/lib/services/evaultService.ts +++ b/infrastructure/control-panel/src/lib/services/evaultService.ts @@ -1,6 +1,29 @@ import type { EVault } from '../../routes/api/evaults/+server'; import { cacheService } from './cacheService'; +export type GroupSenderRow = { + /** User ontology display name / username, same idea as the dashboard. */ + displayName: string; + /** W3ID ename when known, raw sender id when not in registry, or "—" for system/no sender. */ + ename: string; + messageCount: number; +}; + +export type GroupInsights = { + evault: { ename: string; uri: string; evault: string }; + manifest: Record; + messageStats: { + totalCount: number; + messagesScanned: number; + /** Messages bucketed to a sender id or resolvable ename (excludes null / missing sender). */ + messagesWithSenderBucket: number; + /** Messages with no sender id in the payload. */ + messagesWithoutSender: number; + capped: boolean; + senderRows: GroupSenderRow[]; + }; +}; + export class EVaultService { /** * Get eVaults - load from cache first, then fetch fresh data @@ -115,6 +138,38 @@ export class EVaultService { } } + static async getGroupEVaults(): Promise { + try { + const response = await fetch('/api/evaults/groups'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return data.evaults || []; + } catch (error) { + console.error('Failed to fetch group eVaults:', error); + throw error; + } + } + + static async getGroupInsights(evaultId: string): Promise { + try { + const response = await fetch( + `/api/evaults/${encodeURIComponent(evaultId)}/group-insights` + ); + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + const message = + typeof errBody?.error === 'string' ? errBody.error : `HTTP ${response.status}`; + throw new Error(message); + } + return (await response.json()) as GroupInsights; + } catch (error) { + console.error('Failed to fetch group insights:', error); + throw error; + } + } + /** * Get logs for a specific eVault by namespace and podName */ diff --git a/infrastructure/control-panel/src/routes/+layout.svelte b/infrastructure/control-panel/src/routes/+layout.svelte index f4bade69e..9e6115575 100644 --- a/infrastructure/control-panel/src/routes/+layout.svelte +++ b/infrastructure/control-panel/src/routes/+layout.svelte @@ -12,6 +12,7 @@ const navLinks = [ { label: 'Dashboard', href: '/' }, + { label: 'Groups', href: '/groups' }, { label: 'Monitoring', href: '/monitoring' }, { label: 'Actions', href: '/actions' }, { label: 'Notifications', href: '/notifications' }, diff --git a/infrastructure/control-panel/src/routes/+page.svelte b/infrastructure/control-panel/src/routes/+page.svelte index 1ed9b1885..1df24205d 100644 --- a/infrastructure/control-panel/src/routes/+page.svelte +++ b/infrastructure/control-panel/src/routes/+page.svelte @@ -5,7 +5,7 @@ import { registryService } from '$lib/services/registry'; import type { Platform } from '$lib/services/registry'; import { Table } from '$lib/ui'; - import { RefreshCw, UserRound, Users } from 'lucide-svelte'; + import { CircleQuestionMark, RefreshCw, UserRound, Users } from 'lucide-svelte'; import { onMount } from 'svelte'; import type { EVault } from './api/evaults/+server'; @@ -81,7 +81,7 @@ type: 'snippet' as const, value: { ename: evault.ename || 'N/A', - type: evault.type || 'group' + type: evault.type }, snippet: ENameWithType, width: 7 @@ -363,9 +363,12 @@ const paginated = paginatedEVaults(); const evault = paginated[index]; if (evault) { - // Use evault ID (evault field or ename) for navigation const evaultId = evault.evault || evault.ename || evault.id; - goto(`/evaults/${encodeURIComponent(evaultId)}`); + if (evault.type === 'group') { + goto(`/groups/${encodeURIComponent(evaultId)}`); + } else { + goto(`/evaults/${encodeURIComponent(evaultId)}`); + } } } @@ -463,14 +466,17 @@ {#snippet ENameWithType(value: unknown)} - {@const item = (value as { ename?: string; type?: 'user' | 'group' } | null) ?? null} + {@const item = (value as { ename?: string; type?: 'user' | 'group' | 'unknown' } | null) ?? + null} {@const ename = item?.ename || 'N/A'} - {@const type = item?.type || 'group'} + {@const type = item?.type ?? 'unknown'}
{#if type === 'user'} - {:else} + {:else if type === 'group'} + {:else} + {/if} {ename}
diff --git a/infrastructure/control-panel/src/routes/api/evaults/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/+server.ts index 3dea2f0bd..e33a7d892 100644 --- a/infrastructure/control-panel/src/routes/api/evaults/+server.ts +++ b/infrastructure/control-panel/src/routes/api/evaults/+server.ts @@ -1,169 +1,13 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; -import { PUBLIC_CONTROL_PANEL_URL, PUBLIC_REGISTRY_URL } from '$env/static/public'; -import { registryService, type RegistryVault } from '$lib/services/registry'; +import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public'; +import { + fetchRegistryEvaultRows, + requestPlatformToken, + type RegistryEvaultRow +} from '$lib/server/evault-graphql'; -const USER_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440000'; -const GROUP_ONTOLOGY_ID = 'a8bfb7cf-3200-4b25-9ea9-ee41100f212e'; - -const META_ENVELOPES_QUERY = ` - query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int) { - metaEnvelopes(filter: $filter, first: $first) { - edges { - node { - parsed - } - } - } - } -`; - -export interface EVault { - id: string; // evault identifier (evault field from registry) - name: string; // display name (ename or evault) - type: 'user' | 'group'; // derived from ontology lookup - ename: string; // w3id identifier - uri: string; // resolved service URI - evault: string; // evault identifier - status: string; // derived from health check - serviceUrl?: string; // same as uri for display -} - -function firstNonEmptyString(...values: unknown[]): string | null { - for (const value of values) { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - } - return null; -} - -async function fetchFirstParsedByOntology( - vault: RegistryVault, - ontologyId: string, - token?: string -): Promise | null> { - const tryRequest = async (withAuth: boolean): Promise | null> => { - const headers: Record = { - 'Content-Type': 'application/json', - 'X-ENAME': vault.ename - }; - if (withAuth && token) { - headers.Authorization = `Bearer ${token}`; - } - - const response = await fetch(`${vault.uri}/graphql`, { - method: 'POST', - headers, - body: JSON.stringify({ - query: META_ENVELOPES_QUERY, - variables: { - filter: { ontologyId }, - first: 1 - } - }), - signal: AbortSignal.timeout(2500) - }); - - if (!response.ok) { - return null; - } - - const payload = await response.json(); - if (Array.isArray(payload?.errors) && payload.errors.length > 0) { - return null; - } - const parsed = payload?.data?.metaEnvelopes?.edges?.[0]?.node?.parsed; - - if (!parsed || typeof parsed !== 'object') { - return null; - } - - return parsed as Record; - }; - - try { - // Try with token first (preferred), then fallback to X-ENAME-only. - const withAuth = await tryRequest(Boolean(token)); - if (withAuth) { - return withAuth; - } - - if (token) { - return await tryRequest(false); - } - - return null; - } catch { - return null; - } -} - -async function resolveVaultIdentity( - vault: RegistryVault, - token?: string -): Promise<{ name: string; type: 'user' | 'group' }> { - const defaultName = firstNonEmptyString(vault.ename, vault.evault, 'Unknown') || 'Unknown'; - - const userProfile = await fetchFirstParsedByOntology(vault, USER_ONTOLOGY_ID, token); - if (userProfile) { - return { - type: 'user', - name: - firstNonEmptyString( - userProfile.displayName, - userProfile.username, - vault.ename, - vault.evault - ) || defaultName - }; - } - - const groupManifest = await fetchFirstParsedByOntology(vault, GROUP_ONTOLOGY_ID, token); - if (groupManifest) { - return { - type: 'group', - name: - firstNonEmptyString( - groupManifest.name, - groupManifest.displayName, - groupManifest.title, - groupManifest.eName, - groupManifest.ename, - vault.ename, - vault.evault - ) || defaultName - }; - } - - return { - type: 'group', - name: defaultName - }; -} - -async function requestPlatformToken(platform: string): Promise { - const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.staging.metastate.foundation'; - const response = await fetch(new URL('/platforms/certification', registryUrl).toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ platform }), - signal: AbortSignal.timeout(2500) - }); - - if (!response.ok) { - throw new Error(`Failed to get platform token: HTTP ${response.status}`); - } - - const data = (await response.json()) as { token?: string }; - if (!data.token) { - throw new Error('Failed to get platform token: missing token in response'); - } - - return data.token; -} +export type EVault = RegistryEvaultRow; export const GET: RequestHandler = async ({ url }) => { try { @@ -175,29 +19,7 @@ export const GET: RequestHandler = async ({ url }) => { console.warn('Falling back to X-ENAME-only eVault queries:', tokenError); } - // Fetch all evaults from registry - const registryVaults = await registryService.getEVaults(); - - // Transform registry vaults to EVault format - const evaults: EVault[] = await Promise.all( - registryVaults.map(async (vault) => { - // Use evault identifier as the primary ID, fallback to ename - const evaultId = vault.evault || vault.ename; - - const identity = await resolveVaultIdentity(vault, token); - - return { - id: evaultId, - name: identity.name, - type: identity.type, - ename: vault.ename, - uri: vault.uri, - evault: vault.evault, - status: 'Unknown', - serviceUrl: vault.uri - }; - }) - ); + const evaults: EVault[] = await fetchRegistryEvaultRows(token); // Filter out platform-owned user profiles (e.g. "File Manager Platform"). const filteredEVaults = evaults.filter( diff --git a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts new file mode 100644 index 000000000..6c574b215 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts @@ -0,0 +1,759 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public'; +import { cacheService } from '$lib/services/cacheService'; +import { registryService, type RegistryVault } from '$lib/services/registry'; +import { + displayNameFromUserProfile, + evaultGraphqlPost, + fetchGroupManifestOrFallbackParsed, + fetchMetaEnvelopeById, + fetchRegistryEvaultRows, + isUserOntologyId, + MESSAGE_ONTOLOGY_ID, + requestPlatformToken, + resolveVaultIdentity, + type RegistryEvaultRow +} from '$lib/server/evault-graphql'; + +/** + * Strategy A: resolve message `senderId` (User MetaEnvelope global id) by probing registry user vaults with + * `metaEnvelope(id)` + X-ENAME. Strategy C (long-term): denormalize senderEname/display on write in + * web3-adapter / Blabsy message mapping — avoids O(senders × vaults) GraphQL. + */ +const PROBE_VAULT_CONCURRENCY = 6; +const MAX_USER_VAULTS_TO_PROBE = 150; + +const MESSAGES_PAGE_QUERY = ` + query GroupMessages($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) { + metaEnvelopes(filter: $filter, first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + parsed + } + } + } + } +`; + +const PAGE_SIZE = 100; +const MAX_PAGES = 50; + +type MessageConnection = { + totalCount?: number; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + edges?: Array<{ node?: { parsed?: Record } }>; +}; + +function readMessageConnection(payload: Record | null): MessageConnection | null { + if (!payload) { + return null; + } + const data = payload.data as Record | undefined; + const conn = data?.metaEnvelopes as MessageConnection | undefined; + return conn ?? null; +} + +/** Match dashboard / monitoring: ignore leading @ and case when comparing W3IDs. */ +function normalizeW3id(s: string): string { + return s.trim().replace(/^@+/u, '').toLowerCase(); +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isLikelyUuid(s: string): boolean { + return UUID_RE.test(s.trim()); +} + +/** + * Web3-adapter stores relations as `user(firebaseOrGlobalId)` in some paths; registry keys are often the inner id. + * Peel wrappers so sender buckets line up with registry `evault` / mapping global ids. + */ +function unwrapRelationIdDeep(raw: string | null | undefined): string | null { + if (raw == null || typeof raw !== 'string') { + return null; + } + let t = raw.trim(); + if (!t) { + return null; + } + for (let i = 0; i < 4; i++) { + const m = /^(\w+)\(([^)]+)\)$/.exec(t); + if (!m) { + break; + } + const inner = m[2].trim(); + if (!inner || inner === t) { + break; + } + t = inner; + } + return t || null; +} + +/** Same `name` field as the dashboard table (`fetchRegistryEvaultRows`), keyed every way we might see a sender id. */ +function buildDashboardNameByKey(rows: RegistryEvaultRow[]): Map { + const m = new Map(); + for (const row of rows) { + const name = typeof row.name === 'string' ? row.name.trim() : ''; + if (!name) { + continue; + } + const add = (k: string | undefined | null) => { + if (k == null || typeof k !== 'string') { + return; + } + let t = k.trim(); + if (!t) { + return; + } + for (let depth = 0; depth < 6 && t; depth++) { + m.set(t, name); + m.set(t.toLowerCase(), name); + const nw = normalizeW3id(t); + if (nw) { + m.set(nw, name); + } + const inner = /^(\w+)\(([^)]+)\)$/.exec(t); + t = inner ? inner[2].trim() : ''; + } + }; + add(row.evault); + add(row.ename); + add(row.id); + } + return m; +} + +function recordDisplayHint( + byBucket: Map>, + bucket: string, + hint: string | null +): void { + if (!hint?.trim()) { + return; + } + const h = hint.trim(); + if (h.length < 2 || isLikelyUuid(h)) { + return; + } + let counts = byBucket.get(bucket); + if (!counts) { + counts = new Map(); + byBucket.set(bucket, counts); + } + counts.set(h, (counts.get(h) ?? 0) + 1); +} + +function pickBestDisplayHint(counts: Map | undefined): string | null { + if (!counts?.size) { + return null; + } + let best: string | null = null; + let bestN = 0; + for (const [k, n] of counts) { + if (n > bestN) { + bestN = n; + best = k; + } + } + return best; +} + +function collectLookupKeys(...parts: (string | null | undefined)[]): string[] { + const keys: string[] = []; + const seen = new Set(); + const addChain = (seed: string | null | undefined) => { + if (!seed?.trim()) { + return; + } + let t = seed.trim(); + for (let depth = 0; depth < 6 && t; depth++) { + if (!seen.has(t)) { + seen.add(t); + keys.push(t); + } + const tl = t.toLowerCase(); + if (!seen.has(tl)) { + seen.add(tl); + keys.push(tl); + } + const nw = normalizeW3id(t); + if (nw && !seen.has(nw)) { + seen.add(nw); + keys.push(nw); + } + const inner = /^(\w+)\(([^)]+)\)$/.exec(t); + t = inner ? inner[2].trim() : ''; + } + }; + for (const p of parts) { + addChain(p); + } + return keys; +} + +function userRegistryRowsForProbe(rows: RegistryEvaultRow[]): RegistryEvaultRow[] { + return rows.filter( + (r) => + r.type === 'user' && + !(typeof r.name === 'string' && /platform$/i.test(r.name.trim())) + ); +} + +function registryRowToVault(row: RegistryEvaultRow): RegistryVault { + return { ename: row.ename, uri: row.uri, evault: row.evault }; +} + +/** + * `metaEnvelopeId` is the web3-adapter global id stored on messages (User profile row id in that user's vault). + * Values are `string` when resolved, `null` when this request already probed all vaults without a hit. + */ +async function probeUserVaultsForSenderMetaEnvelopeId( + metaEnvelopeId: string, + userRows: RegistryEvaultRow[], + token: string | undefined, + probeRequestCache: Map +): Promise { + if (!isLikelyUuid(metaEnvelopeId)) { + return null; + } + const memo = probeRequestCache.get(metaEnvelopeId); + if (memo !== undefined) { + return memo; + } + + const ttlHit = cacheService.getCachedSenderProfileDisplayName(metaEnvelopeId); + if (ttlHit !== undefined) { + probeRequestCache.set(metaEnvelopeId, ttlHit); + return ttlHit; + } + + const limited = userRows.slice(0, MAX_USER_VAULTS_TO_PROBE); + if (userRows.length > MAX_USER_VAULTS_TO_PROBE) { + console.warn( + '[group-insights] sender profile probe capped at', + MAX_USER_VAULTS_TO_PROBE, + 'user vaults (registry has', + userRows.length, + ')' + ); + } + + for (let i = 0; i < limited.length; i += PROBE_VAULT_CONCURRENCY) { + const chunk = limited.slice(i, i + PROBE_VAULT_CONCURRENCY); + const names = await Promise.all( + chunk.map(async (row) => { + const vault = registryRowToVault(row); + const me = await fetchMetaEnvelopeById(vault, metaEnvelopeId, token); + if (!me || !isUserOntologyId(me.ontology)) { + return null; + } + return displayNameFromUserProfile(me.parsed, row.ename); + }) + ); + for (const name of names) { + if (name) { + cacheService.setCachedSenderProfileDisplayName(metaEnvelopeId, name); + probeRequestCache.set(metaEnvelopeId, name); + return name; + } + } + } + + probeRequestCache.set(metaEnvelopeId, null); + return null; +} + +async function resolveSenderDisplayName( + bucketKey: string, + v: RegistryVault | null, + enameCol: string, + token: string | undefined, + dashboardNameByKey: Map, + hintByBucket: Map>, + userRowsForProbe: RegistryEvaultRow[], + probeRequestCache: Map +): Promise { + for (const k of collectLookupKeys(bucketKey, enameCol)) { + const hit = dashboardNameByKey.get(k); + if (hit) { + return hit; + } + } + if (v) { + const identity = await resolveVaultIdentity(v, token); + return identity.name; + } + const hint = pickBestDisplayHint(hintByBucket.get(bucketKey)); + if (hint) { + return hint; + } + if (enameCol && enameCol !== bucketKey && !isLikelyUuid(enameCol)) { + return enameCol; + } + const trimmedBucket = bucketKey.trim(); + if (isLikelyUuid(trimmedBucket)) { + const probed = await probeUserVaultsForSenderMetaEnvelopeId( + trimmedBucket, + userRowsForProbe, + token, + probeRequestCache + ); + if (probed) { + return probed; + } + } + return bucketKey; +} + +/** Build evault id / raw ename → canonical registry `ename` for resolving message senders. */ +function buildRegistryEnameLookup(vaults: RegistryVault[]): Map { + const map = new Map(); + for (const v of vaults) { + const en = typeof v.ename === 'string' && v.ename.trim() ? v.ename.trim() : ''; + if (!en) { + continue; + } + map.set(en, en); + map.set(en.toLowerCase(), en); + const nw = normalizeW3id(en); + if (nw) { + map.set(nw, en); + } + if (v.evault) { + map.set(v.evault, en); + map.set(v.evault.toLowerCase(), en); + } + } + return map; +} + +function firstNonEmptyStringField( + parsed: Record, + keys: string[] +): string | null { + for (const k of keys) { + const v = parsed[k]; + if (typeof v === 'string' && v.trim().length > 0) { + return v.trim(); + } + } + return null; +} + +/** Raw sender id from payload (UUID / legacy), not used as the aggregation key when registry resolves. */ +function rawMessageSenderId(parsed: Record | null | undefined): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + for (const v of [parsed.senderId, parsed.sender_id, parsed.userId]) { + if (typeof v === 'string' && v.trim().length > 0) { + return unwrapRelationIdDeep(v.trim()); + } + if (typeof v === 'number' && Number.isFinite(v)) { + return String(v); + } + } + return null; +} + +function displayHintFromMessage(parsed: Record | null | undefined): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + const top = firstNonEmptyStringField(parsed, [ + 'senderName', + 'sender_display_name', + 'senderDisplayName', + 'authorName', + 'authorDisplayName', + 'fromUserName', + 'fromDisplayName' + ]); + if (top && !isLikelyUuid(top)) { + return top; + } + const sender = parsed.sender; + if (sender && typeof sender === 'object' && !Array.isArray(sender)) { + const s = sender as Record; + const nested = firstNonEmptyStringField(s, [ + 'displayName', + 'display_name', + 'name', + 'username', + 'ename', + 'eName' + ]); + if (nested && !isLikelyUuid(nested)) { + return nested; + } + } + return null; +} + +/** + * Bucket key for counts: W3ID eName when possible (manifest + UI use @…). + * 1) eName fields on the message payload + * 2) registry lookup: senderId / userId as evault id + */ +function resolveMessageSenderEname( + parsed: Record | null | undefined, + registryLookup: Map +): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + const fromPayload = firstNonEmptyStringField(parsed, [ + 'senderEname', + 'sender_ename', + 'senderEName', + 'eName', + 'ename', + 'senderW3id', + 'w3id' + ]); + if (fromPayload) { + return ( + registryLookup.get(fromPayload) ?? + registryLookup.get(fromPayload.toLowerCase()) ?? + registryLookup.get(normalizeW3id(fromPayload)) ?? + fromPayload + ); + } + const rawId = rawMessageSenderId(parsed); + if (!rawId) { + return null; + } + return ( + registryLookup.get(rawId) ?? + registryLookup.get(rawId.toLowerCase()) ?? + registryLookup.get(normalizeW3id(rawId)) ?? + null + ); +} + +const NO_SENDER_BUCKET = '__no_sender__'; + +/** Stable aggregation key: canonical ename when resolvable, else raw sender id, else system bucket. */ +function senderBucketKey( + parsed: Record | null | undefined, + registryLookup: Map +): string { + const ename = resolveMessageSenderEname(parsed, registryLookup); + if (ename) { + return ename; + } + const raw = rawMessageSenderId(parsed); + if (raw) { + return raw; + } + return NO_SENDER_BUCKET; +} + +function findSenderVaultForBucket( + bucketKey: string, + lookup: Map, + vaults: RegistryVault[] +): RegistryVault | null { + const trimmed = bucketKey.trim(); + const peeled = unwrapRelationIdDeep(trimmed); + if (peeled && peeled !== trimmed) { + const nested = findSenderVaultForBucket(peeled, lookup, vaults); + if (nested) { + return nested; + } + } + const lower = trimmed.toLowerCase(); + + const byEvault = vaults.find((v) => v.evault === trimmed); + if (byEvault) { + return byEvault; + } + const byEvaultCi = vaults.find((v) => (v.evault ?? '').toLowerCase() === lower); + if (byEvaultCi) { + return byEvaultCi; + } + + const canonFromLookup = + lookup.get(trimmed) ?? lookup.get(lower) ?? lookup.get(normalizeW3id(trimmed)); + if (canonFromLookup) { + const v = vaults.find( + (x) => + x.ename === canonFromLookup || + normalizeW3id(x.ename) === normalizeW3id(canonFromLookup) + ); + if (v) { + return v; + } + } + + const bucketNorm = normalizeW3id(trimmed); + if (bucketNorm) { + const byNorm = vaults.find((x) => normalizeW3id(x.ename) === bucketNorm); + if (byNorm) { + return byNorm; + } + } + + return ( + vaults.find( + (x) => x.ename === trimmed || x.ename.toLowerCase() === lower + ) ?? null + ); +} + +type SenderRow = { displayName: string; ename: string; messageCount: number }; + +async function mapInChunks(items: T[], chunkSize: number, fn: (item: T) => Promise): Promise { + const out: R[] = []; + for (let i = 0; i < items.length; i += chunkSize) { + const chunk = items.slice(i, i + chunkSize); + out.push(...(await Promise.all(chunk.map(fn)))); + } + return out; +} + +async function buildSenderRows( + byBucket: Record, + registryEnameLookup: Map, + allVaults: RegistryVault[], + token: string | undefined, + dashboardNameByKey: Map, + hintByBucket: Map>, + userRowsForProbe: RegistryEvaultRow[], + probeRequestCache: Map +): Promise { + const entries = Object.entries(byBucket).filter(([k]) => k !== NO_SENDER_BUCKET); + const rows = await mapInChunks(entries, 8, async ([bucketKey, messageCount]) => { + const v = findSenderVaultForBucket(bucketKey, registryEnameLookup, allVaults); + const peeledBucket = unwrapRelationIdDeep(bucketKey.trim()); + const enameFromPeel = peeledBucket + ? registryEnameLookup.get(peeledBucket) ?? + registryEnameLookup.get(peeledBucket.toLowerCase()) ?? + registryEnameLookup.get(normalizeW3id(peeledBucket)) + : undefined; + const enameCol = + v?.ename?.trim() ?? + registryEnameLookup.get(bucketKey.trim()) ?? + registryEnameLookup.get(bucketKey.trim().toLowerCase()) ?? + registryEnameLookup.get(normalizeW3id(bucketKey)) ?? + enameFromPeel ?? + bucketKey; + + const displayName = await resolveSenderDisplayName( + bucketKey, + v, + enameCol, + token, + dashboardNameByKey, + hintByBucket, + userRowsForProbe, + probeRequestCache + ); + + return { displayName, ename: enameCol, messageCount }; + }); + + rows.sort((a, b) => b.messageCount - a.messageCount); + + const noSenderCount = byBucket[NO_SENDER_BUCKET] ?? 0; + if (noSenderCount > 0) { + rows.push({ + displayName: 'System / no sender', + ename: '—', + messageCount: noSenderCount + }); + } + + return rows; +} + +function previewMessageBody(parsed: Record | undefined): string | undefined { + if (!parsed) { + return undefined; + } + for (const key of ['text', 'content', 'body'] as const) { + const v = parsed[key]; + if (typeof v === 'string' && v.length > 0) { + const oneLine = v.replace(/\s+/g, ' ').trim(); + return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine; + } + } + return undefined; +} + +/** Log one JSON line per scanned message. Set `CONTROL_PANEL_LOG_GROUP_MESSAGES=0` to disable. */ +const LOG_EACH_MESSAGE = process.env.CONTROL_PANEL_LOG_GROUP_MESSAGES !== '0'; + +export const GET: RequestHandler = async ({ params, url }) => { + const evaultId = params.evaultId; + + try { + const allVaults = await registryService.getEVaults(); + const registryEnameLookup = buildRegistryEnameLookup(allVaults); + + const vault = allVaults.find((v) => v.evault === evaultId || v.ename === evaultId); + + if (!vault) { + return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 }); + } + + const platform = PUBLIC_CONTROL_PANEL_URL || url.origin; + let token: string | undefined; + try { + token = await requestPlatformToken(platform); + } catch (tokenError) { + console.warn('Group insights: no platform token:', tokenError); + } + + let registryRows: RegistryEvaultRow[] = []; + try { + registryRows = await fetchRegistryEvaultRows(token); + } catch (rowsErr) { + console.warn('Group insights: fetchRegistryEvaultRows failed:', rowsErr); + } + const dashboardNameByKey = buildDashboardNameByKey(registryRows); + const hintByBucket = new Map>(); + + const manifest = await fetchGroupManifestOrFallbackParsed(vault, token); + if (!manifest) { + return json( + { + error: + 'Could not load group manifest from this vault (not a group eVault, unreachable, or auth failed).' + }, + { status: 400 } + ); + } + + const byBucket: Record = {}; + let messagesScanned = 0; + let totalCount = 0; + let after: string | undefined; + let capped = false; + + for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex++) { + const variables: Record = { + filter: { ontologyId: MESSAGE_ONTOLOGY_ID }, + first: PAGE_SIZE + }; + if (after) { + variables.after = after; + } + + const payload = await evaultGraphqlPost({ + uri: vault.uri, + ename: vault.ename, + query: MESSAGES_PAGE_QUERY, + variables, + token, + timeoutMs: 15000 + }); + + const conn = readMessageConnection(payload); + if (!conn) { + break; + } + + if (pageIndex === 0 && typeof conn.totalCount === 'number') { + totalCount = conn.totalCount; + } + + const edges = conn.edges ?? []; + for (const edge of edges) { + const parsed = edge.node?.parsed as Record | undefined; + const senderEname = resolveMessageSenderEname(parsed, registryEnameLookup); + const rawSenderId = rawMessageSenderId(parsed); + const bucket = senderBucketKey(parsed, registryEnameLookup); + byBucket[bucket] = (byBucket[bucket] ?? 0) + 1; + messagesScanned += 1; + recordDisplayHint(hintByBucket, bucket, displayHintFromMessage(parsed)); + + if (LOG_EACH_MESSAGE) { + console.log( + '[group-insights:message]', + JSON.stringify({ + evaultId, + vaultEname: vault.ename, + index: messagesScanned, + senderBucket: bucket, + senderEname: senderEname ?? null, + senderId: rawSenderId ?? null, + id: parsed?.id ?? null, + chatId: parsed?.chatId ?? null, + isSystemMessage: parsed?.isSystemMessage ?? null, + preview: previewMessageBody(parsed), + parsedKeys: parsed && typeof parsed === 'object' ? Object.keys(parsed).sort() : [] + }) + ); + } + } + + const hasNext = conn.pageInfo?.hasNextPage === true; + const endCursor = conn.pageInfo?.endCursor; + if (!hasNext || edges.length === 0) { + break; + } + if (pageIndex === MAX_PAGES - 1) { + capped = true; + break; + } + after = endCursor ?? undefined; + } + + const messagesWithoutSender = byBucket[NO_SENDER_BUCKET] ?? 0; + const messagesWithSenderBucket = Math.max(0, messagesScanned - messagesWithoutSender); + const userRowsForProbe = userRegistryRowsForProbe(registryRows); + const probeRequestCache = new Map(); + const senderRows = await buildSenderRows( + byBucket, + registryEnameLookup, + allVaults, + token, + dashboardNameByKey, + hintByBucket, + userRowsForProbe, + probeRequestCache + ); + + console.log('[group-insights:summary]', { + evaultId, + vaultEname: vault.ename, + totalCount, + messagesScanned, + messagesWithSenderBucket, + messagesWithoutSender, + senderRowCount: senderRows.length, + capped, + byBucket, + perMessageLinesLogged: LOG_EACH_MESSAGE + }); + + return json({ + evault: { + ename: vault.ename, + uri: vault.uri, + evault: vault.evault + }, + manifest, + messageStats: { + totalCount, + messagesScanned, + messagesWithSenderBucket, + messagesWithoutSender, + capped, + senderRows + } + }); + } catch (error) { + console.error('Error fetching group insights:', error); + return json({ error: 'Failed to fetch group insights' }, { status: 500 }); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts new file mode 100644 index 000000000..f2e4c9bce --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public'; +import { fetchRegistryEvaultRows, requestPlatformToken } from '$lib/server/evault-graphql'; +import type { EVault } from '../+server'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const platform = PUBLIC_CONTROL_PANEL_URL || url.origin; + let token: string | undefined; + try { + token = await requestPlatformToken(platform); + } catch (tokenError) { + console.warn('Falling back to X-ENAME-only eVault queries:', tokenError); + } + + const rows = await fetchRegistryEvaultRows(token); + const evaults: EVault[] = rows.filter((v) => v.type === 'group'); + + return json({ evaults }); + } catch (error) { + console.error('Error fetching group eVaults:', error); + return json({ error: 'Failed to fetch group eVaults', evaults: [] }, { status: 500 }); + } +}; diff --git a/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte b/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte index 372b688d7..8c8358834 100644 --- a/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte +++ b/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte @@ -1,4 +1,5 @@ + + + Group eVaults — Control Panel + + +
+

Group eVaults

+

+ Registry entries with a readable group manifest. Open a row for members and message stats. +

+
+ + + 0 ? `${filtered().length} group(s)` : ''} + /> + {#if isLoading} +
+
+
+ {:else if error} +
+ {error} + +
+ {:else if groups.length === 0} +
No group eVaults found.
+ {:else} + +
+ + Showing {(currentPage - 1) * itemsPerPage + 1} – + {Math.min(currentPage * itemsPerPage, filtered().length)} of {filtered().length} + +
+ + Page {currentPage} / {totalPages} + +
+
+ {/if} + + +
+ +
diff --git a/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte b/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte new file mode 100644 index 000000000..3e34ace26 --- /dev/null +++ b/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte @@ -0,0 +1,166 @@ + + + + Group {insights?.manifest?.name ?? evaultId} — Control Panel + + +
+ + {#if isLoading} +
+
+
+ {:else if error} +
+

Could not load this group

+

{error}

+ +
+ {:else if insights} + {@const m = insights.manifest} + {@const stats = insights.messageStats} + {@const withSender = stats.messagesWithSenderBucket} + {@const withoutSender = stats.messagesWithoutSender} +

+ {asString(m.name) || insights.evault.ename || evaultId} +

+

{insights.evault.ename}

+ {#if asString(m.description)} +

{asString(m.description)}

+ {/if} + +
+
+

Vault

+
+
+
eVault ID
+
{insights.evault.evault || '—'}
+
+
+
URI
+
+ {insights.evault.uri} +
+
+
+ + Open pod logs & health → + +
+ +
+

Messages

+

+ {stats.totalCount ?? stats.messagesScanned} + total in vault +

+

+ Scanned {stats.messagesScanned} message envelope(s):{' '} + {withSender} + with a sender id or resolvable ename (bucketed in the table + below), + {withoutSender} + with no sender in the payload (system / null). +

+ {#if stats.capped} +

+ Counts may be incomplete: scanning stopped at the safety limit ({stats.messagesScanned} + messages). +

+ {/if} +
+
+ +
+
+

Messages by sender

+

+ Real name comes from each sender’s user vault (same fields as the dashboard). eName is the + W3ID when we can match the registry; otherwise the raw sender id from the message. +

+
+
+ {#if stats.senderRows?.length} +
+ + + + + + + + + {#each stats.senderRows as row} + + + + + + {/each} + +
Real nameeNameCount
{row.displayName}{row.ename}{row.messageCount}
+ {:else} +

No message rows.

+ {/if} + + + {/if} + From d93813b7332c2fc6176d394e5b0e2f9149359991 Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Tue, 24 Mar 2026 12:05:16 +0530 Subject: [PATCH 2/2] feat: add charts --- infrastructure/control-panel/package.json | 3 + .../charts/GroupContributionDonut.svelte | 154 +++++ .../components/charts/GroupDonutArcs.svelte | 53 ++ .../src/lib/server/group-message-buckets.ts | 237 ++++++++ .../lib/server/group-sender-resolve.server.ts | 346 ++++++++++++ .../src/lib/services/evaultService.ts | 40 ++ .../[evaultId]/group-insights/+server.ts | 534 ++---------------- .../[evaultId]/group-messages/+server.ts | 214 +++++++ .../src/routes/groups/[evaultId]/+page.svelte | 35 +- .../groups/[evaultId]/messages/+page.svelte | 164 ++++++ pnpm-lock.yaml | 51 +- 11 files changed, 1312 insertions(+), 519 deletions(-) create mode 100644 infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte create mode 100644 infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte create mode 100644 infrastructure/control-panel/src/lib/server/group-message-buckets.ts create mode 100644 infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts create mode 100644 infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts create mode 100644 infrastructure/control-panel/src/routes/groups/[evaultId]/messages/+page.svelte diff --git a/infrastructure/control-panel/package.json b/infrastructure/control-panel/package.json index e38357874..3813c274b 100644 --- a/infrastructure/control-panel/package.json +++ b/infrastructure/control-panel/package.json @@ -24,6 +24,7 @@ "@sveltejs/kit": "^2.22.0", "@sveltejs/vite-plugin-svelte": "^6.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/d3-shape": "^3.1.8", "@types/node": "^22", "@types/qrcode": "^1.5.6", "eslint": "^9.18.0", @@ -48,10 +49,12 @@ "@inlang/paraglide-js": "^2.0.0", "@xyflow/svelte": "^1.2.2", "clsx": "^2.1.1", + "d3-shape": "^3.2.0", "flowbite": "^3.1.2", "flowbite-svelte": "^1.10.7", "flowbite-svelte-icons": "^2.2.1", "jose": "^6.2.0", + "layercake": "^10.0.2", "lowdb": "^7.0.1", "lucide-svelte": "^0.561.0", "qrcode": "^1.5.4", diff --git a/infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte b/infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte new file mode 100644 index 000000000..ee1894220 --- /dev/null +++ b/infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte @@ -0,0 +1,154 @@ + + +
+

Contribution by sender

+

+ Share of scanned messages per sender (same counts as the table below) +

+ + {#if senderRows.some((r) => isSystemNoSenderRow(r) && r.messageCount > 0)} + + {/if} + + {#if slices.length === 0} +

No sender data to chart.

+ {:else} +
+
+ s.id)} + yDomain={[0, Math.max(...slices.map((s) => s.value), 1)]} + padding={{ top: 8, right: 8, bottom: 8, left: 8 }} + > + + + + +
+ +
    + {#each slices as s (s.id)} + {@const pct = + totalMessages > 0 ? Math.round((s.value / totalMessages) * 1000) / 10 : 0} +
  • + +
    +
    + {#if s.evaultPageId} + {s.label} + {:else} + {s.label} + {/if} +
    +
    {s.sub}
    +
    +
    + {s.value} + ({pct}%) +
    +
  • + {/each} +
+
+ {/if} +
+ + diff --git a/infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte b/infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte new file mode 100644 index 000000000..4acd24c4d --- /dev/null +++ b/infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte @@ -0,0 +1,53 @@ + + + + {#each pieLayout($data ?? []) as a (a.data.id)} + {@const outerR = Math.min($width, $height) / 2 - 6} + {@const innerR = outerR * 0.56} + {@const d = arc>() + .innerRadius(innerR) + .outerRadius(outerR) + .cornerRadius(1.5)(a)} + {#if d} + + + {a.data.label} — {a.data.sub}: {a.data.value} messages (open list) + + + {/if} + {/each} + diff --git a/infrastructure/control-panel/src/lib/server/group-message-buckets.ts b/infrastructure/control-panel/src/lib/server/group-message-buckets.ts new file mode 100644 index 000000000..fe11329d9 --- /dev/null +++ b/infrastructure/control-panel/src/lib/server/group-message-buckets.ts @@ -0,0 +1,237 @@ +import type { RegistryVault } from '$lib/services/registry'; + +/** Literal bucket key for messages with no sender id (must match group-insights aggregation). */ +export const NO_SENDER_BUCKET = '__no_sender__'; + +/** Match dashboard / monitoring: ignore leading @ and case when comparing W3IDs. */ +export function normalizeW3id(s: string): string { + return s.trim().replace(/^@+/u, '').toLowerCase(); +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isLikelyUuid(s: string): boolean { + return UUID_RE.test(s.trim()); +} + +/** + * Web3-adapter stores relations as `user(firebaseOrGlobalId)` in some paths; registry keys are often the inner id. + * Peel wrappers so sender buckets line up with registry `evault` / mapping global ids. + */ +export function unwrapRelationIdDeep(raw: string | null | undefined): string | null { + if (raw == null || typeof raw !== 'string') { + return null; + } + let t = raw.trim(); + if (!t) { + return null; + } + for (let i = 0; i < 4; i++) { + const m = /^(\w+)\(([^)]+)\)$/.exec(t); + if (!m) { + break; + } + const inner = m[2].trim(); + if (!inner || inner === t) { + break; + } + t = inner; + } + return t || null; +} + +/** Build evault id / raw ename → canonical registry `ename` for resolving message senders. */ +export function buildRegistryEnameLookup(vaults: RegistryVault[]): Map { + const map = new Map(); + for (const v of vaults) { + const en = typeof v.ename === 'string' && v.ename.trim() ? v.ename.trim() : ''; + if (!en) { + continue; + } + map.set(en, en); + map.set(en.toLowerCase(), en); + const nw = normalizeW3id(en); + if (nw) { + map.set(nw, en); + } + if (v.evault) { + map.set(v.evault, en); + map.set(v.evault.toLowerCase(), en); + } + } + return map; +} + +function firstNonEmptyStringField( + parsed: Record, + keys: string[] +): string | null { + for (const k of keys) { + const v = parsed[k]; + if (typeof v === 'string' && v.trim().length > 0) { + return v.trim(); + } + } + return null; +} + +/** Raw sender id from payload (UUID / legacy), not used as the aggregation key when registry resolves. */ +export function rawMessageSenderId(parsed: Record | null | undefined): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + for (const v of [parsed.senderId, parsed.sender_id, parsed.userId]) { + if (typeof v === 'string' && v.trim().length > 0) { + return unwrapRelationIdDeep(v.trim()); + } + if (typeof v === 'number' && Number.isFinite(v)) { + return String(v); + } + } + return null; +} + +/** + * Bucket key for counts: W3ID eName when possible (manifest + UI use @…). + * 1) eName fields on the message payload + * 2) registry lookup: senderId / userId as evault id + */ +export function resolveMessageSenderEname( + parsed: Record | null | undefined, + registryLookup: Map +): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + const fromPayload = firstNonEmptyStringField(parsed, [ + 'senderEname', + 'sender_ename', + 'senderEName', + 'eName', + 'ename', + 'senderW3id', + 'w3id' + ]); + if (fromPayload) { + return ( + registryLookup.get(fromPayload) ?? + registryLookup.get(fromPayload.toLowerCase()) ?? + registryLookup.get(normalizeW3id(fromPayload)) ?? + fromPayload + ); + } + const rawId = rawMessageSenderId(parsed); + if (!rawId) { + return null; + } + return ( + registryLookup.get(rawId) ?? + registryLookup.get(rawId.toLowerCase()) ?? + registryLookup.get(normalizeW3id(rawId)) ?? + null + ); +} + +/** Stable aggregation key: canonical ename when resolvable, else raw sender id, else system bucket. */ +export function senderBucketKey( + parsed: Record | null | undefined, + registryLookup: Map +): string { + const ename = resolveMessageSenderEname(parsed, registryLookup); + if (ename) { + return ename; + } + const raw = rawMessageSenderId(parsed); + if (raw) { + return raw; + } + return NO_SENDER_BUCKET; +} + +export function previewMessageBody(parsed: Record | undefined): string | undefined { + if (!parsed) { + return undefined; + } + for (const key of ['text', 'content', 'body'] as const) { + const v = parsed[key]; + if (typeof v === 'string' && v.length > 0) { + const oneLine = v.replace(/\s+/g, ' ').trim(); + return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine; + } + } + return undefined; +} + +export function findSenderVaultForBucket( + bucketKey: string, + lookup: Map, + vaults: RegistryVault[] +): RegistryVault | null { + const trimmed = bucketKey.trim(); + const peeled = unwrapRelationIdDeep(trimmed); + if (peeled && peeled !== trimmed) { + const nested = findSenderVaultForBucket(peeled, lookup, vaults); + if (nested) { + return nested; + } + } + const lower = trimmed.toLowerCase(); + + const byEvault = vaults.find((v) => v.evault === trimmed); + if (byEvault) { + return byEvault; + } + const byEvaultCi = vaults.find((v) => (v.evault ?? '').toLowerCase() === lower); + if (byEvaultCi) { + return byEvaultCi; + } + + const canonFromLookup = + lookup.get(trimmed) ?? lookup.get(lower) ?? lookup.get(normalizeW3id(trimmed)); + if (canonFromLookup) { + const v = vaults.find( + (x) => + x.ename === canonFromLookup || + normalizeW3id(x.ename) === normalizeW3id(canonFromLookup) + ); + if (v) { + return v; + } + } + + const bucketNorm = normalizeW3id(trimmed); + if (bucketNorm) { + const byNorm = vaults.find((x) => normalizeW3id(x.ename) === bucketNorm); + if (byNorm) { + return byNorm; + } + } + + return ( + vaults.find((x) => x.ename === trimmed || x.ename.toLowerCase() === lower) ?? null + ); +} + +/** Segment for `/evaults/[id]` — matches client list `id === evault || ename`. */ +export function evaultPageIdForRow( + v: RegistryVault | null, + enameCol: string, + bucketKey: string +): string | null { + if (v) { + const link = (v.evault?.trim() || v.ename?.trim()) ?? ''; + if (link) { + return link; + } + } + const en = enameCol.trim(); + if (en && en !== '—' && !isLikelyUuid(en)) { + return en; + } + const bk = bucketKey.trim(); + if (bk && bk !== NO_SENDER_BUCKET && !isLikelyUuid(bk)) { + return bk; + } + return null; +} diff --git a/infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts b/infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts new file mode 100644 index 000000000..d4b17cf75 --- /dev/null +++ b/infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts @@ -0,0 +1,346 @@ +import { cacheService } from '$lib/services/cacheService'; +import { type RegistryVault } from '$lib/services/registry'; +import { + displayNameFromUserProfile, + fetchMetaEnvelopeById, + isUserOntologyId, + resolveVaultIdentity, + type RegistryEvaultRow +} from '$lib/server/evault-graphql'; +import { + evaultPageIdForRow, + findSenderVaultForBucket, + isLikelyUuid, + normalizeW3id, + NO_SENDER_BUCKET, + unwrapRelationIdDeep +} from '$lib/server/group-message-buckets'; + +const PROBE_VAULT_CONCURRENCY = 6; +const MAX_USER_VAULTS_TO_PROBE = 150; + +/** Same `name` field as the dashboard table (`fetchRegistryEvaultRows`), keyed every way we might see a sender id. */ +export function buildDashboardNameByKey(rows: RegistryEvaultRow[]): Map { + const m = new Map(); + for (const row of rows) { + const name = typeof row.name === 'string' ? row.name.trim() : ''; + if (!name) { + continue; + } + const add = (k: string | undefined | null) => { + if (k == null || typeof k !== 'string') { + return; + } + let t = k.trim(); + if (!t) { + return; + } + for (let depth = 0; depth < 6 && t; depth++) { + m.set(t, name); + m.set(t.toLowerCase(), name); + const nw = normalizeW3id(t); + if (nw) { + m.set(nw, name); + } + const inner = /^(\w+)\(([^)]+)\)$/.exec(t); + t = inner ? inner[2].trim() : ''; + } + }; + add(row.evault); + add(row.ename); + add(row.id); + } + return m; +} + +export function recordDisplayHint( + byBucket: Map>, + bucket: string, + hint: string | null +): void { + if (!hint?.trim()) { + return; + } + const h = hint.trim(); + if (h.length < 2 || isLikelyUuid(h)) { + return; + } + let counts = byBucket.get(bucket); + if (!counts) { + counts = new Map(); + byBucket.set(bucket, counts); + } + counts.set(h, (counts.get(h) ?? 0) + 1); +} + +function pickBestDisplayHint(counts: Map | undefined): string | null { + if (!counts?.size) { + return null; + } + let best: string | null = null; + let bestN = 0; + for (const [k, n] of counts) { + if (n > bestN) { + bestN = n; + best = k; + } + } + return best; +} + +function collectLookupKeys(...parts: (string | null | undefined)[]): string[] { + const keys: string[] = []; + const seen = new Set(); + const addChain = (seed: string | null | undefined) => { + if (!seed?.trim()) { + return; + } + let t = seed.trim(); + for (let depth = 0; depth < 6 && t; depth++) { + if (!seen.has(t)) { + seen.add(t); + keys.push(t); + } + const tl = t.toLowerCase(); + if (!seen.has(tl)) { + seen.add(tl); + keys.push(tl); + } + const nw = normalizeW3id(t); + if (nw && !seen.has(nw)) { + seen.add(nw); + keys.push(nw); + } + const inner = /^(\w+)\(([^)]+)\)$/.exec(t); + t = inner ? inner[2].trim() : ''; + } + }; + for (const p of parts) { + addChain(p); + } + return keys; +} + +export function userRegistryRowsForProbe(rows: RegistryEvaultRow[]): RegistryEvaultRow[] { + return rows.filter( + (r) => + r.type === 'user' && + !(typeof r.name === 'string' && /platform$/i.test(r.name.trim())) + ); +} + +function registryRowToVault(row: RegistryEvaultRow): RegistryVault { + return { ename: row.ename, uri: row.uri, evault: row.evault }; +} + +async function probeUserVaultsForSenderMetaEnvelopeId( + metaEnvelopeId: string, + userRows: RegistryEvaultRow[], + token: string | undefined, + probeRequestCache: Map +): Promise { + if (!isLikelyUuid(metaEnvelopeId)) { + return null; + } + const memo = probeRequestCache.get(metaEnvelopeId); + if (memo !== undefined) { + return memo; + } + + const ttlHit = cacheService.getCachedSenderProfileDisplayName(metaEnvelopeId); + if (ttlHit !== undefined) { + probeRequestCache.set(metaEnvelopeId, ttlHit); + return ttlHit; + } + + const limited = userRows.slice(0, MAX_USER_VAULTS_TO_PROBE); + if (userRows.length > MAX_USER_VAULTS_TO_PROBE) { + console.warn( + '[group-sender-resolve] sender profile probe capped at', + MAX_USER_VAULTS_TO_PROBE, + 'user vaults (registry has', + userRows.length, + ')' + ); + } + + for (let i = 0; i < limited.length; i += PROBE_VAULT_CONCURRENCY) { + const chunk = limited.slice(i, i + PROBE_VAULT_CONCURRENCY); + const names = await Promise.all( + chunk.map(async (row) => { + const vault = registryRowToVault(row); + const me = await fetchMetaEnvelopeById(vault, metaEnvelopeId, token); + if (!me || !isUserOntologyId(me.ontology)) { + return null; + } + return displayNameFromUserProfile(me.parsed, row.ename); + }) + ); + for (const name of names) { + if (name) { + cacheService.setCachedSenderProfileDisplayName(metaEnvelopeId, name); + probeRequestCache.set(metaEnvelopeId, name); + return name; + } + } + } + + probeRequestCache.set(metaEnvelopeId, null); + return null; +} + +async function resolveSenderDisplayName( + bucketKey: string, + v: RegistryVault | null, + enameCol: string, + token: string | undefined, + dashboardNameByKey: Map, + hintByBucket: Map>, + userRowsForProbe: RegistryEvaultRow[], + probeRequestCache: Map +): Promise { + for (const k of collectLookupKeys(bucketKey, enameCol)) { + const hit = dashboardNameByKey.get(k); + if (hit) { + return hit; + } + } + if (v) { + const identity = await resolveVaultIdentity(v, token); + return identity.name; + } + const hint = pickBestDisplayHint(hintByBucket.get(bucketKey)); + if (hint) { + return hint; + } + if (enameCol && enameCol !== bucketKey && !isLikelyUuid(enameCol)) { + return enameCol; + } + const trimmedBucket = bucketKey.trim(); + if (isLikelyUuid(trimmedBucket)) { + const probed = await probeUserVaultsForSenderMetaEnvelopeId( + trimmedBucket, + userRowsForProbe, + token, + probeRequestCache + ); + if (probed) { + return probed; + } + } + return bucketKey; +} + +function firstNonEmptyStringField( + parsed: Record, + keys: string[] +): string | null { + for (const k of keys) { + const v = parsed[k]; + if (typeof v === 'string' && v.trim().length > 0) { + return v.trim(); + } + } + return null; +} + +export function displayHintFromMessage(parsed: Record | null | undefined): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + const top = firstNonEmptyStringField(parsed, [ + 'senderName', + 'sender_display_name', + 'senderDisplayName', + 'authorName', + 'authorDisplayName', + 'fromUserName', + 'fromDisplayName' + ]); + if (top && !isLikelyUuid(top)) { + return top; + } + const sender = parsed.sender; + if (sender && typeof sender === 'object' && !Array.isArray(sender)) { + const s = sender as Record; + const nested = firstNonEmptyStringField(s, [ + 'displayName', + 'display_name', + 'name', + 'username', + 'ename', + 'eName' + ]); + if (nested && !isLikelyUuid(nested)) { + return nested; + } + } + return null; +} + +export type ResolveSenderRowFieldsArgs = { + bucketKey: string; + allVaults: RegistryVault[]; + registryEnameLookup: Map; + token: string | undefined; + dashboardNameByKey: Map; + hintByBucket: Map>; + userRowsForProbe: RegistryEvaultRow[]; + probeRequestCache: Map; +}; + +/** One sender row’s display fields (same rules as the group insights table). */ +export async function resolveSenderRowFields( + args: ResolveSenderRowFieldsArgs +): Promise<{ displayName: string; ename: string; evaultPageId: string | null }> { + const { + bucketKey, + allVaults, + registryEnameLookup, + token, + dashboardNameByKey, + hintByBucket, + userRowsForProbe, + probeRequestCache + } = args; + + if (bucketKey === NO_SENDER_BUCKET) { + return { + displayName: 'System / no sender', + ename: '—', + evaultPageId: null + }; + } + + const v = findSenderVaultForBucket(bucketKey, registryEnameLookup, allVaults); + const peeledBucket = unwrapRelationIdDeep(bucketKey.trim()); + const enameFromPeel = peeledBucket + ? registryEnameLookup.get(peeledBucket) ?? + registryEnameLookup.get(peeledBucket.toLowerCase()) ?? + registryEnameLookup.get(normalizeW3id(peeledBucket)) + : undefined; + const enameCol = + v?.ename?.trim() ?? + registryEnameLookup.get(bucketKey.trim()) ?? + registryEnameLookup.get(bucketKey.trim().toLowerCase()) ?? + registryEnameLookup.get(normalizeW3id(bucketKey)) ?? + enameFromPeel ?? + bucketKey; + + const displayName = await resolveSenderDisplayName( + bucketKey, + v, + enameCol, + token, + dashboardNameByKey, + hintByBucket, + userRowsForProbe, + probeRequestCache + ); + + return { + displayName, + ename: enameCol, + evaultPageId: evaultPageIdForRow(v, enameCol, bucketKey) + }; +} diff --git a/infrastructure/control-panel/src/lib/services/evaultService.ts b/infrastructure/control-panel/src/lib/services/evaultService.ts index 315ceeca8..5e07c3a15 100644 --- a/infrastructure/control-panel/src/lib/services/evaultService.ts +++ b/infrastructure/control-panel/src/lib/services/evaultService.ts @@ -1,12 +1,33 @@ import type { EVault } from '../../routes/api/evaults/+server'; import { cacheService } from './cacheService'; +/** Must match `NO_SENDER_BUCKET` in `$lib/server/group-message-buckets`. */ +export const GROUP_NO_SENDER_BUCKET_KEY = '__no_sender__'; + export type GroupSenderRow = { /** User ontology display name / username, same idea as the dashboard. */ displayName: string; /** W3ID ename when known, raw sender id when not in registry, or "—" for system/no sender. */ ename: string; messageCount: number; + /** Aggregation key; pass as `bucket` to group messages API / messages page. */ + bucketKey: string; + /** Target for `/evaults/[id]` when we can resolve a registry vault or non-UUID handle. */ + evaultPageId: string | null; +}; + +export type GroupMessagesForSenderResponse = { + evault: { ename: string; uri: string; evault: string }; + /** Group manifest display name (fallback: vault eName / route id). */ + groupDisplayName: string; + senderDisplayName: string; + senderEname: string; + bucket: string; + messages: Array<{ id: string | null; preview: string | undefined; senderBucket: string }>; + matchedCount: number; + messagesScanned: number; + totalCount: number; + capped: boolean; }; export type GroupInsights = { @@ -170,6 +191,25 @@ export class EVaultService { } } + static async getGroupMessagesForSender( + evaultId: string, + bucketKey: string + ): Promise { + const url = new URL( + `/api/evaults/${encodeURIComponent(evaultId)}/group-messages`, + window.location.origin + ); + url.searchParams.set('bucket', bucketKey); + const response = await fetch(url.toString()); + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + const message = + typeof errBody?.error === 'string' ? errBody.error : `HTTP ${response.status}`; + throw new Error(message); + } + return (await response.json()) as GroupMessagesForSenderResponse; + } + /** * Get logs for a specific eVault by namespace and podName */ diff --git a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts index 6c574b215..8490d4292 100644 --- a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts +++ b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts @@ -1,28 +1,34 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public'; -import { cacheService } from '$lib/services/cacheService'; import { registryService, type RegistryVault } from '$lib/services/registry'; import { - displayNameFromUserProfile, evaultGraphqlPost, fetchGroupManifestOrFallbackParsed, - fetchMetaEnvelopeById, fetchRegistryEvaultRows, - isUserOntologyId, MESSAGE_ONTOLOGY_ID, requestPlatformToken, - resolveVaultIdentity, type RegistryEvaultRow } from '$lib/server/evault-graphql'; +import { + buildRegistryEnameLookup, + NO_SENDER_BUCKET, + previewMessageBody, + rawMessageSenderId, + resolveMessageSenderEname, + senderBucketKey +} from '$lib/server/group-message-buckets'; +import { + buildDashboardNameByKey, + displayHintFromMessage, + recordDisplayHint, + resolveSenderRowFields, + userRegistryRowsForProbe +} from '$lib/server/group-sender-resolve.server'; /** - * Strategy A: resolve message `senderId` (User MetaEnvelope global id) by probing registry user vaults with - * `metaEnvelope(id)` + X-ENAME. Strategy C (long-term): denormalize senderEname/display on write in - * web3-adapter / Blabsy message mapping — avoids O(senders × vaults) GraphQL. + * Sender display resolution lives in `$lib/server/group-sender-resolve.server.ts` (probe user vaults, dashboard names). */ -const PROBE_VAULT_CONCURRENCY = 6; -const MAX_USER_VAULTS_TO_PROBE = 150; const MESSAGES_PAGE_QUERY = ` query GroupMessages($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) { @@ -59,457 +65,13 @@ function readMessageConnection(payload: Record | null): Message return conn ?? null; } -/** Match dashboard / monitoring: ignore leading @ and case when comparing W3IDs. */ -function normalizeW3id(s: string): string { - return s.trim().replace(/^@+/u, '').toLowerCase(); -} - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -function isLikelyUuid(s: string): boolean { - return UUID_RE.test(s.trim()); -} - -/** - * Web3-adapter stores relations as `user(firebaseOrGlobalId)` in some paths; registry keys are often the inner id. - * Peel wrappers so sender buckets line up with registry `evault` / mapping global ids. - */ -function unwrapRelationIdDeep(raw: string | null | undefined): string | null { - if (raw == null || typeof raw !== 'string') { - return null; - } - let t = raw.trim(); - if (!t) { - return null; - } - for (let i = 0; i < 4; i++) { - const m = /^(\w+)\(([^)]+)\)$/.exec(t); - if (!m) { - break; - } - const inner = m[2].trim(); - if (!inner || inner === t) { - break; - } - t = inner; - } - return t || null; -} - -/** Same `name` field as the dashboard table (`fetchRegistryEvaultRows`), keyed every way we might see a sender id. */ -function buildDashboardNameByKey(rows: RegistryEvaultRow[]): Map { - const m = new Map(); - for (const row of rows) { - const name = typeof row.name === 'string' ? row.name.trim() : ''; - if (!name) { - continue; - } - const add = (k: string | undefined | null) => { - if (k == null || typeof k !== 'string') { - return; - } - let t = k.trim(); - if (!t) { - return; - } - for (let depth = 0; depth < 6 && t; depth++) { - m.set(t, name); - m.set(t.toLowerCase(), name); - const nw = normalizeW3id(t); - if (nw) { - m.set(nw, name); - } - const inner = /^(\w+)\(([^)]+)\)$/.exec(t); - t = inner ? inner[2].trim() : ''; - } - }; - add(row.evault); - add(row.ename); - add(row.id); - } - return m; -} - -function recordDisplayHint( - byBucket: Map>, - bucket: string, - hint: string | null -): void { - if (!hint?.trim()) { - return; - } - const h = hint.trim(); - if (h.length < 2 || isLikelyUuid(h)) { - return; - } - let counts = byBucket.get(bucket); - if (!counts) { - counts = new Map(); - byBucket.set(bucket, counts); - } - counts.set(h, (counts.get(h) ?? 0) + 1); -} - -function pickBestDisplayHint(counts: Map | undefined): string | null { - if (!counts?.size) { - return null; - } - let best: string | null = null; - let bestN = 0; - for (const [k, n] of counts) { - if (n > bestN) { - bestN = n; - best = k; - } - } - return best; -} - -function collectLookupKeys(...parts: (string | null | undefined)[]): string[] { - const keys: string[] = []; - const seen = new Set(); - const addChain = (seed: string | null | undefined) => { - if (!seed?.trim()) { - return; - } - let t = seed.trim(); - for (let depth = 0; depth < 6 && t; depth++) { - if (!seen.has(t)) { - seen.add(t); - keys.push(t); - } - const tl = t.toLowerCase(); - if (!seen.has(tl)) { - seen.add(tl); - keys.push(tl); - } - const nw = normalizeW3id(t); - if (nw && !seen.has(nw)) { - seen.add(nw); - keys.push(nw); - } - const inner = /^(\w+)\(([^)]+)\)$/.exec(t); - t = inner ? inner[2].trim() : ''; - } - }; - for (const p of parts) { - addChain(p); - } - return keys; -} - -function userRegistryRowsForProbe(rows: RegistryEvaultRow[]): RegistryEvaultRow[] { - return rows.filter( - (r) => - r.type === 'user' && - !(typeof r.name === 'string' && /platform$/i.test(r.name.trim())) - ); -} - -function registryRowToVault(row: RegistryEvaultRow): RegistryVault { - return { ename: row.ename, uri: row.uri, evault: row.evault }; -} - -/** - * `metaEnvelopeId` is the web3-adapter global id stored on messages (User profile row id in that user's vault). - * Values are `string` when resolved, `null` when this request already probed all vaults without a hit. - */ -async function probeUserVaultsForSenderMetaEnvelopeId( - metaEnvelopeId: string, - userRows: RegistryEvaultRow[], - token: string | undefined, - probeRequestCache: Map -): Promise { - if (!isLikelyUuid(metaEnvelopeId)) { - return null; - } - const memo = probeRequestCache.get(metaEnvelopeId); - if (memo !== undefined) { - return memo; - } - - const ttlHit = cacheService.getCachedSenderProfileDisplayName(metaEnvelopeId); - if (ttlHit !== undefined) { - probeRequestCache.set(metaEnvelopeId, ttlHit); - return ttlHit; - } - - const limited = userRows.slice(0, MAX_USER_VAULTS_TO_PROBE); - if (userRows.length > MAX_USER_VAULTS_TO_PROBE) { - console.warn( - '[group-insights] sender profile probe capped at', - MAX_USER_VAULTS_TO_PROBE, - 'user vaults (registry has', - userRows.length, - ')' - ); - } - - for (let i = 0; i < limited.length; i += PROBE_VAULT_CONCURRENCY) { - const chunk = limited.slice(i, i + PROBE_VAULT_CONCURRENCY); - const names = await Promise.all( - chunk.map(async (row) => { - const vault = registryRowToVault(row); - const me = await fetchMetaEnvelopeById(vault, metaEnvelopeId, token); - if (!me || !isUserOntologyId(me.ontology)) { - return null; - } - return displayNameFromUserProfile(me.parsed, row.ename); - }) - ); - for (const name of names) { - if (name) { - cacheService.setCachedSenderProfileDisplayName(metaEnvelopeId, name); - probeRequestCache.set(metaEnvelopeId, name); - return name; - } - } - } - - probeRequestCache.set(metaEnvelopeId, null); - return null; -} - -async function resolveSenderDisplayName( - bucketKey: string, - v: RegistryVault | null, - enameCol: string, - token: string | undefined, - dashboardNameByKey: Map, - hintByBucket: Map>, - userRowsForProbe: RegistryEvaultRow[], - probeRequestCache: Map -): Promise { - for (const k of collectLookupKeys(bucketKey, enameCol)) { - const hit = dashboardNameByKey.get(k); - if (hit) { - return hit; - } - } - if (v) { - const identity = await resolveVaultIdentity(v, token); - return identity.name; - } - const hint = pickBestDisplayHint(hintByBucket.get(bucketKey)); - if (hint) { - return hint; - } - if (enameCol && enameCol !== bucketKey && !isLikelyUuid(enameCol)) { - return enameCol; - } - const trimmedBucket = bucketKey.trim(); - if (isLikelyUuid(trimmedBucket)) { - const probed = await probeUserVaultsForSenderMetaEnvelopeId( - trimmedBucket, - userRowsForProbe, - token, - probeRequestCache - ); - if (probed) { - return probed; - } - } - return bucketKey; -} - -/** Build evault id / raw ename → canonical registry `ename` for resolving message senders. */ -function buildRegistryEnameLookup(vaults: RegistryVault[]): Map { - const map = new Map(); - for (const v of vaults) { - const en = typeof v.ename === 'string' && v.ename.trim() ? v.ename.trim() : ''; - if (!en) { - continue; - } - map.set(en, en); - map.set(en.toLowerCase(), en); - const nw = normalizeW3id(en); - if (nw) { - map.set(nw, en); - } - if (v.evault) { - map.set(v.evault, en); - map.set(v.evault.toLowerCase(), en); - } - } - return map; -} - -function firstNonEmptyStringField( - parsed: Record, - keys: string[] -): string | null { - for (const k of keys) { - const v = parsed[k]; - if (typeof v === 'string' && v.trim().length > 0) { - return v.trim(); - } - } - return null; -} - -/** Raw sender id from payload (UUID / legacy), not used as the aggregation key when registry resolves. */ -function rawMessageSenderId(parsed: Record | null | undefined): string | null { - if (!parsed || typeof parsed !== 'object') { - return null; - } - for (const v of [parsed.senderId, parsed.sender_id, parsed.userId]) { - if (typeof v === 'string' && v.trim().length > 0) { - return unwrapRelationIdDeep(v.trim()); - } - if (typeof v === 'number' && Number.isFinite(v)) { - return String(v); - } - } - return null; -} - -function displayHintFromMessage(parsed: Record | null | undefined): string | null { - if (!parsed || typeof parsed !== 'object') { - return null; - } - const top = firstNonEmptyStringField(parsed, [ - 'senderName', - 'sender_display_name', - 'senderDisplayName', - 'authorName', - 'authorDisplayName', - 'fromUserName', - 'fromDisplayName' - ]); - if (top && !isLikelyUuid(top)) { - return top; - } - const sender = parsed.sender; - if (sender && typeof sender === 'object' && !Array.isArray(sender)) { - const s = sender as Record; - const nested = firstNonEmptyStringField(s, [ - 'displayName', - 'display_name', - 'name', - 'username', - 'ename', - 'eName' - ]); - if (nested && !isLikelyUuid(nested)) { - return nested; - } - } - return null; -} - -/** - * Bucket key for counts: W3ID eName when possible (manifest + UI use @…). - * 1) eName fields on the message payload - * 2) registry lookup: senderId / userId as evault id - */ -function resolveMessageSenderEname( - parsed: Record | null | undefined, - registryLookup: Map -): string | null { - if (!parsed || typeof parsed !== 'object') { - return null; - } - const fromPayload = firstNonEmptyStringField(parsed, [ - 'senderEname', - 'sender_ename', - 'senderEName', - 'eName', - 'ename', - 'senderW3id', - 'w3id' - ]); - if (fromPayload) { - return ( - registryLookup.get(fromPayload) ?? - registryLookup.get(fromPayload.toLowerCase()) ?? - registryLookup.get(normalizeW3id(fromPayload)) ?? - fromPayload - ); - } - const rawId = rawMessageSenderId(parsed); - if (!rawId) { - return null; - } - return ( - registryLookup.get(rawId) ?? - registryLookup.get(rawId.toLowerCase()) ?? - registryLookup.get(normalizeW3id(rawId)) ?? - null - ); -} - -const NO_SENDER_BUCKET = '__no_sender__'; - -/** Stable aggregation key: canonical ename when resolvable, else raw sender id, else system bucket. */ -function senderBucketKey( - parsed: Record | null | undefined, - registryLookup: Map -): string { - const ename = resolveMessageSenderEname(parsed, registryLookup); - if (ename) { - return ename; - } - const raw = rawMessageSenderId(parsed); - if (raw) { - return raw; - } - return NO_SENDER_BUCKET; -} - -function findSenderVaultForBucket( - bucketKey: string, - lookup: Map, - vaults: RegistryVault[] -): RegistryVault | null { - const trimmed = bucketKey.trim(); - const peeled = unwrapRelationIdDeep(trimmed); - if (peeled && peeled !== trimmed) { - const nested = findSenderVaultForBucket(peeled, lookup, vaults); - if (nested) { - return nested; - } - } - const lower = trimmed.toLowerCase(); - - const byEvault = vaults.find((v) => v.evault === trimmed); - if (byEvault) { - return byEvault; - } - const byEvaultCi = vaults.find((v) => (v.evault ?? '').toLowerCase() === lower); - if (byEvaultCi) { - return byEvaultCi; - } - - const canonFromLookup = - lookup.get(trimmed) ?? lookup.get(lower) ?? lookup.get(normalizeW3id(trimmed)); - if (canonFromLookup) { - const v = vaults.find( - (x) => - x.ename === canonFromLookup || - normalizeW3id(x.ename) === normalizeW3id(canonFromLookup) - ); - if (v) { - return v; - } - } - - const bucketNorm = normalizeW3id(trimmed); - if (bucketNorm) { - const byNorm = vaults.find((x) => normalizeW3id(x.ename) === bucketNorm); - if (byNorm) { - return byNorm; - } - } - - return ( - vaults.find( - (x) => x.ename === trimmed || x.ename.toLowerCase() === lower - ) ?? null - ); -} - -type SenderRow = { displayName: string; ename: string; messageCount: number }; +type SenderRow = { + displayName: string; + ename: string; + messageCount: number; + bucketKey: string; + evaultPageId: string | null; +}; async function mapInChunks(items: T[], chunkSize: number, fn: (item: T) => Promise): Promise { const out: R[] = []; @@ -532,33 +94,23 @@ async function buildSenderRows( ): Promise { const entries = Object.entries(byBucket).filter(([k]) => k !== NO_SENDER_BUCKET); const rows = await mapInChunks(entries, 8, async ([bucketKey, messageCount]) => { - const v = findSenderVaultForBucket(bucketKey, registryEnameLookup, allVaults); - const peeledBucket = unwrapRelationIdDeep(bucketKey.trim()); - const enameFromPeel = peeledBucket - ? registryEnameLookup.get(peeledBucket) ?? - registryEnameLookup.get(peeledBucket.toLowerCase()) ?? - registryEnameLookup.get(normalizeW3id(peeledBucket)) - : undefined; - const enameCol = - v?.ename?.trim() ?? - registryEnameLookup.get(bucketKey.trim()) ?? - registryEnameLookup.get(bucketKey.trim().toLowerCase()) ?? - registryEnameLookup.get(normalizeW3id(bucketKey)) ?? - enameFromPeel ?? - bucketKey; - - const displayName = await resolveSenderDisplayName( + const { displayName, ename, evaultPageId } = await resolveSenderRowFields({ bucketKey, - v, - enameCol, + allVaults, + registryEnameLookup, token, dashboardNameByKey, hintByBucket, userRowsForProbe, probeRequestCache - ); - - return { displayName, ename: enameCol, messageCount }; + }); + return { + displayName, + ename, + messageCount, + bucketKey, + evaultPageId + }; }); rows.sort((a, b) => b.messageCount - a.messageCount); @@ -568,27 +120,15 @@ async function buildSenderRows( rows.push({ displayName: 'System / no sender', ename: '—', - messageCount: noSenderCount + messageCount: noSenderCount, + bucketKey: NO_SENDER_BUCKET, + evaultPageId: null }); } return rows; } -function previewMessageBody(parsed: Record | undefined): string | undefined { - if (!parsed) { - return undefined; - } - for (const key of ['text', 'content', 'body'] as const) { - const v = parsed[key]; - if (typeof v === 'string' && v.length > 0) { - const oneLine = v.replace(/\s+/g, ' ').trim(); - return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine; - } - } - return undefined; -} - /** Log one JSON line per scanned message. Set `CONTROL_PANEL_LOG_GROUP_MESSAGES=0` to disable. */ const LOG_EACH_MESSAGE = process.env.CONTROL_PANEL_LOG_GROUP_MESSAGES !== '0'; diff --git a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts new file mode 100644 index 000000000..8c8abd027 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts @@ -0,0 +1,214 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public'; +import { registryService } from '$lib/services/registry'; +import { + evaultGraphqlPost, + fetchGroupManifestOrFallbackParsed, + fetchRegistryEvaultRows, + MESSAGE_ONTOLOGY_ID, + requestPlatformToken, + type RegistryEvaultRow +} from '$lib/server/evault-graphql'; +import { + buildDashboardNameByKey, + resolveSenderRowFields, + userRegistryRowsForProbe +} from '$lib/server/group-sender-resolve.server'; +import { + buildRegistryEnameLookup, + previewMessageBody, + senderBucketKey +} from '$lib/server/group-message-buckets'; + +const MESSAGES_PAGE_QUERY = ` + query GroupMessagesList($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) { + metaEnvelopes(filter: $filter, first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + parsed + } + } + } + } +`; + +const PAGE_SIZE = 100; +const MAX_PAGES = 50; +const MAX_BUCKET_PARAM_LEN = 512; + +type MessageListConnection = { + totalCount?: number; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + edges?: Array<{ node?: { id?: string; parsed?: Record } }>; +}; + +function readConnection(payload: Record | null): MessageListConnection | null { + if (!payload) { + return null; + } + const data = payload.data as Record | undefined; + const conn = data?.metaEnvelopes as MessageListConnection | undefined; + return conn ?? null; +} + +export const GET: RequestHandler = async ({ params, url }) => { + const evaultId = params.evaultId; + const bucketParam = url.searchParams.get('bucket'); + + if (bucketParam == null || bucketParam === '') { + return json({ error: 'Missing required query parameter: bucket' }, { status: 400 }); + } + if (bucketParam.length > MAX_BUCKET_PARAM_LEN) { + return json({ error: 'Invalid bucket parameter' }, { status: 400 }); + } + + const filterBucket = bucketParam; + + try { + const allVaults = await registryService.getEVaults(); + const registryEnameLookup = buildRegistryEnameLookup(allVaults); + + const vault = allVaults.find((v) => v.evault === evaultId || v.ename === evaultId); + + if (!vault) { + return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 }); + } + + const platform = PUBLIC_CONTROL_PANEL_URL || url.origin; + let token: string | undefined; + try { + token = await requestPlatformToken(platform); + } catch (tokenError) { + console.warn('Group messages: no platform token:', tokenError); + } + + let registryRows: RegistryEvaultRow[] = []; + try { + registryRows = await fetchRegistryEvaultRows(token); + } catch (rowsErr) { + console.warn('Group messages: fetchRegistryEvaultRows failed:', rowsErr); + } + const dashboardNameByKey = buildDashboardNameByKey(registryRows); + const hintByBucket = new Map>(); + const userRowsForProbe = userRegistryRowsForProbe(registryRows); + const probeRequestCache = new Map(); + const { displayName: senderDisplayName, ename: senderEname } = await resolveSenderRowFields({ + bucketKey: filterBucket, + allVaults, + registryEnameLookup, + token, + dashboardNameByKey, + hintByBucket, + userRowsForProbe, + probeRequestCache + }); + + const manifest = await fetchGroupManifestOrFallbackParsed(vault, token); + const manifestRec = manifest && typeof manifest === 'object' && !Array.isArray(manifest) + ? (manifest as Record) + : null; + const groupDisplayNameRaw = manifestRec?.name; + const groupDisplayName = + typeof groupDisplayNameRaw === 'string' && groupDisplayNameRaw.trim() + ? groupDisplayNameRaw.trim() + : vault.ename || evaultId || 'Group'; + + const messages: Array<{ + id: string | null; + preview: string | undefined; + senderBucket: string; + }> = []; + + let messagesScanned = 0; + let totalCount = 0; + let after: string | undefined; + let capped = false; + + for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex++) { + const variables: Record = { + filter: { ontologyId: MESSAGE_ONTOLOGY_ID }, + first: PAGE_SIZE + }; + if (after) { + variables.after = after; + } + + const payload = await evaultGraphqlPost({ + uri: vault.uri, + ename: vault.ename, + query: MESSAGES_PAGE_QUERY, + variables, + token, + timeoutMs: 15000 + }); + + const conn = readConnection(payload); + if (!conn) { + break; + } + + if (pageIndex === 0 && typeof conn.totalCount === 'number') { + totalCount = conn.totalCount; + } + + const edges = conn.edges ?? []; + for (const edge of edges) { + const node = edge.node; + const parsed = node?.parsed; + const p = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + const bucket = senderBucketKey(p, registryEnameLookup); + messagesScanned += 1; + if (bucket === filterBucket) { + const graphqlId = typeof node?.id === 'string' ? node.id : null; + const parsedId = p && typeof p.id === 'string' ? p.id : null; + messages.push({ + id: graphqlId ?? parsedId, + preview: previewMessageBody(p), + senderBucket: bucket + }); + } + } + + const hasNext = conn.pageInfo?.hasNextPage === true; + const endCursor = conn.pageInfo?.endCursor; + if (!hasNext || edges.length === 0) { + break; + } + if (pageIndex === MAX_PAGES - 1) { + capped = true; + break; + } + after = endCursor ?? undefined; + } + + return json({ + evault: { + ename: vault.ename, + uri: vault.uri, + evault: vault.evault + }, + groupDisplayName, + senderDisplayName, + senderEname, + bucket: filterBucket, + messages, + matchedCount: messages.length, + messagesScanned, + totalCount, + capped + }); + } catch (error) { + console.error('Error fetching group messages:', error); + return json({ error: 'Failed to fetch group messages' }, { status: 500 }); + } +}; diff --git a/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte b/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte index 3e34ace26..c4af9b379 100644 --- a/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte +++ b/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte @@ -1,6 +1,7 @@ + + + + {payload + ? `${payload.senderDisplayName} · ${payload.groupDisplayName} — Control Panel` + : 'Messages in group — Control Panel'} + + + +
+ + + {#if isLoading} +
+
+
+ {:else if error} +
+

Could not load messages

+

{error}

+ +
+ {:else if payload} +

Real name

+

{payload.senderDisplayName}

+

+ eName + {payload.senderEname} +

+ +
+

Group

+

{payload.groupDisplayName}

+

+ eName + {payload.evault.ename} +

+
+ +

+ Bucket {payload.bucket} + · matched {payload.matchedCount} of {payload.messagesScanned} scanned + {#if payload.totalCount} + · {payload.totalCount} total messages in vault (reported) + {/if} +

+ {#if payload.capped} +

+ Scan stopped at the safety limit; this list may be incomplete. +

+ {/if} + +
    + {#each payload.messages as m, i (m.id ?? `idx-${i}`)} +
  • + {#if m.id} +

    {m.id}

    + {/if} +

    + {m.preview ?? '(no text preview)'} +

    +
  • + {:else} +
  • + No messages in this bucket for the scanned range. +
  • + {/each} +
+ {/if} +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b4e0b82f..4d64734ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 flowbite: specifier: ^3.1.2 version: 3.1.2(rollup@4.59.0) @@ -151,6 +154,9 @@ importers: jose: specifier: ^6.2.0 version: 6.2.1 + layercake: + specifier: ^10.0.2 + version: 10.0.2(svelte@5.53.11)(typescript@5.8.2) lowdb: specifier: ^7.0.1 version: 7.0.1 @@ -194,6 +200,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.2.1(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/d3-shape': + specifier: ^3.1.8 + version: 3.1.8 '@types/node': specifier: ^22 version: 22.19.15 @@ -3123,7 +3132,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3144,7 +3153,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -15039,6 +15048,13 @@ packages: launch-editor@2.13.1: resolution: {integrity: sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==} + layercake@10.0.2: + resolution: {integrity: sha512-cYtPVA+AL/HbAzsH5xDL9/pbuNuhboGUI9GBtrC1Akjsx4V74rvBmUHZW8lO2ufCDMu+J2CtBRKgO8rkONsYBw==} + engines: {node: ^20.17.0 || >=22.9.0} + peerDependencies: + svelte: '>=5' + typescript: ^5.0.2 + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -31657,9 +31673,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0(encoding@0.1.13) + fbjs: 2.0.0 immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -31667,9 +31683,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -32963,7 +32979,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0(encoding@0.1.13): + fbjs@2.0.0: dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -33844,9 +33860,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -35378,6 +35394,15 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 + layercake@10.0.2(svelte@5.53.11)(typescript@5.8.2): + dependencies: + d3-array: 3.2.4 + d3-color: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + svelte: 5.53.11 + typescript: 5.8.2 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -38147,12 +38172,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1