From d232290a47b627a5f4337a79a2c3f795e23d5a4b Mon Sep 17 00:00:00 2001 From: Alessandro Joabar Date: Thu, 6 Aug 2026 13:15:23 -0400 Subject: [PATCH] compute view mesh map Signed-off-by: Alessandro Joabar --- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/mesh_llm.rs | 4 +- .../src-tauri/src/commands/mesh_snapshot.rs | 15 +- desktop/src-tauri/src/mesh_llm/snapshot.rs | 42 +- .../mesh-compute/communityComputeFixtures.ts | 234 ++++++++ .../communityComputeHexLayout.test.mjs | 358 +++++++++++ .../mesh-compute/communityComputeHexLayout.ts | 448 ++++++++++++++ .../communityComputeMapModel.test.mjs | 206 +++++++ .../mesh-compute/communityComputeMapModel.ts | 365 +++++++++++ .../mesh-compute/hooks/useMeshComputeState.ts | 7 +- .../ui/CommunityComputeComponents.test.mjs | 95 +++ .../ui/CommunityComputeHealthScorecard.tsx | 154 +++++ .../ui/CommunityComputeKpiRow.tsx | 83 +++ .../ui/CommunityComputeTerritoryMap.tsx | 568 ++++++++++++++++++ .../ui/CommunityComputeTutorialPanel.tsx | 146 +++++ .../ui/MeshComputeCommunityView.tsx | 122 ++++ .../mesh-compute/ui/MeshComputePage.tsx | 103 ++++ .../ui/MeshComputeSettingsCard.tsx | 16 +- .../ui/MeshComputeShareBanner.tsx | 113 ++++ .../mesh-compute/ui/SidebarMeshComputeRow.tsx | 78 ++- .../features/settings/ui/SettingsPanels.tsx | 4 +- desktop/src/shared/api/tauriMesh.ts | 10 + desktop/src/testing/e2eBridge.ts | 81 ++- desktop/tailwind.config.js | 8 + .../e2e/mesh-compute-screenshots.spec.ts | 36 ++ desktop/tests/e2e/mesh-compute.spec.ts | 144 +++++ 26 files changed, 3367 insertions(+), 74 deletions(-) create mode 100644 desktop/src/features/mesh-compute/communityComputeFixtures.ts create mode 100644 desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs create mode 100644 desktop/src/features/mesh-compute/communityComputeHexLayout.ts create mode 100644 desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs create mode 100644 desktop/src/features/mesh-compute/communityComputeMapModel.ts create mode 100644 desktop/src/features/mesh-compute/ui/CommunityComputeComponents.test.mjs create mode 100644 desktop/src/features/mesh-compute/ui/CommunityComputeHealthScorecard.tsx create mode 100644 desktop/src/features/mesh-compute/ui/CommunityComputeKpiRow.tsx create mode 100644 desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx create mode 100644 desktop/src/features/mesh-compute/ui/CommunityComputeTutorialPanel.tsx create mode 100644 desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx create mode 100644 desktop/src/features/mesh-compute/ui/MeshComputePage.tsx create mode 100644 desktop/src/features/mesh-compute/ui/MeshComputeShareBanner.tsx create mode 100644 desktop/tests/e2e/mesh-compute-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..ab9397e858 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ "**/add-community-screenshots.spec.ts", "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", + "**/mesh-compute-screenshots.spec.ts", "**/messaging.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/custom-emoji.spec.ts", diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index aaed5701ad..7e1b01113b 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -218,7 +218,9 @@ async fn query_mesh_discovery_events_at( } } -async fn query_mesh_discovery_events(state: &AppState) -> Result, String> { +pub(super) async fn query_mesh_discovery_events( + state: &AppState, +) -> Result, String> { query_mesh_discovery_events_at(state, &relay::relay_ws_url_with_override(state)).await } diff --git a/desktop/src-tauri/src/commands/mesh_snapshot.rs b/desktop/src-tauri/src/commands/mesh_snapshot.rs index f30c95f939..0bd419f2a7 100644 --- a/desktop/src-tauri/src/commands/mesh_snapshot.rs +++ b/desktop/src-tauri/src/commands/mesh_snapshot.rs @@ -22,15 +22,12 @@ type CmdResult = Result; /// state. A genuine relay/transport failure still returns `Err`. #[tauri::command] pub async fn mesh_snapshot(state: State<'_, AppState>) -> CmdResult { - let events = crate::relay::query_relay( - &state, - &[ - mesh_llm::mesh_status_filter(), - mesh_llm::relay_membership_filter(), - ], - ) - .await - .map_err(|error| format!("Shared compute status query failed: {error}"))?; + // Reuse discovery's author-scoped, composite-cursor pagination. A direct + // two-filter query stops at the status filter's 100-event page size and + // silently undercounts larger communities. + let events = super::mesh_llm::query_mesh_discovery_events(&state) + .await + .map_err(|error| format!("Shared compute status query failed: {error}"))?; // Identify this member's own device so the card can say "including yours". // A missing/locked identity is not fatal here — the snapshot is still diff --git a/desktop/src-tauri/src/mesh_llm/snapshot.rs b/desktop/src-tauri/src/mesh_llm/snapshot.rs index bc95d6d42a..b76b4bd12d 100644 --- a/desktop/src-tauri/src/mesh_llm/snapshot.rs +++ b/desktop/src-tauri/src/mesh_llm/snapshot.rs @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; use super::discovery::{ device_name_from_status, endpoint_binding_is_valid, endpoint_id_from_status, - latest_membership_list, owner_id_from_status_event, status_is_fresh, + latest_membership_list, owner_id_from_status_event, status_is_fresh, STATUS_FRESHNESS_SECS, }; use super::{MeshServeTarget, MESH_STATUS_KIND}; @@ -62,6 +62,13 @@ pub struct MeshSnapshotDevice { pub state: MeshDeviceState, /// Whether this is the local member's own device. pub is_self: bool, + /// Community member who signed this status note. Safe for profile lookup; + /// endpoint tokens, owner signatures, and raw hardware remain excluded. + pub member_pubkey: Option, + /// Relay event creation time, in Unix seconds. + pub reported_at: Option, + /// Approximate hosted-model footprint reported by MeshLLM, when available. + pub model_size_gb: Option, } /// What the community's shared compute looks like right now. @@ -70,10 +77,15 @@ pub struct MeshSnapshotDevice { pub struct MeshSnapshot { /// Devices advertising at least one routable model. pub sharing_device_count: usize, + /// Unique current members with at least one ready serving device. + pub contributor_member_count: usize, /// Summed shared capacity across sharing devices. `None` when no sharing /// device published a figure — the UI then shows a count with no GB, never /// a misleading `0 GB`. pub shared_capacity_gb: Option, + /// Summed model footprint only when every sharing device reports it. + /// `None` preserves mixed-version honesty instead of presenting a partial sum. + pub allocated_capacity_gb: Option, /// Distinct models ready to use, deduped across devices. pub models: Vec, /// Per-device rows, for the topology view. Sharing devices first. @@ -87,6 +99,10 @@ pub struct MeshSnapshot { /// note, so their hardware is unknown by design — they never consented to /// disclose it. Ghost nodes in the topology are a count, never GB. pub member_count: usize, + /// When this projection was evaluated, in Unix seconds. + pub observed_at: u64, + /// Maximum age of a relay-reported serving status. + pub freshness_seconds: u64, /// Why the snapshot is empty, when it is. Never a hard error: an empty /// mesh is a normal, expected state. pub reason: Option, @@ -95,6 +111,8 @@ pub struct MeshSnapshot { impl MeshSnapshot { fn empty(reason: impl Into) -> Self { Self { + observed_at: nostr::Timestamp::now().as_secs(), + freshness_seconds: STATUS_FRESHNESS_SECS, reason: Some(reason.into()), ..Default::default() } @@ -204,6 +222,9 @@ pub fn snapshot_from_events( state: device_state(&content, !models.is_empty()), models, is_self, + member_pubkey: Some(event.pubkey.to_hex()), + reported_at: Some(event.created_at.as_secs()), + model_size_gb: f64_field(&content, "model_size_gb"), }, ); } @@ -224,6 +245,11 @@ pub fn snapshot_from_events( .filter(|device| device.state == MeshDeviceState::Serving) .collect::>(); let sharing_device_count = sharing.len(); + let contributor_member_count = sharing + .iter() + .filter_map(|device| device.member_pubkey.as_ref()) + .collect::>() + .len(); let includes_self = sharing.iter().any(|device| device.is_self); // Sum only reported figures; keep `None` when nobody reported one so the // UI can drop the number instead of printing `0 GB`. @@ -231,6 +257,14 @@ pub fn snapshot_from_events( .iter() .filter_map(|device| device.capacity_gb) .fold(None::, |acc, gb| Some(acc.unwrap_or(0.0) + gb)); + let allocated_capacity_gb = (!sharing.is_empty() + && sharing.iter().all(|device| device.model_size_gb.is_some())) + .then(|| { + sharing + .iter() + .filter_map(|device| device.model_size_gb) + .sum() + }); let mut models = sharing .iter() .flat_map(|device| device.models.iter().cloned()) @@ -246,11 +280,15 @@ pub fn snapshot_from_events( MeshSnapshot { member_count: members.len(), + contributor_member_count, sharing_device_count, shared_capacity_gb, + allocated_capacity_gb, models, devices, includes_self, + observed_at: now, + freshness_seconds: STATUS_FRESHNESS_SECS, reason, } } @@ -265,6 +303,8 @@ mod tests { assert_eq!(snapshot.sharing_device_count, 0); assert_eq!(snapshot.shared_capacity_gb, None); assert!(snapshot.reason.is_some()); + assert_eq!(snapshot.freshness_seconds, STATUS_FRESHNESS_SECS); + assert!(snapshot.observed_at > 0); } #[test] diff --git a/desktop/src/features/mesh-compute/communityComputeFixtures.ts b/desktop/src/features/mesh-compute/communityComputeFixtures.ts new file mode 100644 index 0000000000..e88f672680 --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeFixtures.ts @@ -0,0 +1,234 @@ +import type { + CommunityComputeDeviceInput, + CommunityComputeSnapshotInput, +} from "./communityComputeMapModel"; + +export type CommunityComputeTutorialFixtureId = + | "empty" + | "single-device" + | "small-company" + | "large-dense-500"; + +export type CommunityComputeTutorialFixture = { + id: CommunityComputeTutorialFixtureId; + title: string; + description: string; + snapshot: CommunityComputeSnapshotInput; +}; + +const MODEL_REFS = [ + "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M", + "Qwen/Qwen3.5-35B-A3B-GGUF:Q4_K_M", + "mistralai/Ministral-3-14B-Instruct-GGUF:Q4_K_M", + "bartowski/Llama-3.3-70B-Instruct-GGUF:Q4_K_M", +] as const; + +export const EMPTY_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "empty", + title: "New community", + description: "No member has shared compute yet.", + snapshot: { + sharingDeviceCount: 0, + contributorMemberCount: 0, + sharedCapacityGb: null, + allocatedCapacityGb: null, + models: [], + devices: [], + includesSelf: false, + memberCount: 8, + reason: "No sharing devices found", + }, + }; + +export const SINGLE_DEVICE_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "single-device", + title: "First deployment", + description: "One member is sharing a single workstation.", + snapshot: snapshotFromDevices( + [ + device({ + index: 0, + id: "studio-workstation", + label: "Morgan’s Studio", + capacityGb: 24, + modelSizeGb: 18, + isSelf: true, + }), + ], + 1, + ), + }; + +export const SMALL_COMPANY_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "small-company", + title: "Small company", + description: "A mixed fleet across a small distributed team.", + snapshot: snapshotFromDevices( + [ + ...[ + { capacityGb: 24, modelSizeGb: 18 }, + { capacityGb: 24, modelSizeGb: 18 }, + { capacityGb: 36, modelSizeGb: 28 }, + { capacityGb: 36, modelSizeGb: 28 }, + { capacityGb: 48, modelSizeGb: 36 }, + { capacityGb: 48, modelSizeGb: 36 }, + { capacityGb: 64, modelSizeGb: 48 }, + { capacityGb: 64, modelSizeGb: 48 }, + { capacityGb: 96, modelSizeGb: 72 }, + { capacityGb: 96, modelSizeGb: 72 }, + ].map(({ capacityGb, modelSizeGb }, index) => + device({ + index, + id: `team-macbook-${String(index + 1).padStart(2, "0")}`, + label: `Team MacBook ${String(index + 1).padStart(2, "0")}`, + capacityGb, + modelSizeGb, + isSelf: index === 0, + }), + ), + device({ + index: 10, + id: "intensive-model-host-a", + label: "Intensive model host A", + capacityGb: 768, + modelRef: "unsloth/Kimi-K2-Thinking-GGUF:UD-Q4_K_XL", + modelSizeGb: 646, + }), + device({ + index: 11, + id: "intensive-model-host-b", + label: "Intensive model host B", + capacityGb: 1_440, + modelRef: "unsloth/DeepSeek-V3.2-GGUF:UD-Q4_K_XL", + modelSizeGb: 1_000, + }), + ], + 24, + ), + }; + +/** Deterministic stress fixture using only the production snapshot contract. */ +export const LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "large-dense-500", + title: "Large company", + description: + "Five hundred deterministic reporting devices for density testing.", + snapshot: createLargeDenseSnapshot(500), + }; + +export const COMMUNITY_COMPUTE_TUTORIAL_FIXTURES: Readonly< + Record +> = { + empty: EMPTY_COMMUNITY_COMPUTE_FIXTURE, + "single-device": SINGLE_DEVICE_COMMUNITY_COMPUTE_FIXTURE, + "small-company": SMALL_COMPANY_COMMUNITY_COMPUTE_FIXTURE, + "large-dense-500": LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE, +}; + +export function createLargeDenseSnapshot( + deviceCount = 500, +): CommunityComputeSnapshotInput { + const count = Math.max(0, Math.floor(deviceCount)); + const capacities = [8, 16, 24, 32, 48, 64, 96, 128] as const; + const devices = Array.from({ length: count }, (_, index) => { + const capacityGb = capacities[index % capacities.length]; + const state = + index % 41 === 0 ? "loading" : index % 17 === 0 ? "standby" : "serving"; + const largeIndex = [49, 149, 249, 349, 449, 499].indexOf(index); + const largeSizes = [170, 230, 350, 480, 700, 1_000] as const; + const largeCapacities = [192, 256, 384, 512, 768, 1_440] as const; + const modelSizeGb = + largeIndex >= 0 + ? largeSizes[largeIndex] + : roundCapacity(capacityGb * 0.72); + return device({ + index, + id: `dense-device-${String(index + 1).padStart(4, "0")}`, + label: + largeIndex >= 0 + ? `${largeCapacities[largeIndex]} GB model host` + : `Compute ${String(index + 1).padStart(4, "0")}`, + capacityGb: largeIndex >= 0 ? largeCapacities[largeIndex] : capacityGb, + modelRef: + largeIndex >= 0 + ? `community/Large-${modelSizeGb}GB-Instruct:FP8` + : undefined, + modelSizeGb, + state, + models: state === "serving" ? undefined : [], + isSelf: index === 0, + }); + }); + return snapshotFromDevices(devices, Math.ceil(count * 1.37)); +} + +type DeviceOptions = { + index: number; + id: string; + label: string; + capacityGb: number; + modelRef?: string; + modelSizeGb?: number; + models?: string[]; + state?: CommunityComputeDeviceInput["state"]; + isSelf?: boolean; +}; + +function device(options: DeviceOptions): CommunityComputeDeviceInput { + const model = + options.modelRef ?? MODEL_REFS[options.index % MODEL_REFS.length]; + return { + deviceId: options.id, + label: options.label, + capacityGb: options.capacityGb, + models: options.models ?? [model], + state: options.state ?? "serving", + isSelf: options.isSelf ?? false, + memberPubkey: `tutorial-member-${String(options.index + 1).padStart(4, "0")}`, + reportedAt: 1_786_032_000, + modelSizeGb: options.modelSizeGb ?? null, + }; +} + +function roundCapacity(value: number): number { + return Math.round(value * 10) / 10; +} + +function snapshotFromDevices( + devices: CommunityComputeDeviceInput[], + memberCount: number, +): CommunityComputeSnapshotInput { + const serving = devices.filter( + (item) => item.state === "serving" && item.models.length > 0, + ); + const models = [...new Set(serving.flatMap((item) => item.models))].sort(); + const sharedCapacityGb = completeSum(serving.map((item) => item.capacityGb)); + const allocatedCapacityGb = completeSum( + serving.map((item) => item.modelSizeGb ?? null), + ); + return { + sharingDeviceCount: serving.length, + contributorMemberCount: new Set( + serving.map((item) => item.memberPubkey ?? item.deviceId), + ).size, + sharedCapacityGb, + allocatedCapacityGb, + models, + devices, + includesSelf: serving.some((item) => item.isSelf), + memberCount, + observedAt: 1_786_032_000, + freshnessSeconds: 120, + reason: serving.length === 0 ? "No sharing devices found" : null, + }; +} + +function completeSum(values: readonly (number | null)[]): number | null { + return values.length > 0 && values.every((value) => value !== null) + ? values.reduce((total, value) => total + (value ?? 0), 0) + : null; +} diff --git a/desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs b/desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs new file mode 100644 index 0000000000..ee14e9032d --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs @@ -0,0 +1,358 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hexCoordinateKey, + hexDistance, + hexNeighbours, + hexTerritoryBoundaryEdges, + hexTerritoryInteriorEdges, + isContiguousHexTerritory, + layoutCommunityComputeHexTerritories, +} from "./communityComputeHexLayout.ts"; +import { LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE } from "./communityComputeFixtures.ts"; +import { deriveCommunityComputeMapModel } from "./communityComputeMapModel.ts"; + +function assertStructuralInvariants(layout) { + const occupied = new Set(layout.cells.map(hexCoordinateKey)); + assert.equal(occupied.size, layout.cells.length, "cells must not overlap"); + assert.ok( + layout.territories.every((territory) => + isContiguousHexTerritory(territory.cells), + ), + "every territory must be contiguous", + ); + if (layout.cells.length > 0) { + const reached = new Set([hexCoordinateKey(layout.cells[0])]); + const queue = [layout.cells[0]]; + for (let cursor = 0; cursor < queue.length; cursor += 1) { + for (const neighbour of hexNeighbours(queue[cursor])) { + const key = hexCoordinateKey(neighbour); + if (occupied.has(key) && !reached.has(key)) { + reached.add(key); + queue.push(neighbour); + } + } + } + assert.equal( + reached.size, + occupied.size, + "occupied mesh must be connected", + ); + } +} + +function organicGaps(layout) { + const occupied = new Set(layout.cells.map(hexCoordinateKey)); + const empty = new Map(); + for (const cell of layout.cells) { + for (const neighbour of hexNeighbours(cell)) { + const key = hexCoordinateKey(neighbour); + if (!occupied.has(key)) empty.set(key, neighbour); + } + } + return [...empty.values()].filter( + (cell) => + hexNeighbours(cell).filter((neighbour) => + occupied.has(hexCoordinateKey(neighbour)), + ).length >= 2, + ); +} + +function assertBalancedSparseFrontier(layout, deploymentCount) { + const points = layout.cells.map((cell) => ({ + x: Math.sqrt(3) * (cell.q + cell.r / 2), + y: 1.5 * cell.r, + })); + const width = + Math.max(...points.map(({ x }) => x)) - + Math.min(...points.map(({ x }) => x)) + + Math.sqrt(3); + const height = + Math.max(...points.map(({ y }) => y)) - + Math.min(...points.map(({ y }) => y)) + + 2; + const aspectRatio = width / height; + assert.ok( + aspectRatio >= 0.8 && aspectRatio <= 1.3, + `${deploymentCount} deployments have unbalanced aspect ratio ${aspectRatio}`, + ); + + const radius = Math.max( + ...layout.cells.map((cell) => hexDistance(cell, { q: 0, r: 0 })), + ); + const directionalRadii = Array(6).fill(0); + for (const cell of layout.cells) { + const projections = [ + cell.q, + -cell.r, + -cell.q - cell.r, + -cell.q, + cell.r, + cell.q + cell.r, + ]; + for (const [index, projection] of projections.entries()) { + directionalRadii[index] = Math.max(directionalRadii[index], projection); + } + } + assert.ok( + Math.max(...directionalRadii) - Math.min(...directionalRadii) <= 2, + `${deploymentCount} deployments must reach every side evenly`, + ); + + const outerOccupancy = + layout.cells.filter((cell) => hexDistance(cell, { q: 0, r: 0 }) === radius) + .length / + (6 * radius); + assert.ok( + outerOccupancy >= 0.1 && outerOccupancy <= 0.55, + `${deploymentCount} deployments need a sparse, notched outer edge (${outerOccupancy})`, + ); +} + +function deployments(count) { + const buckets = [1, 2, 4, 7]; + return Array.from({ length: count }, (_, index) => ({ + id: `deployment-${String(index + 1).padStart(4, "0")}`, + cellCount: buckets[index % buckets.length], + })); +} + +test("layout is deterministic and independent of input order", () => { + const input = deployments(80); + const first = layoutCommunityComputeHexTerritories(input); + const second = layoutCommunityComputeHexTerritories([...input].reverse()); + assert.deepEqual(first, second); +}); + +test("growing layouts stay balanced with sparse outer frontiers", () => { + for (const count of [24, 250, 1_000]) { + const layout = layoutCommunityComputeHexTerritories(deployments(count)); + assertStructuralInvariants(layout); + assertBalancedSparseFrontier(layout, count); + assert.ok( + organicGaps(layout).length > 0, + `${count} deployments should leave deterministic pockets or edge gaps`, + ); + } +}); + +test("canonical territory order remains stable when previous placement is reused", () => { + const input = deployments(60); + const initial = layoutCommunityComputeHexTerritories(input); + const next = layoutCommunityComputeHexTerritories([...input].reverse(), { + previous: initial, + }); + assert.deepEqual( + next.territories.map(({ deploymentId }) => deploymentId), + initial.territories.map(({ deploymentId }) => deploymentId), + ); +}); + +test("payload changes preserve positions because IDs own the slots", () => { + const initial = deployments(40); + const changed = initial.map((deployment) => ({ + ...deployment, + label: `Changed ${deployment.id}`, + health: "degraded", + })); + assert.deepEqual( + layoutCommunityComputeHexTerritories(initial), + layoutCommunityComputeHexTerritories(changed), + ); +}); + +test("surviving IDs keep positions as deployments are added and removed", () => { + const initialInput = deployments(40); + const initial = layoutCommunityComputeHexTerritories(initialInput); + const nextInput = [ + ...initialInput.filter((_, index) => index % 4 !== 0), + ...deployments(6).map((item, index) => ({ + ...item, + id: `new-deployment-${index}`, + })), + ]; + const next = layoutCommunityComputeHexTerritories(nextInput, { + previous: initial, + }); + const initialCenters = new Map( + initial.territories.map((territory) => [ + territory.deploymentId, + territory.center, + ]), + ); + + for (const territory of next.territories) { + const oldCenter = initialCenters.get(territory.deploymentId); + if (oldCenter !== undefined) { + assert.deepEqual(territory.center, oldCenter); + } + } + assert.equal( + new Set(next.cells.map(hexCoordinateKey)).size, + next.cells.length, + ); +}); + +test("every supported territory shape is contiguous", () => { + const layout = layoutCommunityComputeHexTerritories([ + { id: "one", cellCount: 1 }, + { id: "two", cellCount: 2 }, + { id: "four", cellCount: 4 }, + { id: "seven", cellCount: 7 }, + ]); + const expected = new Map([ + ["one", 1], + ["two", 2], + ["four", 4], + ["seven", 7], + ]); + + for (const territory of layout.territories) { + assert.equal(territory.cells.length, expected.get(territory.deploymentId)); + assert.ok( + isContiguousHexTerritory(territory.cells), + `${territory.deploymentId} must be contiguous`, + ); + } +}); + +test("fused boundaries omit interior edges for 1, 2, 4, and 7 cells", () => { + const shapes = [ + [{ q: 0, r: 0 }], + [ + { q: 0, r: 0 }, + { q: 1, r: 0 }, + ], + [ + { q: 0, r: 0 }, + { q: 1, r: 0 }, + { q: 1, r: -1 }, + { q: 0, r: -1 }, + ], + [{ q: 0, r: 0 }, ...hexNeighbours({ q: 0, r: 0 })], + ]; + + for (const [index, cells] of shapes.entries()) { + const sharedPairs = + cells.reduce( + (total, cell) => + total + + hexNeighbours(cell).filter((neighbour) => + cells.some( + (candidate) => + hexCoordinateKey(candidate) === hexCoordinateKey(neighbour), + ), + ).length, + 0, + ) / 2; + const boundary = hexTerritoryBoundaryEdges(cells); + assert.equal(boundary.length, 6 * cells.length - 2 * sharedPairs); + + const endpointDegree = new Map(); + for (const edge of boundary) { + assert.notDeepEqual(edge.start, edge.end); + for (const point of [edge.start, edge.end]) { + const key = `${point.x.toFixed(8)},${point.y.toFixed(8)}`; + endpointDegree.set(key, (endpointDegree.get(key) ?? 0) + 1); + } + } + assert.ok( + [...endpointDegree.values()].every((degree) => degree === 2), + `shape ${index} boundary must form closed loops without dangling edges`, + ); + } + + const edgeKey = (edge) => + [edge.start, edge.end] + .map((point) => `${point.x.toFixed(8)},${point.y.toFixed(8)}`) + .sort() + .join("|"); + assert.deepEqual( + hexTerritoryBoundaryEdges(shapes[3]).map(edgeKey).sort(), + hexTerritoryBoundaryEdges([...shapes[3]].reverse()) + .map(edgeKey) + .sort(), + "edge membership must not depend on cell order", + ); +}); + +test("interior edges contain each shared device boundary exactly once", () => { + const cells = [{ q: 0, r: 0 }, ...hexNeighbours({ q: 0, r: 0 })]; + const interior = hexTerritoryInteriorEdges(cells); + const edgeKey = (edge) => + [edge.start, edge.end] + .map((point) => `${point.x.toFixed(8)},${point.y.toFixed(8)}`) + .sort() + .join("|"); + + assert.equal(interior.length, 12); + assert.equal(new Set(interior.map(edgeKey)).size, interior.length); + assert.deepEqual( + interior.map(edgeKey).sort(), + hexTerritoryInteriorEdges([...cells].reverse()) + .map(edgeKey) + .sort(), + "shared edge membership must not depend on cell order", + ); + assert.deepEqual(hexTerritoryInteriorEdges([{ q: 0, r: 0 }]), []); +}); + +test("territories never emit duplicate occupied cells", () => { + const layout = layoutCommunityComputeHexTerritories(deployments(250)); + const coordinates = layout.cells.map(hexCoordinateKey); + assert.equal(new Set(coordinates).size, coordinates.length); + assert.equal( + new Set(layout.cells.map((cell) => cell.id)).size, + layout.cells.length, + ); +}); + +test("duplicate deployment IDs are rejected rather than overlapped", () => { + assert.throws( + () => + layoutCommunityComputeHexTerritories([ + { id: "same", cellCount: 1 }, + { id: "same", cellCount: 7 }, + ]), + /Duplicate community compute deployment ID: same/, + ); +}); + +test("empty layout has no fabricated bounds", () => { + assert.deepEqual(layoutCommunityComputeHexTerritories([]), { + territories: [], + cells: [], + bounds: null, + }); +}); + +test("500-deployment fixture lays out uniquely and contiguously", () => { + const model = deriveCommunityComputeMapModel( + LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE.snapshot, + ); + const input = model.deployments.map(({ id, cellCount }) => ({ + id, + cellCount, + })); + const layout = layoutCommunityComputeHexTerritories(input); + + assert.equal(layout.territories.length, model.deployments.length); + assert.equal( + layout.cells.length, + model.deployments.reduce( + (total, deployment) => total + deployment.cellCount, + 0, + ), + ); + assert.equal( + new Set(layout.cells.map(hexCoordinateKey)).size, + layout.cells.length, + ); + assert.ok( + layout.territories.every((territory) => + isContiguousHexTerritory(territory.cells), + ), + ); + assert.ok(layout.bounds !== null); +}); diff --git a/desktop/src/features/mesh-compute/communityComputeHexLayout.ts b/desktop/src/features/mesh-compute/communityComputeHexLayout.ts new file mode 100644 index 0000000000..aac860d99a --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeHexLayout.ts @@ -0,0 +1,448 @@ +import type { HexCellCount } from "./communityComputeMapModel"; + +/** Axial coordinates for a pointy-top hex grid. */ +export type HexCoordinate = { + q: number; + r: number; +}; + +/** Cartesian point in pointy-top hex geometry. */ +export type HexPoint = { + x: number; + y: number; +}; + +/** One exposed side of a fused hex territory boundary. */ +export type HexBoundaryEdge = { + start: HexPoint; + end: HexPoint; +}; + +export type CommunityComputeLayoutInput = { + id: string; + cellCount: HexCellCount; +}; + +export type CommunityComputeHexCell = HexCoordinate & { + id: string; + deploymentId: string; + index: number; +}; + +export type CommunityComputeHexTerritory = { + deploymentId: string; + center: HexCoordinate; + cells: readonly CommunityComputeHexCell[]; +}; + +export type CommunityComputeHexBounds = { + minQ: number; + maxQ: number; + minR: number; + maxR: number; +}; + +export type CommunityComputeHexLayout = { + territories: readonly CommunityComputeHexTerritory[]; + cells: readonly CommunityComputeHexCell[]; + bounds: CommunityComputeHexBounds | null; +}; + +export type CommunityComputeHexLayoutOptions = { + /** + * Reuse centres for IDs that are still present. Supplying the last layout + * keeps a live map steady while deployments arrive, leave, or change size. + */ + previous?: CommunityComputeHexLayout | null; +}; + +const HEX_DIRECTIONS: readonly HexCoordinate[] = [ + { q: 1, r: 0 }, + { q: 1, r: -1 }, + { q: 0, r: -1 }, + { q: -1, r: 0 }, + { q: -1, r: 1 }, + { q: 0, r: 1 }, +]; + +/** + * Lay deployments out as one organic, edge-connected honeycomb. + * + * Input order never affects the result. Stable hashes choose territory + * orientation, while a deterministic low-discrepancy permutation spreads each + * ring's placements around the whole current frontier. Completed inner rings + * remain dense; only the growing outer layers carry deliberate notches. With a + * previous layout, collision-free surviving shapes retain their exact cells; + * new shapes take the first non-overlapping frontier position that shares an + * edge with the occupied mesh. + */ +export function layoutCommunityComputeHexTerritories( + deployments: readonly CommunityComputeLayoutInput[], + options: CommunityComputeHexLayoutOptions = {}, +): CommunityComputeHexLayout { + const seenIds = new Set(); + for (const deployment of deployments) { + if (seenIds.has(deployment.id)) { + throw new Error( + `Duplicate community compute deployment ID: ${deployment.id}`, + ); + } + seenIds.add(deployment.id); + } + + const ordered = [...deployments].sort( + (left, right) => + stableHash(left.id) - stableHash(right.id) || + left.id.localeCompare(right.id), + ); + // Previous cells are retained only when they remain collision-free. New + // territories search the compact spiral for the first candidate that both + // fits and touches the occupied frontier. + const previousById = new Map( + (options.previous?.territories ?? []).map((territory) => [ + territory.deploymentId, + territory, + ]), + ); + const retainableIds = connectedRetainedTerritoryIds(ordered, previousById); + const occupied = new Set(); + const territories: CommunityComputeHexTerritory[] = []; + const placementOrder = [...ordered].sort( + (left, right) => + Number(retainableIds.has(right.id)) - + Number(retainableIds.has(left.id)) || + ordered.indexOf(left) - ordered.indexOf(right), + ); + + for (const deployment of placementOrder) { + const previous = retainableIds.has(deployment.id) + ? previousById.get(deployment.id) + : undefined; + const retained = + previous?.cells.length === deployment.cellCount + ? previous.cells.map(({ q, r }) => ({ q, r })) + : null; + const retainedFits = retained?.every( + (cell) => !occupied.has(hexCoordinateKey(cell)), + ); + const orientation = stableHash(`${deployment.id}:orientation`) % 6; + let center = retainedFits ? previous?.center : undefined; + let coordinates = retainedFits ? retained : null; + + if (!coordinates || !center) { + for (let slot = 0; ; slot += 1) { + const candidate = balancedSpiralCoordinate(slot); + const offsets = territoryOffsets(deployment.cellCount, orientation); + const candidateCells = offsets.map((offset) => ({ + q: candidate.q + offset.q, + r: candidate.r + offset.r, + })); + const fits = candidateCells.every( + (cell) => !occupied.has(hexCoordinateKey(cell)), + ); + const touches = + occupied.size === 0 || + candidateCells.some((cell) => + hexNeighbours(cell).some((neighbour) => + occupied.has(hexCoordinateKey(neighbour)), + ), + ); + if (fits && touches) { + center = candidate; + coordinates = candidateCells; + break; + } + } + } + + const cells = coordinates.map(({ q, r }, index) => { + occupied.add(hexCoordinateKey({ q, r })); + return { + id: `${deployment.id}@${q},${r}`, + deploymentId: deployment.id, + index, + q, + r, + }; + }); + territories.push({ deploymentId: deployment.id, center, cells }); + } + territories.sort( + (left, right) => + ordered.findIndex((item) => item.id === left.deploymentId) - + ordered.findIndex((item) => item.id === right.deploymentId), + ); + const cells = territories.flatMap((territory) => territory.cells); + + return { territories, cells, bounds: findBounds(cells) }; +} + +/** The six axial neighbours of a cell, in stable clockwise order. */ +export function hexNeighbours(coordinate: HexCoordinate): HexCoordinate[] { + return HEX_DIRECTIONS.map((direction) => ({ + q: coordinate.q + direction.q, + r: coordinate.r + direction.r, + })); +} + +/** + * Return only the exposed unit-hex sides of a territory. + * + * Coordinates use pointy-top axial geometry. Shared sides are omitted, so the + * result can be stroked as one fused territory rather than as overlapping + * individual hexagons. Edge direction is stable and follows each cell's + * perimeter; callers may chain matching endpoints when an ordered path is + * required. + */ +export function hexTerritoryBoundaryEdges( + cells: readonly HexCoordinate[], +): HexBoundaryEdge[] { + const occupied = new Set(cells.map(hexCoordinateKey)); + const edges: HexBoundaryEdge[] = []; + + for (const cell of cells) { + for (let direction = 0; direction < HEX_DIRECTIONS.length; direction += 1) { + const neighbour = { + q: cell.q + HEX_DIRECTIONS[direction].q, + r: cell.r + HEX_DIRECTIONS[direction].r, + }; + if (!occupied.has(hexCoordinateKey(neighbour))) { + edges.push(hexEdge(cell, direction)); + } + } + } + + return edges; +} + +/** + * Return each side shared by two cells in a territory exactly once. + * + * Fused model territories do not render these edges. Split deployments use + * them to show the device boundaries without drawing inset outlines around + * every cell or accidentally doubling a shared stroke. + */ +export function hexTerritoryInteriorEdges( + cells: readonly HexCoordinate[], +): HexBoundaryEdge[] { + const occupied = new Set(cells.map(hexCoordinateKey)); + const edges: HexBoundaryEdge[] = []; + + for (const cell of cells) { + const cellKey = hexCoordinateKey(cell); + for (let direction = 0; direction < HEX_DIRECTIONS.length; direction += 1) { + const neighbour = { + q: cell.q + HEX_DIRECTIONS[direction].q, + r: cell.r + HEX_DIRECTIONS[direction].r, + }; + const neighbourKey = hexCoordinateKey(neighbour); + if (occupied.has(neighbourKey) && cellKey < neighbourKey) { + edges.push(hexEdge(cell, direction)); + } + } + } + + return edges; +} + +function hexEdge(cell: HexCoordinate, direction: number): HexBoundaryEdge { + const center = { + x: Math.sqrt(3) * (cell.q + cell.r / 2), + y: 1.5 * cell.r, + }; + const corners = Array.from({ length: 6 }, (_, index) => { + const angle = ((-30 + index * 60) * Math.PI) / 180; + return { + x: normalizeHexPointComponent(center.x + Math.cos(angle)), + y: normalizeHexPointComponent(center.y + Math.sin(angle)), + }; + }); + return { + start: corners[(6 - direction) % 6], + end: corners[(7 - direction) % 6], + }; +} + +function normalizeHexPointComponent(value: number): number { + return Math.abs(value) < Number.EPSILON * 8 ? 0 : value; +} + +/** Axial hex distance, useful to consumers for borders and transitions. */ +export function hexDistance(left: HexCoordinate, right: HexCoordinate): number { + const dq = left.q - right.q; + const dr = left.r - right.r; + return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(dq + dr)); +} + +/** True when every cell can be reached from every other through shared edges. */ +export function isContiguousHexTerritory( + cells: readonly HexCoordinate[], +): boolean { + if (cells.length === 0) { + return false; + } + const remaining = new Set(cells.map(hexCoordinateKey)); + const queue: HexCoordinate[] = [cells[0]]; + remaining.delete(hexCoordinateKey(cells[0])); + for (let cursor = 0; cursor < queue.length; cursor += 1) { + for (const neighbour of hexNeighbours(queue[cursor])) { + const key = hexCoordinateKey(neighbour); + if (remaining.delete(key)) { + queue.push(neighbour); + } + } + } + return remaining.size === 0; +} + +export function hexCoordinateKey(coordinate: HexCoordinate): string { + return `${coordinate.q},${coordinate.r}`; +} + +/** Keep only the edge-connected component of valid surviving territories. */ +function connectedRetainedTerritoryIds( + ordered: readonly CommunityComputeLayoutInput[], + previousById: ReadonlyMap, +): Set { + const valid = ordered + .map((deployment) => previousById.get(deployment.id)) + .filter( + (territory): territory is CommunityComputeHexTerritory => + territory !== undefined && + territory.cells.length === + ordered.find((item) => item.id === territory.deploymentId)?.cellCount, + ); + if (valid.length === 0) return new Set(); + + const owner = new Map(); + for (const territory of valid) { + for (const cell of territory.cells) { + const key = hexCoordinateKey(cell); + if (owner.has(key)) return new Set(); + owner.set(key, territory.deploymentId); + } + } + const retained = new Set([valid[0].deploymentId]); + const queue = [valid[0].deploymentId]; + const byId = new Map( + valid.map((territory) => [territory.deploymentId, territory]), + ); + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const territory = byId.get(queue[cursor]); + if (!territory) continue; + for (const cell of territory.cells) { + for (const neighbour of hexNeighbours(cell)) { + const neighbourOwner = owner.get(hexCoordinateKey(neighbour)); + if (neighbourOwner && !retained.has(neighbourOwner)) { + retained.add(neighbourOwner); + queue.push(neighbourOwner); + } + } + } + } + return retained; +} + +function territoryOffsets( + count: HexCellCount, + orientation: number, +): HexCoordinate[] { + const center = { q: 0, r: 0 }; + if (count === 1) { + return [center]; + } + if (count === 2) { + return [center, HEX_DIRECTIONS[orientation]]; + } + if (count === 4) { + return [ + center, + HEX_DIRECTIONS[orientation], + HEX_DIRECTIONS[(orientation + 1) % 6], + HEX_DIRECTIONS[(orientation + 2) % 6], + ]; + } + return [center, ...HEX_DIRECTIONS]; +} + +/** + * Zero-based spiral slot: origin, then complete radius-one, radius-two, … rings. + * Runtime is O(radius), which is O(sqrt(n)); even at dense fixture sizes the + * largest radius remains small. + */ +function balancedSpiralCoordinate(index: number): HexCoordinate { + if (index === 0) { + return { q: 0, r: 0 }; + } + + let radius = 1; + let ringStart = 1; + while (index >= ringStart + 6 * radius) { + ringStart += 6 * radius; + radius += 1; + } + + const ringLength = 6 * radius; + const ringIndex = index - ringStart; + // Multiplication by a number coprime with the ring length is a permutation, + // not sampling: every slot is eventually visited. A step near half a ring + // alternates opposite sides, preventing a partially filled layer from + // producing the one-sided growth of a clockwise spiral. + let step = Math.max(1, Math.floor(ringLength / 2) - 1); + while (greatestCommonDivisor(step, ringLength) !== 1) step -= 1; + const offsetSeed = stableHash(`ring:${radius}`) % ringLength; + let offset = (offsetSeed + ringIndex * step) % ringLength; + let coordinate = { q: -radius, r: radius }; + for (const direction of HEX_DIRECTIONS) { + const steps = Math.min(radius, offset); + coordinate = { + q: coordinate.q + direction.q * steps, + r: coordinate.r + direction.r * steps, + }; + offset -= steps; + if (offset === 0) { + break; + } + } + return coordinate; +} + +function greatestCommonDivisor(left: number, right: number): number { + let a = left; + let b = right; + while (b !== 0) { + const remainder = a % b; + a = b; + b = remainder; + } + return a; +} + +function findBounds( + cells: readonly HexCoordinate[], +): CommunityComputeHexBounds | null { + if (cells.length === 0) { + return null; + } + let minQ = cells[0].q; + let maxQ = cells[0].q; + let minR = cells[0].r; + let maxR = cells[0].r; + for (const cell of cells.slice(1)) { + minQ = Math.min(minQ, cell.q); + maxQ = Math.max(maxQ, cell.q); + minR = Math.min(minR, cell.r); + maxR = Math.max(maxR, cell.r); + } + return { minQ, maxQ, minR, maxR }; +} + +/** FNV-1a over UTF-16 code units; deterministic on every JS runtime. */ +function stableHash(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} diff --git a/desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs b/desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs new file mode 100644 index 0000000000..e9c3ed32b2 --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bucketHexCellCount, + capacityGbToHexCellCount, + deriveCommunityComputeMapModel, +} from "./communityComputeMapModel.ts"; +import { + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES, + LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE, + createLargeDenseSnapshot, +} from "./communityComputeFixtures.ts"; + +function device(overrides = {}) { + return { + deviceId: "device-1", + label: "Studio", + capacityGb: 32, + models: ["gemma"], + state: "serving", + isSelf: false, + memberPubkey: "member-1", + reportedAt: 1_786_032_000, + modelSizeGb: 18, + ...overrides, + }; +} + +function snapshot(overrides = {}) { + return { + sharingDeviceCount: 1, + contributorMemberCount: 1, + sharedCapacityGb: 32, + allocatedCapacityGb: 18, + models: ["gemma"], + devices: [device()], + includesSelf: false, + memberCount: 4, + reason: null, + ...overrides, + }; +} + +test("cell buckets are exactly 1, 2, 4, and 7", () => { + assert.deepEqual( + [-1, 1, 1.1, 2, 2.1, 4, 4.1, 999, Number.NaN].map(bucketHexCellCount), + [1, 1, 2, 2, 4, 4, 7, 7, 1], + ); + assert.deepEqual( + [null, 128, 128.1, 256, 256.1, 512, 512.1, 1000].map( + capacityGbToHexCellCount, + ), + [1, 1, 2, 2, 4, 4, 7, 7], + ); +}); + +test("only ready serving models become deployments", () => { + const model = deriveCommunityComputeMapModel( + snapshot({ + sharingDeviceCount: 1, + devices: [ + device({ deviceId: "a", models: ["gemma", "qwen"] }), + device({ deviceId: "b", state: "loading", models: [] }), + device({ deviceId: "c", state: "standby", models: [] }), + device({ deviceId: "d", state: "consuming", models: [] }), + ], + }), + ); + + assert.equal(model.status, "ready"); + assert.deepEqual( + model.deployments.map((deployment) => deployment.modelId), + ["gemma", "qwen"], + ); + assert.equal(model.kpis.deploymentCount, 2); + assert.equal(model.kpis.contributorMemberCount, 1); + assert.equal(model.health.counts.healthy, 1); + assert.equal(model.health.counts.warming, 1); + assert.equal(model.health.counts.idle, 1); +}); + +test("one territory is produced per model and per device replica", () => { + const model = deriveCommunityComputeMapModel( + snapshot({ + devices: [ + device({ deviceId: "a", models: ["qwen", "gemma"] }), + device({ deviceId: "b", memberPubkey: "member-2", models: ["gemma"] }), + ], + }), + ); + assert.equal(model.deployments.length, 3); + assert.equal( + model.deployments.filter((deployment) => deployment.modelId === "gemma") + .length, + 2, + ); + assert.equal(new Set(model.deployments.map(({ id }) => id)).size, 3); +}); + +test("reported model footprint controls area without fixture overrides", () => { + const model = deriveCommunityComputeMapModel( + snapshot({ devices: [device({ capacityGb: 768, modelSizeGb: 96 })] }), + ); + assert.equal(model.deployments[0].cellCount, 1); + + const large = deriveCommunityComputeMapModel( + snapshot({ devices: [device({ capacityGb: 768, modelSizeGb: 646 })] }), + ); + assert.equal(large.deployments[0].cellCount, 7); +}); + +test("loading, consumer-only, and empty snapshots render no territories", () => { + for (const state of ["loading", "standby", "consuming"]) { + const model = deriveCommunityComputeMapModel( + snapshot({ devices: [device({ state, models: [] })] }), + ); + assert.equal(model.status, "empty"); + assert.equal(model.deployments.length, 0); + } + assert.equal(deriveCommunityComputeMapModel(null).status, "loading"); + assert.equal( + deriveCommunityComputeMapModel( + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES.empty.snapshot, + ).status, + "empty", + ); +}); + +test("projection is deterministic and independent of device/model order", () => { + const devices = [ + device({ deviceId: "z", label: "Zulu", models: ["qwen", "gemma"] }), + device({ deviceId: "a", label: "Alpha", models: ["mistral"] }), + ]; + const forward = deriveCommunityComputeMapModel(snapshot({ devices })); + const reverse = deriveCommunityComputeMapModel( + snapshot({ + devices: [...devices] + .reverse() + .map((item) => ({ ...item, models: [...item.models].reverse() })), + }), + ); + assert.deepEqual(forward, reverse); +}); + +test("tutorial fixtures use only production fields and production sizing", () => { + assert.deepEqual(Object.keys(COMMUNITY_COMPUTE_TUTORIAL_FIXTURES).sort(), [ + "empty", + "large-dense-500", + "single-device", + "small-company", + ]); + const allowed = new Set([ + "deviceId", + "label", + "capacityGb", + "models", + "state", + "isSelf", + "memberPubkey", + "reportedAt", + "modelSizeGb", + ]); + for (const fixture of Object.values(COMMUNITY_COMPUTE_TUTORIAL_FIXTURES)) { + for (const item of fixture.snapshot.devices) { + assert.deepEqual( + Object.keys(item).filter((key) => !allowed.has(key)), + [], + ); + } + } + const small = deriveCommunityComputeMapModel( + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES["small-company"].snapshot, + ); + assert.equal(small.deployments.length, 12); + assert.equal( + small.deployments.filter(({ deviceLabel }) => + deviceLabel.startsWith("Team MacBook"), + ).length, + 10, + ); + assert.equal( + small.deployments.filter(({ cellCount }) => cellCount === 1).length, + 10, + ); + assert.equal( + small.deployments.filter(({ cellCount }) => cellCount === 7).length, + 2, + ); +}); + +test("large dense fixture is deterministic and excludes non-serving rows", () => { + const fixture = LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE.snapshot; + assert.deepEqual(fixture, createLargeDenseSnapshot(500)); + assert.equal(fixture.devices.length, 500); + const servingCount = fixture.devices.filter( + (item) => item.state === "serving" && item.models.length > 0, + ).length; + const model = deriveCommunityComputeMapModel(fixture); + assert.equal(model.deployments.length, servingCount); + assert.equal(model.kpis.deploymentCount, servingCount); + assert.equal( + new Set(model.deployments.map(({ id }) => id)).size, + servingCount, + ); +}); diff --git a/desktop/src/features/mesh-compute/communityComputeMapModel.ts b/desktop/src/features/mesh-compute/communityComputeMapModel.ts new file mode 100644 index 0000000000..663b957d5f --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeMapModel.ts @@ -0,0 +1,365 @@ +import type { + MeshDeviceState, + MeshSnapshot, + MeshSnapshotDevice, +} from "@/shared/api/tauriMesh"; + +/** The only territory sizes rendered by the community compute map. */ +export const HEX_CELL_BUCKETS = [1, 2, 4, 7] as const; + +export type HexCellCount = (typeof HEX_CELL_BUCKETS)[number]; + +/** + * The Community view intentionally consumes only fields in the production + * snapshot contract. Tutorial fixtures use this same shape so examples cannot + * quietly grow capabilities that live Compute does not have. + */ +export type CommunityComputeDeviceInput = MeshSnapshotDevice; + +/** A readonly, structurally compatible form of the current mesh snapshot. */ +export type CommunityComputeSnapshotInput< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +> = Omit & { + readonly devices: readonly TDevice[]; +}; + +export type CommunityComputeDeploymentHealth = + | "healthy" + | "warming" + | "idle" + | "degraded" + | "offline" + | "unknown"; + +export type CommunityComputeMapHealth = + | "healthy" + | "attention" + | "critical" + | "empty" + | "unknown"; + +export type CommunityComputeDeployment< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +> = { + /** Stable map identity. Never derived from the device's array position. */ + id: string; + /** Model shown as the territory's primary identity. */ + label: string; + modelId: string; + deviceLabel: string; + capacityGb: number | null; + models: readonly string[]; + state: MeshDeviceState; + isSelf: boolean; + health: CommunityComputeDeploymentHealth; + healthReason: string | null; + cellCount: HexCellCount; + /** Preserves additive backend data for a future detail projection. */ + source: Readonly; +}; + +export type CommunityComputeKpis = { + memberCount: number; + contributorMemberCount: number; + deploymentCount: number; + activeDeploymentCount: number; + sharedCapacityGb: number | null; + allocatedCapacityGb: number | null; + allocatedPercent: number | null; + modelCount: number; +}; + +export type CommunityComputeHealthSummary = { + status: CommunityComputeMapHealth; + label: string; + reason: string | null; + counts: Record; +}; + +export type CommunityComputeMapModel< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +> = { + status: "loading" | "ready" | "empty"; + kpis: CommunityComputeKpis; + health: CommunityComputeHealthSummary; + deployments: readonly CommunityComputeDeployment[]; +}; + +/** + * Round a desired territory footprint up to one of the four supported shapes. + * Invalid and non-positive weights still get one cell so a deployment cannot + * disappear from the map. + */ +export function bucketHexCellCount( + weight: number | null | undefined, +): HexCellCount { + if (weight === null || weight === undefined || !Number.isFinite(weight)) { + return 1; + } + const normalized = Math.max(1, Math.ceil(weight)); + return HEX_CELL_BUCKETS.find((bucket) => normalized <= bucket) ?? 7; +} + +/** + * Turn model memory into a deliberately coarse footprint. + * + * A 128 GB Mac is the common mesh workhorse, so every model that fits on one is + * one cell. Multi-cell territories are reserved for genuinely multi-GPU scale + * deployments; this avoids making ordinary local models look fragmented. + */ +export function capacityGbToHexCellCount( + capacityGb: number | null | undefined, +): HexCellCount { + if (capacityGb === null || capacityGb === undefined || capacityGb <= 128) { + return 1; + } + if (capacityGb <= 256) { + return 2; + } + if (capacityGb <= 512) { + return 4; + } + return 7; +} + +const EMPTY_HEALTH_COUNTS: Record = { + healthy: 0, + warming: 0, + idle: 0, + degraded: 0, + offline: 0, + unknown: 0, +}; + +/** Project a backend snapshot into the complete, render-agnostic map model. */ +export function deriveCommunityComputeMapModel< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +>( + snapshot: CommunityComputeSnapshotInput | null, +): CommunityComputeMapModel { + if (snapshot === null) { + return { + status: "loading", + kpis: { + memberCount: 0, + contributorMemberCount: 0, + deploymentCount: 0, + activeDeploymentCount: 0, + sharedCapacityGb: null, + allocatedCapacityGb: null, + allocatedPercent: null, + modelCount: 0, + }, + health: { + status: "unknown", + label: "Checking community compute", + reason: null, + counts: { ...EMPTY_HEALTH_COUNTS }, + }, + deployments: [], + }; + } + + const deployments = projectDeployments(snapshot.devices); + const healthStates = snapshot.devices + .filter((device) => device.state !== "consuming") + .map((device) => healthFromState(device.state)); + const counts = countHealth(healthStates); + const models = new Set(snapshot.models); + for (const deployment of deployments) { + for (const model of deployment.models) { + models.add(model); + } + } + + const contributorMemberCount = + snapshot.contributorMemberCount ?? + new Set( + deployments + .filter((deployment) => deployment.state === "serving") + .map( + (deployment) => + deployment.source.memberPubkey?.trim() || deployment.id, + ), + ).size; + const sharedCapacityGb = finiteNonNegative(snapshot.sharedCapacityGb); + const reportedAllocated = finiteNonNegative(snapshot.allocatedCapacityGb); + const servingDeployments = deployments.filter( + (deployment) => deployment.state === "serving", + ); + const inferredAllocations = servingDeployments.map((deployment) => + finiteNonNegative(deployment.source.modelSizeGb), + ); + const allocatedCapacityGb = + reportedAllocated ?? + (inferredAllocations.length > 0 && + inferredAllocations.every((value) => value !== null) + ? inferredAllocations.reduce((total, value) => total + (value ?? 0), 0) + : null); + + return { + status: deployments.length === 0 ? "empty" : "ready", + kpis: { + memberCount: nonNegativeInteger(snapshot.memberCount), + contributorMemberCount, + deploymentCount: deployments.length, + activeDeploymentCount: counts.healthy + counts.warming, + sharedCapacityGb, + allocatedCapacityGb, + allocatedPercent: + allocatedCapacityGb !== null && + sharedCapacityGb !== null && + sharedCapacityGb > 0 + ? Math.min(100, (allocatedCapacityGb / sharedCapacityGb) * 100) + : null, + modelCount: models.size, + }, + health: summarizeHealth(counts, healthStates, snapshot.reason), + deployments, + }; +} + +function projectDeployments( + devices: readonly TDevice[], +): CommunityComputeDeployment[] { + const canonical = devices + .filter((device) => device.state === "serving" && device.models.length > 0) + .flatMap((device) => + [...new Set(device.models)].map((model) => ({ + device: { ...device, models: [model] } as TDevice, + identity: deploymentIdentity(device, model), + })), + ) + .sort( + (left, right) => + left.identity.localeCompare(right.identity) || + deviceFingerprint(left.device).localeCompare( + deviceFingerprint(right.device), + ), + ); + const occurrences = new Map(); + + return canonical.map(({ device, identity }) => { + const occurrence = occurrences.get(identity) ?? 0; + occurrences.set(identity, occurrence + 1); + const models = [...new Set(device.models)].sort(); + return { + id: occurrence === 0 ? identity : `${identity}#${occurrence + 1}`, + label: models[0] ?? device.label, + modelId: models[0] ?? "Model not reported", + deviceLabel: device.label, + capacityGb: finiteNonNegative(device.capacityGb), + models, + state: device.state, + isSelf: device.isSelf, + health: healthFromState(device.state), + healthReason: null, + cellCount: capacityGbToHexCellCount( + device.modelSizeGb ?? device.capacityGb, + ), + source: device, + }; + }); +} + +function deploymentIdentity( + device: MeshSnapshotDevice, + modelId: string, +): string { + const deviceId = cleanOptionalText(device.deviceId); + const model = encodeURIComponent(modelId); + return deviceId === null + ? `derived:${encodeURIComponent(deviceFingerprint(device))}:${model}` + : `deployment:${encodeURIComponent(deviceId)}:${model}`; +} + +function deviceFingerprint(device: MeshSnapshotDevice): string { + return JSON.stringify([ + device.label, + device.capacityGb, + [...device.models].sort(), + device.state, + device.isSelf, + ]); +} + +function healthFromState( + state: MeshDeviceState, +): CommunityComputeDeploymentHealth { + switch (state) { + case "serving": + case "consuming": + return "healthy"; + case "loading": + return "warming"; + case "standby": + return "idle"; + } +} + +function countHealth( + states: readonly CommunityComputeDeploymentHealth[], +): Record { + const counts = { ...EMPTY_HEALTH_COUNTS }; + for (const state of states) { + counts[state] += 1; + } + return counts; +} + +function summarizeHealth( + counts: Record, + states: readonly CommunityComputeDeploymentHealth[], + snapshotReason: string | null, +): CommunityComputeHealthSummary { + if (states.length === 0) { + return { + status: "empty", + label: "No deployments", + reason: cleanOptionalText(snapshotReason), + counts, + }; + } + + const unavailable = counts.offline + counts.degraded; + const reason = null; + + if (counts.offline === states.length) { + return { status: "critical", label: "Compute unavailable", reason, counts }; + } + if (unavailable > 0) { + return { + status: "attention", + label: `${unavailable} deployment${unavailable === 1 ? "" : "s"} need attention`, + reason, + counts, + }; + } + if (counts.unknown === states.length) { + return { status: "unknown", label: "Health unknown", reason: null, counts }; + } + return { + status: "healthy", + label: "No reported issues", + reason: null, + counts, + }; +} + +function finiteNonNegative(value: number | null | undefined): number | null { + return value !== null && + value !== undefined && + Number.isFinite(value) && + value >= 0 + ? value + : null; +} + +function nonNegativeInteger(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function cleanOptionalText(value: string | null | undefined): string | null { + const cleaned = value?.trim(); + return cleaned ? cleaned : null; +} diff --git a/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts b/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts index 1f305b07a3..62698ca81e 100644 --- a/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts +++ b/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts @@ -55,7 +55,11 @@ export function useMeshComputeState() { // transition, instead of waiting out the 30s poll. const [snapshotNonce, setSnapshotNonce] = React.useState(0); - const { snapshot, error: snapshotError } = useMeshSnapshot({ + const { + snapshot, + error: snapshotError, + refresh: refreshSnapshot, + } = useMeshSnapshot({ refreshKey: snapshotNonce, }); @@ -243,6 +247,7 @@ export function useMeshComputeState() { shareSwitchChecked, modelToShare, setSharing, + refreshSnapshot, // A transport failure is worth showing; an empty mesh is not an error and // is already expressed by the row tone. error: actionError ?? snapshotError, diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeComponents.test.mjs b/desktop/src/features/mesh-compute/ui/CommunityComputeComponents.test.mjs new file mode 100644 index 0000000000..d20ed6ebee --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeComponents.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + EMPTY_COMMUNITY_COMPUTE_FIXTURE, + SMALL_COMPANY_COMMUNITY_COMPUTE_FIXTURE, +} from "../communityComputeFixtures.ts"; +import { deriveCommunityComputeMapModel } from "../communityComputeMapModel.ts"; +import { CommunityComputeHealthScorecard } from "./CommunityComputeHealthScorecard.tsx"; +import { CommunityComputeKpiRow } from "./CommunityComputeKpiRow.tsx"; +import { CommunityComputeTerritoryMap } from "./CommunityComputeTerritoryMap.tsx"; + +const smallModel = deriveCommunityComputeMapModel( + SMALL_COMPANY_COMMUNITY_COMPUTE_FIXTURE.snapshot, +); + +describe("community compute visual components", () => { + it("renders contract-backed KPI values", () => { + const html = renderToStaticMarkup( + React.createElement(CommunityComputeKpiRow, { kpis: smallModel.kpis }), + ); + assert.match(html, /community-compute-kpi-members-sharing/); + assert.match(html, /community-compute-kpi-contributed-vram/); + assert.match(html, /community-compute-kpi-vram-allocated/); + assert.match(html, /24 community members/); + assert.match(html, />2,744 GB { + const html = renderToStaticMarkup( + React.createElement(CommunityComputeHealthScorecard, { + health: smallModel.health, + }), + ); + assert.match(html, /data-status="healthy"/); + assert.match(html, /No reported issues/); + assert.match(html, /data-testid="community-compute-health-healthy"/); + }); + + it("renders one accessible territory and list item per ready model", () => { + const html = renderToStaticMarkup( + React.createElement(CommunityComputeTerritoryMap, { model: smallModel }), + ); + assert.match(html, /data-testid="community-compute-map-svg"/); + assert.equal( + [ + ...html.matchAll( + /data-testid="community-compute-territory-deployment-/g, + ), + ].length, + smallModel.deployments.length, + ); + assert.equal( + [ + ...html.matchAll( + /data-testid="community-compute-deployment-deployment-/g, + ), + ].length, + smallModel.deployments.length, + ); + assert.match(html, /tabindex="0"/); + assert.match(html, /Search models or devices/); + assert.match(html, /Model footprint/); + assert.doesNotMatch(html, /Utilization|Region|Split across machines/); + }); + + it("keeps the production map static and supports explicit tutorial motion", () => { + const html = renderToStaticMarkup( + React.createElement(CommunityComputeTerritoryMap, { model: smallModel }), + ); + assert.doesNotMatch(html, /animate-mesh-inference/); + + const tutorialHtml = renderToStaticMarkup( + React.createElement(CommunityComputeTerritoryMap, { + model: smallModel, + inferenceDeploymentIds: [smallModel.deployments[0].id], + }), + ); + assert.match(tutorialHtml, /animate-mesh-inference/); + assert.doesNotMatch(html, /community-compute-split-boundary/); + }); + + it("renders a static empty state without a deployment list", () => { + const emptyModel = deriveCommunityComputeMapModel( + EMPTY_COMMUNITY_COMPUTE_FIXTURE.snapshot, + ); + const html = renderToStaticMarkup( + React.createElement(CommunityComputeTerritoryMap, { model: emptyModel }), + ); + assert.match(html, /data-testid="community-compute-map-empty"/); + assert.doesNotMatch(html, /community-compute-deployment-list/); + }); +}); diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeHealthScorecard.tsx b/desktop/src/features/mesh-compute/ui/CommunityComputeHealthScorecard.tsx new file mode 100644 index 0000000000..cbf39dc9a4 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeHealthScorecard.tsx @@ -0,0 +1,154 @@ +import { + AlertTriangle, + CheckCircle2, + CircleHelp, + MinusCircle, + XCircle, +} from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import type { + CommunityComputeDeploymentHealth, + CommunityComputeHealthSummary, +} from "../communityComputeMapModel"; + +export type CommunityComputeHealthScorecardProps = { + health: CommunityComputeHealthSummary; + className?: string; +}; + +const HEALTH_ROWS: readonly { + status: CommunityComputeDeploymentHealth; + label: string; +}[] = [ + { status: "healthy", label: "Healthy" }, + { status: "warming", label: "Warming" }, + { status: "idle", label: "Idle" }, + { status: "degraded", label: "Degraded" }, + { status: "offline", label: "Offline" }, + { status: "unknown", label: "Unknown" }, +]; + +/** Health summary with icon, copy, and count labels that do not rely on color. */ +export function CommunityComputeHealthScorecard({ + health, + className, +}: CommunityComputeHealthScorecardProps) { + const Icon = statusIcon(health.status); + const total = Object.values(health.counts).reduce( + (sum, count) => sum + count, + 0, + ); + + return ( +
+
+ +
+

+ {health.label} +

+

+ {health.reason ?? healthDescription(health.status, total)} +

+
+ + {total} total + +
+ +
+ {HEALTH_ROWS.map(({ status, label }) => ( +
0 && healthClass(status), + )} + data-testid={`community-compute-health-${status}`} + key={status} + > +
{label}
+
+ {health.counts[status]} +
+
+ ))} +
+
+ ); +} + +function statusIcon(status: CommunityComputeHealthSummary["status"]) { + switch (status) { + case "healthy": + return CheckCircle2; + case "attention": + return AlertTriangle; + case "critical": + return XCircle; + case "empty": + return MinusCircle; + case "unknown": + return CircleHelp; + } +} + +function healthClass(status: CommunityComputeDeploymentHealth): string { + switch (status) { + case "healthy": + return "border-solid bg-foreground/[0.04]"; + case "warming": + return "border-dashed bg-foreground/[0.03]"; + case "idle": + return "border-dotted"; + case "degraded": + return "border-dashed border-foreground/50"; + case "offline": + return "border-foreground/50 bg-muted/50 line-through decoration-muted-foreground/50"; + case "unknown": + return "border-dotted bg-muted/20"; + } +} + +function healthDescription( + status: CommunityComputeHealthSummary["status"], + total: number, +): string { + switch (status) { + case "healthy": + return "No reporting device needs attention; some may still be warming or idle."; + case "attention": + return "Some deployments may have reduced availability."; + case "critical": + return "Shared compute is currently unavailable."; + case "empty": + return "No deployments have joined this community yet."; + case "unknown": + return total === 0 + ? "Waiting for deployment health reports." + : "Deployment health could not be determined."; + } +} diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeKpiRow.tsx b/desktop/src/features/mesh-compute/ui/CommunityComputeKpiRow.tsx new file mode 100644 index 0000000000..7e77943f6e --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeKpiRow.tsx @@ -0,0 +1,83 @@ +import { cn } from "@/shared/lib/cn"; +import type { CommunityComputeKpis } from "../communityComputeMapModel"; + +export type CommunityComputeKpiRowProps = { + kpis: CommunityComputeKpis; + className?: string; +}; + +/** Compact, responsive headline figures for the community compute pool. */ +export function CommunityComputeKpiRow({ + kpis, + className, +}: CommunityComputeKpiRowProps) { + const items = [ + { + label: "Members sharing", + value: formatCount(kpis.contributorMemberCount), + detail: `${formatCount(kpis.memberCount)} community members`, + }, + { + label: "Contributed VRAM", + value: formatCapacity(kpis.sharedCapacityGb), + detail: `${formatCount(kpis.deploymentCount)} deployments`, + }, + { + label: "VRAM allocated", + value: + kpis.allocatedPercent === null + ? "—" + : `${Math.round(kpis.allocatedPercent)}%`, + detail: + kpis.allocatedCapacityGb === null + ? "Not fully reported" + : `${formatCapacity(kpis.allocatedCapacityGb)} hosting models`, + }, + ]; + + return ( +
+ {items.map((item, index) => ( +
0 && "border-border/60 border-t sm:border-l sm:border-t-0", + )} + data-testid={`community-compute-kpi-${kpiTestId(item.label)}`} + key={item.label} + > +
+ {item.label} +
+
+ {item.value} +
+
+ {item.detail} +
+
+ ))} +
+ ); +} + +function formatCount(value: number): string { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format( + value, + ); +} + +function formatCapacity(value: number | null): string { + if (value === null) return "—"; + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)} GB`; +} + +function kpiTestId(label: string): string { + return label.toLowerCase().replaceAll(" ", "-"); +} diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx b/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx new file mode 100644 index 0000000000..684078dd40 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx @@ -0,0 +1,568 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { + hexTerritoryBoundaryEdges, + layoutCommunityComputeHexTerritories, + type CommunityComputeHexCell, +} from "../communityComputeHexLayout"; +import type { + CommunityComputeDeployment, + CommunityComputeDeploymentHealth, + CommunityComputeMapModel, +} from "../communityComputeMapModel"; + +export type CommunityComputeTerritoryMapProps = { + model: CommunityComputeMapModel; + selectedDeploymentId?: string | null; + onSelectedDeploymentChange?: (deploymentId: string) => void; + className?: string; + /** Tutorial-only simulated activity; live snapshots cannot attribute requests. */ + inferenceDeploymentIds?: readonly string[]; +}; + +type Point = { x: number; y: number }; + +type ViewBox = { + x: number; + y: number; + width: number; + height: number; +}; + +// Deliberately overlap polygons, then trim their outside with the territory +// edge. This avoids SVG antialias seams inside one model footprint. +const FUSED_TILE_RADIUS = 1.025; +const TILE_EDGE_WIDTH = 0.1; +const SQRT_THREE = Math.sqrt(3); + +/** + * Responsive SVG territory map. Each deployment owns one stable, contiguous + * cluster of discrete hexes; health changes never move that cluster. + */ +export function CommunityComputeTerritoryMap({ + model, + selectedDeploymentId, + onSelectedDeploymentChange, + className, + inferenceDeploymentIds = [], +}: CommunityComputeTerritoryMapProps) { + const [internalSelection, setInternalSelection] = React.useState< + string | null + >(null); + const deploymentById = React.useMemo( + () => + new Map( + model.deployments.map((deployment) => [deployment.id, deployment]), + ), + [model.deployments], + ); + const previousLayoutRef = React.useRef | null>(null); + const layout = React.useMemo( + () => + layoutCommunityComputeHexTerritories( + model.deployments.map(({ id, cellCount }) => ({ id, cellCount })), + { previous: previousLayoutRef.current }, + ), + [model.deployments], + ); + React.useEffect(() => { + previousLayoutRef.current = layout; + }, [layout]); + const fallbackId = model.deployments[0]?.id ?? null; + const requestedId = selectedDeploymentId ?? internalSelection; + const activeId = + requestedId !== null && deploymentById.has(requestedId) + ? requestedId + : fallbackId; + const activeDeployment = activeId + ? (deploymentById.get(activeId) ?? null) + : null; + const viewBox = computeViewBox(layout.cells); + const inferenceIds = React.useMemo( + () => new Set(inferenceDeploymentIds), + [inferenceDeploymentIds], + ); + const [hoveredId, setHoveredId] = React.useState(null); + const [deploymentSearch, setDeploymentSearch] = React.useState(""); + const hoveredDeployment = hoveredId + ? (deploymentById.get(hoveredId) ?? null) + : null; + + function selectDeployment(deploymentId: string) { + if (selectedDeploymentId === undefined) { + setInternalSelection(deploymentId); + } + onSelectedDeploymentChange?.(deploymentId); + } + + if (model.status === "loading") { + return ( +
+
+

+ Mapping community deployments… +

+
+ ); + } + + if (model.status === "empty" || viewBox === null) { + return ( +
+ +

No compute territories yet

+

+ Deployments appear here when members share compute with the community. +

+
+ ); + } + + return ( +
+
+ + {layout.territories.map((territory) => { + const deployment = deploymentById.get(territory.deploymentId); + if (!deployment) return null; + const selected = deployment.id === activeId; + const inferenceActive = inferenceIds.has(deployment.id); + return ( + // SVG groups have no semantic button equivalent; keyboard selection remains in the adjacent deployment list. + // biome-ignore lint/a11y/useSemanticElements: SVG has no native button element. + selectDeployment(deployment.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectDeployment(deployment.id); + } + }} + onMouseEnter={() => setHoveredId(deployment.id)} + onMouseLeave={() => setHoveredId(null)} + role="button" + tabIndex={0} + > + {territoryAccessibleLabel(deployment)} + + + ); + })} + + {hoveredDeployment ? ( +
+

+ {shortModelName(hoveredDeployment.modelId)} +

+

+ {hoveredDeployment.deviceLabel} ·{" "} + {formatCapacity(hoveredDeployment.capacityGb)} +

+

+ {inferenceIds.has(hoveredDeployment.id) + ? "Simulated inference" + : hoveredDeployment.health}{" "} + · {hoveredDeployment.cellCount}{" "} + {hoveredDeployment.cellCount === 1 ? "cell" : "cells"} +

+
+ ) : null} +
+ +
+ + {activeDeployment ? ( + + ) : null} +
+
+ ); +} + +function TerritoryShape({ + cells, + inferenceActive, +}: { + cells: readonly CommunityComputeHexCell[]; + inferenceActive: boolean; +}) { + const boundary = hexTerritoryBoundaryEdges(cells); + const fillClass = "fill-foreground"; + const territoryAnimationStyle = inferenceActive + ? ({ + "--mesh-breath-duration": `${5.8 + (stableVisualHash(cells[0]?.deploymentId ?? "") % 7) * 0.24}s`, + animationDelay: `${-((stableVisualHash(`${cells[0]?.deploymentId}:phase`) % 37) / 10)}s`, + } as React.CSSProperties) + : undefined; + return ( + + {cells.map((cell) => { + const center = axialToPoint(cell.q, cell.r); + return ( + + ); + })} + + + ); +} + +function edgesToPath( + edges: ReturnType, +): string { + return edges + .map( + (edge) => + `M ${edge.start.x} ${edge.start.y} L ${edge.end.x} ${edge.end.y}`, + ) + .join(" "); +} + +function DeploymentList({ + deployments, + activeId, + onSearchChange, + onSelect, + search, +}: { + deployments: readonly CommunityComputeDeployment[]; + activeId: string | null; + onSearchChange: (value: string) => void; + onSelect: (id: string) => void; + search: string; +}) { + const query = search.trim().toLowerCase(); + const visibleDeployments = query + ? deployments.filter((deployment) => + [deployment.modelId, deployment.deviceLabel] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(query)), + ) + : deployments; + return ( +
+
+

Deployments

+ + {visibleDeployments.length} of {deployments.length} + +
+ onSearchChange(event.target.value)} + placeholder="Search models or devices" + type="search" + value={search} + /> +
    + {visibleDeployments.map((deployment) => { + const selected = deployment.id === activeId; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +function DeploymentDetails({ + deployment, +}: { + deployment: CommunityComputeDeployment; +}) { + return ( +
+
+ +
+

+ {shortModelName(deployment.modelId)} +

+

+ {deployment.deviceLabel} + {deployment.isSelf ? " · This machine" : ""} +

+

+ {deployment.health} · {deployment.cellCount}{" "} + {deployment.cellCount === 1 ? "cell" : "cells"} +

+
+
+ {deployment.healthReason ? ( +

+ {deployment.healthReason} +

+ ) : null} +
+ + + + +
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function HealthGlyph({ health }: { health: CommunityComputeDeploymentHealth }) { + const cue = healthCue(health); + return ( + + ); +} + +function EmptyHexes() { + const centers = [ + { x: 2, y: 1.75 }, + { x: 3.58, y: 2.65 }, + { x: 2, y: 3.55 }, + { x: 0.42, y: 2.65 }, + ]; + return ( + + ); +} + +function axialToPoint(q: number, r: number): Point { + return { x: SQRT_THREE * (q + r / 2), y: 1.5 * r }; +} + +function hexPoints(center: Point, radius: number): string { + return Array.from({ length: 6 }, (_, index) => { + const angle = ((60 * index - 30) * Math.PI) / 180; + return `${center.x + radius * Math.cos(angle)},${center.y + radius * Math.sin(angle)}`; + }).join(" "); +} + +function computeViewBox( + cells: readonly CommunityComputeHexCell[], +): ViewBox | null { + if (cells.length === 0) return null; + const centers = cells.map((cell) => axialToPoint(cell.q, cell.r)); + const minX = Math.min(...centers.map(({ x }) => x)) - 1.4; + const maxX = Math.max(...centers.map(({ x }) => x)) + 1.4; + const minY = Math.min(...centers.map(({ y }) => y)) - 1.4; + const maxY = Math.max(...centers.map(({ y }) => y)) + 1.4; + return { + x: minX, + y: minY, + width: Math.max(2.8, maxX - minX), + height: Math.max(2.8, maxY - minY), + }; +} + +function healthCue(health: CommunityComputeDeploymentHealth): { + dashArray?: string; + glyph: string; + strokeWidth: number; +} { + switch (health) { + case "healthy": + return { glyph: "✓", strokeWidth: 0.075 }; + case "warming": + return { glyph: "↻", dashArray: "0.2 0.12", strokeWidth: 0.085 }; + case "idle": + return { glyph: "–", dashArray: "0.04 0.14", strokeWidth: 0.08 }; + case "degraded": + return { glyph: "!", dashArray: "0.3 0.12", strokeWidth: 0.11 }; + case "offline": + return { glyph: "×", dashArray: "0.08 0.1", strokeWidth: 0.075 }; + case "unknown": + return { glyph: "?", dashArray: "0.03 0.12", strokeWidth: 0.075 }; + } +} + +function stableVisualHash(value: string): number { + let hash = 2166136261; + for (const character of value) { + hash ^= character.codePointAt(0) ?? 0; + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function shortModelName(modelId: string): string { + const tail = modelId.split("/").at(-1) ?? modelId; + return tail.length > 34 ? `${tail.slice(0, 31)}…` : tail; +} + +function territoryAccessibleLabel( + deployment: CommunityComputeDeployment, +): string { + const capacity = formatCapacity(deployment.capacityGb); + return `${deployment.modelId} on ${deployment.deviceLabel}, ${deployment.health}, ${capacity}, ${deployment.cellCount} ${deployment.cellCount === 1 ? "cell" : "cells"}`; +} + +function formatCapacity(value: number | null): string { + return value === null + ? "Capacity not reported" + : `${Math.round(value)} GB shared`; +} + +function formatReportedAt(value: number | null | undefined): string { + if (value === null || value === undefined) return "Not reported"; + const milliseconds = value < 10_000_000_000 ? value * 1000 : value; + return new Date(milliseconds).toLocaleString(); +} + +function testId(value: string): string { + return encodeURIComponent(value).replaceAll("%", "-"); +} diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeTutorialPanel.tsx b/desktop/src/features/mesh-compute/ui/CommunityComputeTutorialPanel.tsx new file mode 100644 index 0000000000..7a97ccdacf --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeTutorialPanel.tsx @@ -0,0 +1,146 @@ +import { ArrowLeft, ArrowRight, FlaskConical, X } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import type { CommunityComputeTutorialFixtureId } from "../communityComputeFixtures"; + +const STEPS = [ + { + title: "Every territory is a model deployment", + body: "Each connected group is one ready model on one contributed device. If one device serves multiple models, each model gets its own territory; another device serving the same model appears separately.", + }, + { + title: "Area represents memory", + body: "Models snap to four coarse footprints: 1, 2, 4, or 7 cells. The footprint comes from reported model size, or shared device capacity when model size is unavailable.", + }, + { + title: "Motion illustrates inference in flight", + body: "For this tutorial, breathing territories simulate models handling requests. The live community snapshot cannot yet identify the deployment serving a request, so the live map remains static.", + }, + { + title: "The headline measures the pool", + body: "Members sharing counts contributors, contributed VRAM is the memory they offered, and VRAM allocated is the reported model footprint—not GPU processing utilization.", + }, + { + title: "Health summarizes reported state", + body: "The scorecard counts ready, warming, and idle deployments from current status reports. It does not infer utilization, offline devices, replica resilience, or capacity headroom.", + }, +] as const; + +export function CommunityComputeTutorialPanel({ + fixtureId, + onExit, + onFixtureChange, + onStepChange, + step, +}: { + fixtureId: CommunityComputeTutorialFixtureId; + onExit: () => void; + onFixtureChange: (fixture: CommunityComputeTutorialFixtureId) => void; + onStepChange: (step: number) => void; + step: number; +}) { + const current = STEPS[step] ?? STEPS[0]; + const showDeveloperControls = + import.meta.env.DEV || import.meta.env.MODE === "e2e"; + + return ( +
+
+
+

+ Tutorial · {step + 1} of {STEPS.length} +

+

+ {current.title} +

+

+ {current.body} +

+
+ +
+ +
+ + +
+ {STEPS.map((item, index) => ( +
+
+ + {showDeveloperControls ? ( +
+ + + + + Development-only · deterministic frontend data + +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx new file mode 100644 index 0000000000..ba14693088 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx @@ -0,0 +1,122 @@ +import { Info, RefreshCw } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; +import type { MeshSnapshot } from "@/shared/api/tauriMesh"; +import { + deriveCommunityComputeMapModel, + type CommunityComputeSnapshotInput, +} from "../communityComputeMapModel"; +import { CommunityComputeHealthScorecard } from "./CommunityComputeHealthScorecard"; +import { CommunityComputeKpiRow } from "./CommunityComputeKpiRow"; +import { CommunityComputeTerritoryMap } from "./CommunityComputeTerritoryMap"; + +export function MeshComputeCommunityView({ + communityName, + error, + onRefresh, + simulated, + snapshot, + tutorialInference = false, +}: { + communityName: string; + error: string | null; + onRefresh: () => void; + simulated: boolean; + snapshot: MeshSnapshot | CommunityComputeSnapshotInput | null; + tutorialInference?: boolean; +}) { + const model = deriveCommunityComputeMapModel(snapshot); + const inferenceDeploymentIds = tutorialInference + ? model.deployments + .filter( + (deployment, index) => + deployment.cellCount > 1 || index % 3 === 0 || index % 7 === 2, + ) + .map((deployment) => deployment.id) + : []; + const observedAt = (snapshot as MeshSnapshot | null)?.observedAt; + + return ( +
+ {simulated ? ( +
+ + + Simulated mesh for learning and development. Live community data is + unchanged. + +
+ ) : null} + + {error && !simulated ? ( +
+ + Couldn’t load {communityName} compute: {error} + + +
+ ) : null} + + + +
+
+
+

Model mesh

+

+ Each territory is one ready model on one device. Area reflects its + reported model-memory tier. +

+
+ {!simulated ? ( + + ) : null} +
+ +

+ {simulated + ? "Deterministic tutorial snapshot" + : observedAt + ? `Recently reported · updated ${formatAge(observedAt)}` + : "Recently reported by community members · presence may take up to two minutes to expire"} +

+
+ +
+
+

Mesh health

+

+ Current ready, warming, and idle states from community status + reports. +

+
+ +
+
+ ); +} + +function formatAge(timestamp: number): string { + const timestampMs = timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp; + const seconds = Math.max(0, Math.round((Date.now() - timestampMs) / 1000)); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + return `${Math.floor(seconds / 60)}m ago`; +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputePage.tsx b/desktop/src/features/mesh-compute/ui/MeshComputePage.tsx new file mode 100644 index 0000000000..574ddb9b86 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshComputePage.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import { BookOpen } from "lucide-react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { Button } from "@/shared/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES, + type CommunityComputeTutorialFixtureId, +} from "../communityComputeFixtures"; +import { useMeshComputeState } from "../hooks/useMeshComputeState"; +import { CommunityComputeTutorialPanel } from "./CommunityComputeTutorialPanel"; +import { MeshComputeCommunityView } from "./MeshComputeCommunityView"; +import { MeshComputeSettingsCard } from "./MeshComputeSettingsCard"; +import { MeshComputeShareBanner } from "./MeshComputeShareBanner"; + +export function MeshComputePage() { + const { activeCommunity } = useCommunities(); + const mesh = useMeshComputeState(); + const [tab, setTab] = React.useState("community"); + const [tutorialOpen, setTutorialOpen] = React.useState(false); + const [tutorialStep, setTutorialStep] = React.useState(0); + const [fixtureId, setFixtureId] = + React.useState("small-company"); + const communityName = activeCommunity?.name ?? "this community"; + const tutorialFixture = COMMUNITY_COMPUTE_TUTORIAL_FIXTURES[fixtureId]; + + function toggleTutorial() { + if (tutorialOpen) { + setTutorialOpen(false); + return; + } + setTab("community"); + setTutorialOpen(true); + setTutorialStep(0); + setFixtureId("small-company"); + } + + return ( +
+ + Tutorial + + } + description={`See what ${communityName} can run and contribute this machine’s spare capacity.`} + title="Compute" + /> + +
+ {tutorialOpen ? null : ( + + )} + + + + + Community + + + Settings + + + + + {tutorialOpen ? ( + setTutorialOpen(false)} + onFixtureChange={setFixtureId} + onStepChange={setTutorialStep} + step={tutorialStep} + /> + ) : null} + + + + + + + +
+
+ ); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index 940ffc45b0..42dc6e798b 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -68,7 +68,11 @@ const SHARE_COMPUTE_REVEAL_TRANSITION = { * group. User-facing copy describes the shared-compute behavior without * exposing implementation protocols or raw mesh controls. */ -export function MeshComputeSettingsCard() { +export function MeshComputeSettingsCard({ + hideHeader = false, +}: { + hideHeader?: boolean; +} = {}) { const shouldReduceMotion = useReducedMotion(); const { status, error, refresh } = useMeshNodeStatus(); const [installedModels, setInstalledModels] = React.useState< @@ -203,10 +207,12 @@ export function MeshComputeSettingsCard() { return (
- + {hideHeader ? null : ( + + )} {error ? (

diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeShareBanner.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeShareBanner.tsx new file mode 100644 index 0000000000..925e7f181b --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshComputeShareBanner.tsx @@ -0,0 +1,113 @@ +import { Cpu, LoaderCircle } from "lucide-react"; + +import { Switch } from "@/shared/ui/switch"; +import { cn } from "@/shared/lib/cn"; +import type { useMeshComputeState } from "../hooks/useMeshComputeState"; + +export function MeshComputeShareBanner({ + communityName, + mesh, +}: { + communityName: string; + mesh: ReturnType; +}) { + const { + error, + modelToShare, + pendingAction, + shareSwitchChecked, + status, + toggle, + setSharing, + } = mesh; + const busy = pendingAction !== null; + const statusCopy = shareStatusCopy({ + communityName, + modelToShare, + pendingAction, + statusModel: status?.modelName ?? status?.modelId ?? null, + isConsuming: toggle.isConsuming, + isSharing: toggle.isSharing, + }); + + return ( +

+
+ +
+ +

+ {statusCopy} +

+
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function shareStatusCopy({ + communityName, + isConsuming, + isSharing, + modelToShare, + pendingAction, + statusModel, +}: { + communityName: string; + isConsuming: boolean; + isSharing: boolean; + modelToShare: string | null; + pendingAction: "start" | "stop" | null; + statusModel: string | null; +}): string { + if (pendingAction === "start") { + return `Preparing ${statusModel ?? modelToShare ?? "a model"} for ${communityName}…`; + } + if (pendingAction === "stop") return "Stopping shared compute…"; + if (isSharing) { + return `${statusModel ?? modelToShare ?? "A model"} is available to ${communityName}`; + } + if (isConsuming) { + return "Using another member’s compute; turn this on to contribute instead"; + } + if (modelToShare) { + return `${modelToShare} is ready to contribute to ${communityName}`; + } + return "Choose a model in Settings before sharing"; +} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx index 70b2d72dcb..2dcc3da115 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx @@ -7,7 +7,6 @@ import { cn } from "@/shared/lib/cn"; import { useMeshComputeState } from "../hooks/useMeshComputeState"; import type { MeshRowPulse, MeshRowTone } from "../meshRowModel"; -import { MeshComputePopover } from "./MeshComputePopover"; /** * The sidebar Shared Compute row. @@ -97,49 +96,42 @@ export function SidebarMeshComputeRow({ const mesh = useMeshComputeState(); const { row, toggle, pendingAction, shareSwitchChecked, setSharing } = mesh; - // The row offers joining; the popover owns managing. Once this machine is - // sharing there is nothing left to invite, and the popover's labelled switch - // is the place to stop — a bare unlabelled switch is a poor control for an - // action with consequences. + // Clicking the row is navigation: Shared Compute opens the community Compute + // view. The trailing switch remains a separate action and does not navigate. const showJoinToggle = !toggle.isSharing; return ( - - - - - {/* + + {/* Icon-collapsed only. The label and the dot beside it are both clipped in icon mode, so without this the row would lose its status signal entirely at exactly the width where the tooltip is the only other affordance. */} - - - + + - - {row.label} - - {/* + + {row.label} + {/* Right-aligned, the same position and mechanism a channel row uses for its unread dot (`UnreadDotBadge className="ml-auto"`). Beside the name it competed with the label for one optical line; here it reads as this @@ -148,20 +140,18 @@ export function SidebarMeshComputeRow({ When the join switch is present the button carries `pr-11`, so the dot lands clear of it rather than underneath. */} - - - - - + + + + {/* - A sibling of the button, never a child: `PopoverTrigger asChild` makes the - button itself the trigger, so a switch nested inside would be a button - within a button and every toggle would also open the popover. + A sibling of the navigation button, never a child: nesting this switch + would make toggling sharing navigate away from the current view. */} {showJoinToggle ? (
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 901c9152b7..5246f4d5d8 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -77,7 +77,7 @@ import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; -import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; +import { MeshComputePage } from "@/features/mesh-compute/ui/MeshComputePage"; import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; @@ -883,7 +883,7 @@ export function renderSettingsSection( case "channel-templates": return ; case "compute": - return ; + return ; case "appearance": return ; case "shortcuts": diff --git a/desktop/src/shared/api/tauriMesh.ts b/desktop/src/shared/api/tauriMesh.ts index 2206912acd..b4e094c032 100644 --- a/desktop/src/shared/api/tauriMesh.ts +++ b/desktop/src/shared/api/tauriMesh.ts @@ -156,6 +156,10 @@ export type MeshSnapshotDevice = { models: string[]; state: MeshDeviceState; isSelf: boolean; + /** Additive Community View fields. Older desktop/runtime pairs omit these. */ + memberPubkey?: string | null; + reportedAt?: number | null; + modelSizeGb?: number | null; }; /** @@ -167,11 +171,17 @@ export type MeshSnapshotDevice = { */ export type MeshSnapshot = { sharingDeviceCount: number; + /** Unique members with at least one ready serving device, when reported. */ + contributorMemberCount?: number; /** `null` when no sharing device reported a figure — render the count only. */ sharedCapacityGb: number | null; + /** Approximate hosted-model footprint when every contributor reports it. */ + allocatedCapacityGb?: number | null; models: string[]; devices: MeshSnapshotDevice[]; includesSelf: boolean; + observedAt?: number; + freshnessSeconds?: number; /** * Members in the community right now (NIP-43 roster size). * diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6f5467c39f..9dee1f82c5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1231,6 +1231,8 @@ declare global { */ nodeMode?: "serve" | "client" | null; /** Seed host-side serving usage to exercise the "who's using my compute" indicator. */ + snapshotDevices?: MockMeshSnapshotDevice[]; + snapshotMemberCount?: number; servingUsage?: Partial<{ inflight: number; peakInflight: number; @@ -3014,6 +3016,18 @@ function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { // in a browser. They deliberately do NOT model real admission, real inference, // or real mesh routing — those are proven by the Rust layer-2 tests and the // on-hardware layer-1 example. Do not port any of this into production code. +type MockMeshSnapshotDevice = { + deviceId: string | null; + label: string; + capacityGb: number | null; + models: string[]; + state: "serving" | "loading" | "standby" | "consuming"; + isSelf: boolean; + memberPubkey?: string | null; + reportedAt?: number | null; + modelSizeGb?: number | null; +}; + type MockServingUsage = { inflight: number; peakInflight: number; @@ -3046,6 +3060,8 @@ const mockMeshState: { nodeState: "off" | "running"; nodeMode: "serve" | "client" | null; servingUsage: MockServingUsage; + snapshotDevices: MockMeshSnapshotDevice[] | null; + snapshotMemberCount: number | null; } = { admitted: true, models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }], @@ -3054,6 +3070,8 @@ const mockMeshState: { nodeState: "off", nodeMode: null, servingUsage: { ...ZERO_SERVING_USAGE }, + snapshotDevices: null, + snapshotMemberCount: null, }; function resetMockMesh() { @@ -3064,6 +3082,8 @@ function resetMockMesh() { mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; mockMeshState.servingUsage = { ...ZERO_SERVING_USAGE }; + mockMeshState.snapshotDevices = null; + mockMeshState.snapshotMemberCount = null; } let mockPersonas: RawPersona[] = []; let mockTeams: RawTeam[] = []; @@ -10253,6 +10273,10 @@ export function maybeInstallE2eTauriMocks() { mockMeshState.denyReason = mesh.denyReason; if (mesh.nodeState !== undefined) mockMeshState.nodeState = mesh.nodeState; if (mesh.nodeMode !== undefined) mockMeshState.nodeMode = mesh.nodeMode; + if (mesh.snapshotDevices !== undefined) + mockMeshState.snapshotDevices = mesh.snapshotDevices; + if (mesh.snapshotMemberCount !== undefined) + mockMeshState.snapshotMemberCount = mesh.snapshotMemberCount; if (mesh.servingUsage !== undefined) mockMeshState.servingUsage = { ...mockMeshState.servingUsage, @@ -10916,19 +10940,54 @@ export function maybeInstallE2eTauriMocks() { return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); case "mesh_serving_usage": return mockMeshState.servingUsage; - case "mesh_snapshot": + case "mesh_snapshot": { + const devices = + mockMeshState.snapshotDevices ?? + (mockMeshState.nodeMode === "serve" && mockMeshState.activeModel + ? [ + { + deviceId: "mock-self", + label: "This machine", + capacityGb: 32, + models: [mockMeshState.activeModel.id], + state: "serving" as const, + isSelf: true, + memberPubkey: MOCK_IDENTITY_PUBKEY, + reportedAt: Math.floor(Date.now() / 1000), + modelSizeGb: 3.5, + }, + ] + : []); + const serving = devices.filter((device) => device.state === "serving"); return { - sharingDeviceCount: mockMeshState.nodeMode === "serve" ? 1 : 0, - sharedCapacityGb: mockMeshState.nodeMode === "serve" ? 32 : null, - models: mockMeshState.activeModel - ? [mockMeshState.activeModel.id] - : [], - devices: [], - includesSelf: mockMeshState.nodeMode === "serve", - memberCount: 1, - reason: - mockMeshState.nodeMode === "serve" ? null : "No one is sharing", + sharingDeviceCount: serving.length, + contributorMemberCount: new Set( + serving.map((device) => device.memberPubkey ?? device.deviceId), + ).size, + sharedCapacityGb: + serving.length > 0 + ? serving.reduce( + (total, device) => total + (device.capacityGb ?? 0), + 0, + ) + : null, + allocatedCapacityGb: + serving.length > 0 && + serving.every((device) => device.modelSizeGb != null) + ? serving.reduce( + (total, device) => total + (device.modelSizeGb ?? 0), + 0, + ) + : null, + models: [...new Set(serving.flatMap((device) => device.models))], + devices, + includesSelf: serving.some((device) => device.isSelf), + memberCount: mockMeshState.snapshotMemberCount ?? 1, + observedAt: Math.floor(Date.now() / 1000), + freshnessSeconds: 120, + reason: serving.length > 0 ? null : "No one is sharing", }; + } case "mesh_live_view": return { connected: mockMeshState.nodeState === "running", diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 491cd87063..716821f15f 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -53,10 +53,18 @@ export default { "0%, 100%": { opacity: "1" }, "50%": { opacity: "0.25" }, }, + "mesh-breath": { + "0%, 100%": { fill: "hsl(var(--foreground))" }, + "50%": { + fill: "color-mix(in oklab, hsl(var(--foreground)) 25%, hsl(var(--background)))", + }, + }, }, animation: { // Work in flight right now. "mesh-activity": "mesh-dim 1s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "mesh-inference": + "mesh-breath var(--mesh-breath-duration, 4.8s) ease-in-out infinite", // A start or stop in flight. Slower: nothing is wrong, and nothing needs // a decision. "mesh-progress": "mesh-dim 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite", diff --git a/desktop/tests/e2e/mesh-compute-screenshots.spec.ts b/desktop/tests/e2e/mesh-compute-screenshots.spec.ts new file mode 100644 index 0000000000..939feddc6d --- /dev/null +++ b/desktop/tests/e2e/mesh-compute-screenshots.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; +import { waitForAnimations } from "../helpers/animations"; + +test("captures the Community Compute tutorial", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await openSettings(page, "compute"); + await page.getByTestId("community-compute-tutorial-button").click(); + await expect( + page.getByTestId("community-compute-territory-map"), + ).toBeVisible(); + await expect(page.getByTestId("compute-share-banner")).toHaveCount(0); + await waitForAnimations(page); + await page.getByTestId("settings-panel-compute").screenshot({ + path: "test-results/mesh-compute/community-tutorial.png", + }); + await page.getByTestId("community-compute-territory-map").screenshot({ + path: "test-results/mesh-compute/community-map.png", + }); + + await page.getByLabel("Preview scenario").selectOption("large-dense-500"); + await expect( + page.getByTestId("community-compute-kpi-members-sharing"), + ).toContainText("458"); + await expect(page.locator("[data-cell-count]")).toHaveCount(458); + await expect( + page.locator('[data-inference-active="true"]').first(), + ).toBeVisible(); + await waitForAnimations(page); + await page.getByTestId("settings-panel-compute").screenshot({ + path: "test-results/mesh-compute/community-dense.png", + }); +}); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index c55b9db5d9..37c430bcbb 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -12,6 +12,17 @@ type E2eWindow = Window & { __BUZZ_E2E_SET_MESH__?: (mesh: { nodeState?: "off" | "running"; nodeMode?: "serve" | "client" | null; + snapshotMemberCount?: number; + snapshotDevices?: Array<{ + deviceId: string; + label: string; + capacityGb: number; + models: string[]; + state: "serving" | "loading" | "standby" | "consuming"; + isSelf: boolean; + memberPubkey?: string; + modelSizeGb?: number; + }>; }) => void; }; @@ -20,6 +31,7 @@ test("Share compute chooses a model before sharing", async ({ page }) => { await installMockBridge(page); await page.goto("/"); await openSettings(page, "compute"); + await page.getByTestId("compute-tab-settings").click(); const card = page.getByTestId("settings-mesh-share-compute"); const toggle = page.getByTestId("mesh-share-compute-toggle"); @@ -115,6 +127,7 @@ test("a consuming client can switch to sharing its saved local model", async ({ }); }); await openSettings(page, "compute"); + await page.getByTestId("compute-tab-settings").click(); const card = page.getByTestId("settings-mesh-share-compute"); const toggle = page.getByTestId("mesh-share-compute-toggle"); @@ -147,3 +160,134 @@ test("a consuming client can switch to sharing its saved local model", async ({ }, }); }); + +test("Shared Compute sidebar row opens the Compute community view", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("sidebar-mesh-compute-row").click(); + + await expect(page.getByTestId("settings-mesh-compute-page")).toBeVisible(); + await expect(page.getByTestId("compute-tab-community")).toHaveAttribute( + "data-state", + "active", + ); + await expect(page.getByTestId("community-compute-view")).toBeVisible(); + await expect(page.getByTestId("mesh-compute-popover")).toHaveCount(0); +}); + +test("Community Compute shows live KPIs and the tutorial simulation", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof (window as E2eWindow).__BUZZ_E2E_SET_MESH__ === "function", + ); + await page.evaluate(() => { + (window as E2eWindow).__BUZZ_E2E_SET_MESH__?.({ + snapshotMemberCount: 7, + snapshotDevices: [ + { + deviceId: "alpha", + label: "Alpha Studio", + capacityGb: 64, + models: ["qwen-32b"], + state: "serving", + isSelf: false, + memberPubkey: "member-alpha", + modelSizeGb: 28, + }, + { + deviceId: "beta", + label: "Beta Workstation", + capacityGb: 32, + models: ["gemma-12b"], + state: "serving", + isSelf: false, + memberPubkey: "member-beta", + modelSizeGb: 12, + }, + ], + }); + }); + await openSettings(page, "compute"); + + await expect(page.getByTestId("compute-share-banner")).toBeVisible(); + await expect( + page.getByTestId("community-compute-kpi-members-sharing"), + ).toContainText("2"); + await expect( + page.getByTestId("community-compute-kpi-contributed-vram"), + ).toContainText("96 GB"); + await expect( + page.getByTestId("community-compute-kpi-vram-allocated"), + ).toContainText("42%"); + await expect( + page.getByTestId("community-compute-territory-map"), + ).toBeVisible(); + await expect( + page.getByTestId("community-compute-deployment-list"), + ).toContainText("Alpha Studio"); + await page.getByLabel("Search deployments").fill("Beta"); + await expect( + page.getByTestId("community-compute-deployment-list"), + ).toContainText("Beta Workstation"); + await expect( + page.getByTestId("community-compute-deployment-list"), + ).not.toContainText("Alpha Studio"); + await page.getByLabel("Search deployments").fill(""); + + const tutorialButton = page.getByTestId("community-compute-tutorial-button"); + await tutorialButton.click(); + await expect(tutorialButton).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByTestId("community-compute-simulated-banner"), + ).toBeVisible(); + await expect( + page.getByTestId("community-compute-tutorial-panel"), + ).toContainText("Every territory is a model deployment"); + await expect(page.getByTestId("compute-share-banner")).toHaveCount(0); + const tutorialTile = page + .locator('[data-inference-active="true"]') + .first() + .getByTestId("community-compute-tile") + .first(); + await expect(tutorialTile).toBeVisible(); + await expect + .poll(() => + tutorialTile.evaluate( + (element) => getComputedStyle(element).animationName, + ), + ) + .toBe("mesh-breath"); + await page.getByLabel("Preview scenario").selectOption("single-device"); + await expect( + page.getByTestId("community-compute-kpi-members-sharing"), + ).toContainText("1"); + await page.getByRole("button", { name: "Next" }).click(); + await expect( + page.getByTestId("community-compute-tutorial-panel"), + ).toContainText("Area represents memory"); + await page.getByRole("button", { name: "Next" }).click(); + await expect( + page.getByTestId("community-compute-tutorial-panel"), + ).toContainText("Motion illustrates inference in flight"); + const territory = page.locator("[data-cell-count]").first(); + await territory.click(); + await expect(territory).toHaveAttribute("data-selected", "true"); + await territory.hover(); + await expect( + page.getByTestId("community-compute-hover-details"), + ).toContainText("Simulated inference"); + await tutorialButton.click(); + await expect(tutorialButton).toHaveAttribute("aria-pressed", "false"); + await expect( + page.getByTestId("community-compute-simulated-banner"), + ).toHaveCount(0); + await expect( + page.getByTestId("community-compute-kpi-members-sharing"), + ).toContainText("2"); +});