From 51667bd9652df0b0f3a1940b3ee99ed239d0eecc Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Tue, 1 Sep 2026 22:52:17 +0545 Subject: [PATCH] Feat: Add community portals - Customer (marketplace, leases, compute on the leased node), provider (their nodes) and operator (everything) as separate apps - Shared chrome with community's own session; identity switcher only in dev mode --- apps/community-admin/.env.example | 5 + apps/community-admin/docker-compose.yml | 18 + apps/community-admin/index.html | 13 + apps/community-admin/package.json | 12 + apps/community-admin/postcss.config.js | 1 + apps/community-admin/src/AdminShell.tsx | 62 ++ apps/community-admin/src/App.tsx | 41 ++ apps/community-admin/src/main.tsx | 5 + apps/community-admin/tailwind.config.ts | 5 + apps/community-admin/tsconfig.json | 13 + apps/community-admin/vite.config.ts | 16 + apps/community-provider/.env.example | 5 + apps/community-provider/docker-compose.yml | 17 + apps/community-provider/index.html | 13 + apps/community-provider/package.json | 12 + apps/community-provider/postcss.config.js | 1 + apps/community-provider/src/App.tsx | 32 ++ apps/community-provider/src/ProviderShell.tsx | 43 ++ apps/community-provider/src/main.tsx | 5 + apps/community-provider/tailwind.config.ts | 5 + apps/community-provider/tsconfig.json | 13 + apps/community-provider/vite.config.ts | 16 + apps/community/.env.example | 12 + apps/community/docker-compose.yml | 19 + apps/community/index.html | 13 + apps/community/package.json | 12 + apps/community/postcss.config.js | 1 + apps/community/src/App.tsx | 97 ++++ apps/community/src/CommunityShell.tsx | 107 ++++ apps/community/src/main.tsx | 5 + apps/community/tailwind.config.ts | 5 + apps/community/tsconfig.json | 13 + apps/community/vite.config.ts | 16 + package.json | 6 + packages/shared/src/CommunityPortalChrome.tsx | 260 +++++++++ .../components/DeleteConfirmationDialog.tsx | 6 +- .../src/components/RequireActiveLease.tsx | 70 +++ .../components/community/CloudflarePage.tsx | 241 ++++++++ .../community/CommunityDashboard.tsx | 357 ++++++++++++ .../components/community/MarketplacePage.tsx | 265 +++++++++ .../src/components/community/MyLeasesPage.tsx | 239 ++++++++ .../src/components/community/MyNodesPage.tsx | 411 ++++++++++++++ .../community/admin/AdminAllNodesPage.tsx | 167 ++++++ .../community/admin/AdminLeasesPage.tsx | 141 +++++ .../community/admin/AdminOverviewPage.tsx | 121 ++++ .../community/admin/AdminUsersPage.tsx | 110 ++++ .../community/admin/AdminWorkloadsPage.tsx | 120 ++++ .../src/components/community/admin/shared.tsx | 167 ++++++ .../src/components/community/nodeDisplay.tsx | 112 ++++ .../community/provider/NodeDetailPage.tsx | 350 ++++++++++++ .../provider/ProviderOverviewPage.tsx | 165 ++++++ packages/shared/src/services/communityApi.ts | 536 ++++++++++++++++++ 52 files changed, 4496 insertions(+), 1 deletion(-) create mode 100644 apps/community-admin/.env.example create mode 100644 apps/community-admin/docker-compose.yml create mode 100644 apps/community-admin/index.html create mode 100644 apps/community-admin/package.json create mode 100644 apps/community-admin/postcss.config.js create mode 100644 apps/community-admin/src/AdminShell.tsx create mode 100644 apps/community-admin/src/App.tsx create mode 100644 apps/community-admin/src/main.tsx create mode 100644 apps/community-admin/tailwind.config.ts create mode 100644 apps/community-admin/tsconfig.json create mode 100644 apps/community-admin/vite.config.ts create mode 100644 apps/community-provider/.env.example create mode 100644 apps/community-provider/docker-compose.yml create mode 100644 apps/community-provider/index.html create mode 100644 apps/community-provider/package.json create mode 100644 apps/community-provider/postcss.config.js create mode 100644 apps/community-provider/src/App.tsx create mode 100644 apps/community-provider/src/ProviderShell.tsx create mode 100644 apps/community-provider/src/main.tsx create mode 100644 apps/community-provider/tailwind.config.ts create mode 100644 apps/community-provider/tsconfig.json create mode 100644 apps/community-provider/vite.config.ts create mode 100644 apps/community/.env.example create mode 100644 apps/community/docker-compose.yml create mode 100644 apps/community/index.html create mode 100644 apps/community/package.json create mode 100644 apps/community/postcss.config.js create mode 100644 apps/community/src/App.tsx create mode 100644 apps/community/src/CommunityShell.tsx create mode 100644 apps/community/src/main.tsx create mode 100644 apps/community/tailwind.config.ts create mode 100644 apps/community/tsconfig.json create mode 100644 apps/community/vite.config.ts create mode 100644 packages/shared/src/CommunityPortalChrome.tsx create mode 100644 packages/shared/src/components/RequireActiveLease.tsx create mode 100644 packages/shared/src/components/community/CloudflarePage.tsx create mode 100644 packages/shared/src/components/community/CommunityDashboard.tsx create mode 100644 packages/shared/src/components/community/MarketplacePage.tsx create mode 100644 packages/shared/src/components/community/MyLeasesPage.tsx create mode 100644 packages/shared/src/components/community/MyNodesPage.tsx create mode 100644 packages/shared/src/components/community/admin/AdminAllNodesPage.tsx create mode 100644 packages/shared/src/components/community/admin/AdminLeasesPage.tsx create mode 100644 packages/shared/src/components/community/admin/AdminOverviewPage.tsx create mode 100644 packages/shared/src/components/community/admin/AdminUsersPage.tsx create mode 100644 packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx create mode 100644 packages/shared/src/components/community/admin/shared.tsx create mode 100644 packages/shared/src/components/community/nodeDisplay.tsx create mode 100644 packages/shared/src/components/community/provider/NodeDetailPage.tsx create mode 100644 packages/shared/src/components/community/provider/ProviderOverviewPage.tsx create mode 100644 packages/shared/src/services/communityApi.ts diff --git a/apps/community-admin/.env.example b/apps/community-admin/.env.example new file mode 100644 index 0000000..f117917 --- /dev/null +++ b/apps/community-admin/.env.example @@ -0,0 +1,5 @@ +# The community plane's API. This portal talks to nothing else -- not core, +# not cloud; they are separate services with their own user tables. +VITE_COMMUNITY_API_BASE_URL=http://localhost:5002/api + +ALLOWED_HOST=localhost diff --git a/apps/community-admin/docker-compose.yml b/apps/community-admin/docker-compose.yml new file mode 100644 index 0000000..68672c1 --- /dev/null +++ b/apps/community-admin/docker-compose.yml @@ -0,0 +1,18 @@ +# Community operator portal: every node, every workload, every customer, +# across all owners. Separate from the customer and provider portals -- this +# is the one surface where cross-owner data (who is running what, on whose +# hardware) is visible at all, and it must not be reachable by ordinary +# accounts just because they happen to load the same bundle. +services: + portal: + build: + context: ../.. + dockerfile: Dockerfile + container_name: xcloudify-community-admin-portal + environment: + APP: community-admin + PORT: "8086" + ALLOWED_HOST: ${ALLOWED_HOST:-localhost} + VITE_COMMUNITY_API_BASE_URL: ${VITE_COMMUNITY_API_BASE_URL:-http://localhost:5002/api} + ports: + - "8086:8086" diff --git a/apps/community-admin/index.html b/apps/community-admin/index.html new file mode 100644 index 0000000..54af7af --- /dev/null +++ b/apps/community-admin/index.html @@ -0,0 +1,13 @@ + + + + + + xCloudify Community Admin + + + +
+ + + diff --git a/apps/community-admin/package.json b/apps/community-admin/package.json new file mode 100644 index 0000000..ebe6287 --- /dev/null +++ b/apps/community-admin/package.json @@ -0,0 +1,12 @@ +{ + "name": "@xcloudify/community-admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { "@xcloudify/shared": "*" } +} diff --git a/apps/community-admin/postcss.config.js b/apps/community-admin/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/apps/community-admin/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/apps/community-admin/src/AdminShell.tsx b/apps/community-admin/src/AdminShell.tsx new file mode 100644 index 0000000..db7dc51 --- /dev/null +++ b/apps/community-admin/src/AdminShell.tsx @@ -0,0 +1,62 @@ +/** + * Chrome for the operator portal, built on the shared community chrome + * (packages/shared/src/CommunityPortalChrome.tsx). + * + * The one thing this shell adds that the other two don't: a `guard` that + * refuses a signed-in non-operator entirely, before any route renders. Every + * route in this app is operator-only, so the gate belongs once here rather + * than wrapping each route -- see App.tsx's docstring for why that matters. + */ +import { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Toaster } from "@/components/ui/toaster"; +import { CommunityPortalChrome } from "@/CommunityPortalChrome"; +import { communityOperatorNavItems } from "@/components/AppSidebar"; +import { PlaneProvider } from "@/plane"; + +const queryClient = new QueryClient(); + +const TITLES: Array<[string, string]> = [ + ["/workloads", "All workloads"], + ["/nodes", "All nodes"], + ["/leases", "Leases"], + ["/users", "People"], + ["/", "Platform overview"], +]; + +const GROUPS = [{ label: "Operator", items: communityOperatorNavItems }]; + +function NotAnOperator() { + return ( +
+

Operator access required

+

+ This portal manages community infrastructure across every owner. Your account + does not have operator access. +

+
+ ); +} + +export function AdminShell({ children }: { children: ReactNode }) { + return ( + + + + (user.is_platform_admin ? null : )} + > + {children} + + + + + + ); +} diff --git a/apps/community-admin/src/App.tsx b/apps/community-admin/src/App.tsx new file mode 100644 index 0000000..73d0221 --- /dev/null +++ b/apps/community-admin/src/App.tsx @@ -0,0 +1,41 @@ +/** + * Operator portal: every node, every workload, every customer, across all + * owners. + * + * One of three separate deployments on the community plane -- see + * packages/shared/src/CommunityPortalChrome.tsx. The whole app is gated on + * community's own `is_platform_admin`, once in AdminShell rather than once + * per route -- this is the one surface where cross-owner data is visible at + * all, and nothing here should be reachable by an ordinary account just + * because it happened to load the same bundle. + * + * The gate is a usability guard, not the security boundary: every backend + * route this calls (`community/app/routes/admin_routes.py`) checks + * is_platform_admin itself regardless of what this UI renders. + */ +import { BrowserRouter, Routes, Route } from "react-router-dom"; + +import { AdminOverviewPage } from "@/components/community/admin/AdminOverviewPage"; +import { AdminAllNodesPage } from "@/components/community/admin/AdminAllNodesPage"; +import { AdminWorkloadsPage } from "@/components/community/admin/AdminWorkloadsPage"; +import { AdminLeasesPage } from "@/components/community/admin/AdminLeasesPage"; +import { AdminUsersPage } from "@/components/community/admin/AdminUsersPage"; +import NotFound from "@/pages/NotFound"; +import { AdminShell } from "./AdminShell"; + +export default function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/apps/community-admin/src/main.tsx b/apps/community-admin/src/main.tsx new file mode 100644 index 0000000..b569bfb --- /dev/null +++ b/apps/community-admin/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "@/index.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/community-admin/tailwind.config.ts b/apps/community-admin/tailwind.config.ts new file mode 100644 index 0000000..db5435b --- /dev/null +++ b/apps/community-admin/tailwind.config.ts @@ -0,0 +1,5 @@ +// Re-exports the shared theme so all four apps look identical. Content globs +// are relative to this file, so each app scans its own src plus the shared +// component library. +import shared from "../../packages/shared/tailwind.config"; +export default shared; diff --git a/apps/community-admin/tsconfig.json b/apps/community-admin/tsconfig.json new file mode 100644 index 0000000..60472a7 --- /dev/null +++ b/apps/community-admin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "skipLibCheck": true, "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "isolatedModules": true, + "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, + "noImplicitAny": false, "strictNullChecks": false, "allowJs": true, + "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] } + }, + "include": ["src", "../../packages/shared/src"] +} diff --git a/apps/community-admin/vite.config.ts b/apps/community-admin/vite.config.ts new file mode 100644 index 0000000..1380f57 --- /dev/null +++ b/apps/community-admin/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +// Every app resolves "@/" to the SHARED package, not its own src. That is +// what lets ~110 shared components keep their existing imports untouched +// while each app stays a thin entry point that only picks which portals to +// mount. App-local files import relatively. +const PORT = parseInt(process.env.PORT ?? "8086", 10); + +export default defineConfig({ + server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + plugins: [react()], + resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } }, +}); diff --git a/apps/community-provider/.env.example b/apps/community-provider/.env.example new file mode 100644 index 0000000..f117917 --- /dev/null +++ b/apps/community-provider/.env.example @@ -0,0 +1,5 @@ +# The community plane's API. This portal talks to nothing else -- not core, +# not cloud; they are separate services with their own user tables. +VITE_COMMUNITY_API_BASE_URL=http://localhost:5002/api + +ALLOWED_HOST=localhost diff --git a/apps/community-provider/docker-compose.yml b/apps/community-provider/docker-compose.yml new file mode 100644 index 0000000..bfe7570 --- /dev/null +++ b/apps/community-provider/docker-compose.yml @@ -0,0 +1,17 @@ +# Provider portal: node owners register hardware, watch its status and what +# it has earned. A separate deployment from the customer portal -- lending a +# machine and renting one are different jobs done by different audiences most +# of the time, even though the same account can do both (community/README.md). +services: + portal: + build: + context: ../.. + dockerfile: Dockerfile + container_name: xcloudify-community-provider-portal + environment: + APP: community-provider + PORT: "8084" + ALLOWED_HOST: ${ALLOWED_HOST:-localhost} + VITE_COMMUNITY_API_BASE_URL: ${VITE_COMMUNITY_API_BASE_URL:-http://localhost:5002/api} + ports: + - "8084:8084" diff --git a/apps/community-provider/index.html b/apps/community-provider/index.html new file mode 100644 index 0000000..5aa0b4e --- /dev/null +++ b/apps/community-provider/index.html @@ -0,0 +1,13 @@ + + + + + + xCloudify Provider + + + +
+ + + diff --git a/apps/community-provider/package.json b/apps/community-provider/package.json new file mode 100644 index 0000000..63dc0e1 --- /dev/null +++ b/apps/community-provider/package.json @@ -0,0 +1,12 @@ +{ + "name": "@xcloudify/community-provider", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { "@xcloudify/shared": "*" } +} diff --git a/apps/community-provider/postcss.config.js b/apps/community-provider/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/apps/community-provider/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/apps/community-provider/src/App.tsx b/apps/community-provider/src/App.tsx new file mode 100644 index 0000000..50c6d4c --- /dev/null +++ b/apps/community-provider/src/App.tsx @@ -0,0 +1,32 @@ +/** + * Provider portal: lend hardware to the community cloud, watch its status + * and what it has earned. + * + * One of three separate deployments on the community plane -- see + * packages/shared/src/CommunityPortalChrome.tsx. Reachable by any account + * (registering the first node is the entry point, so nothing here is + * gated) -- it only ever shows the caller's own hardware, never anyone + * else's. Cross-owner visibility is apps/community-admin. + */ +import { BrowserRouter, Routes, Route } from "react-router-dom"; + +import { ProviderOverviewPage } from "@/components/community/provider/ProviderOverviewPage"; +import { MyNodesPage } from "@/components/community/MyNodesPage"; +import { NodeDetailPage } from "@/components/community/provider/NodeDetailPage"; +import NotFound from "@/pages/NotFound"; +import { ProviderShell } from "./ProviderShell"; + +export default function App() { + return ( + + + + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/apps/community-provider/src/ProviderShell.tsx b/apps/community-provider/src/ProviderShell.tsx new file mode 100644 index 0000000..b00f14e --- /dev/null +++ b/apps/community-provider/src/ProviderShell.tsx @@ -0,0 +1,43 @@ +/** + * Chrome for the provider portal, built on the shared community chrome + * (packages/shared/src/CommunityPortalChrome.tsx). Nothing extra: unlike the + * customer portal there is no tenant scope to pick (a provider's own nodes + * are simply all of them), so this is the thinnest of the three shells. + */ +import { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Toaster } from "@/components/ui/toaster"; +import { CommunityPortalChrome } from "@/CommunityPortalChrome"; +import { communityProviderNavItems } from "@/components/AppSidebar"; +import { PlaneProvider } from "@/plane"; + +const queryClient = new QueryClient(); + +const TITLES: Array<[string, string]> = [ + ["/nodes", "My nodes"], + ["/", "Your hardware"], +]; + +const GROUPS = [{ label: "Your hardware", items: communityProviderNavItems }]; + +export function ProviderShell({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + + + ); +} diff --git a/apps/community-provider/src/main.tsx b/apps/community-provider/src/main.tsx new file mode 100644 index 0000000..b569bfb --- /dev/null +++ b/apps/community-provider/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "@/index.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/community-provider/tailwind.config.ts b/apps/community-provider/tailwind.config.ts new file mode 100644 index 0000000..db5435b --- /dev/null +++ b/apps/community-provider/tailwind.config.ts @@ -0,0 +1,5 @@ +// Re-exports the shared theme so all four apps look identical. Content globs +// are relative to this file, so each app scans its own src plus the shared +// component library. +import shared from "../../packages/shared/tailwind.config"; +export default shared; diff --git a/apps/community-provider/tsconfig.json b/apps/community-provider/tsconfig.json new file mode 100644 index 0000000..60472a7 --- /dev/null +++ b/apps/community-provider/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "skipLibCheck": true, "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "isolatedModules": true, + "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, + "noImplicitAny": false, "strictNullChecks": false, "allowJs": true, + "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] } + }, + "include": ["src", "../../packages/shared/src"] +} diff --git a/apps/community-provider/vite.config.ts b/apps/community-provider/vite.config.ts new file mode 100644 index 0000000..f5dedb8 --- /dev/null +++ b/apps/community-provider/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +// Every app resolves "@/" to the SHARED package, not its own src. That is +// what lets ~110 shared components keep their existing imports untouched +// while each app stays a thin entry point that only picks which portals to +// mount. App-local files import relatively. +const PORT = parseInt(process.env.PORT ?? "8084", 10); + +export default defineConfig({ + server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + plugins: [react()], + resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } }, +}); diff --git a/apps/community/.env.example b/apps/community/.env.example new file mode 100644 index 0000000..4abac3c --- /dev/null +++ b/apps/community/.env.example @@ -0,0 +1,12 @@ +# The community plane's API. This portal talks to nothing else -- not core, +# not cloud; they are separate services with their own user tables. +VITE_COMMUNITY_API_BASE_URL=http://localhost:5002/api + +# xcloudifyApi.ts's own base URL, shared with cloud/core. It defaults to +# cloud's port (5001) when unset, which is wrong here -- the compute/storage/ +# network pages (InfrastructurePage, StoragePage, NetworkDetailsPage) go +# through the same client, scoped to a lease instead of a VDC (see +# hooks/useLease.tsx), and need to land on this same community backend. +VITE_API_BASE_URL=http://localhost:5002/api + +ALLOWED_HOST=localhost diff --git a/apps/community/docker-compose.yml b/apps/community/docker-compose.yml new file mode 100644 index 0000000..588a2f3 --- /dev/null +++ b/apps/community/docker-compose.yml @@ -0,0 +1,19 @@ +# Community cloud portal: node owners register hardware, customers reserve it. +services: + portal: + build: + context: ../.. + dockerfile: Dockerfile + container_name: xcloudify-community-portal + environment: + APP: community + PORT: "8081" + ALLOWED_HOST: ${ALLOWED_HOST:-localhost} + VITE_COMMUNITY_API_BASE_URL: ${VITE_COMMUNITY_API_BASE_URL:-http://localhost:5002/api} + # xcloudifyApi.ts's own base URL, shared with cloud/core -- defaults to + # cloud's port (5001) when unset. The compute/storage/network pages + # (InfrastructurePage, StoragePage, NetworkDetailsPage) go through that + # client, scoped by lease instead of VDC, and need this same backend. + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5002/api} + ports: + - "8081:8081" diff --git a/apps/community/index.html b/apps/community/index.html new file mode 100644 index 0000000..369941e --- /dev/null +++ b/apps/community/index.html @@ -0,0 +1,13 @@ + + + + + + xCloudify Community + + + +
+ + + diff --git a/apps/community/package.json b/apps/community/package.json new file mode 100644 index 0000000..27c84b5 --- /dev/null +++ b/apps/community/package.json @@ -0,0 +1,12 @@ +{ + "name": "@xcloudify/community", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { "@xcloudify/shared": "*" } +} diff --git a/apps/community/postcss.config.js b/apps/community/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/apps/community/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/apps/community/src/App.tsx b/apps/community/src/App.tsx new file mode 100644 index 0000000..480ac19 --- /dev/null +++ b/apps/community/src/App.tsx @@ -0,0 +1,97 @@ +/** + * Customer portal: rent somebody else's machine and run work on it. + * + * One of three separate deployments on the community plane -- see + * packages/shared/src/CommunityPortalChrome.tsx for why they are not one app + * with nav sections. This one is for the renting audience only; lending a + * machine is apps/community-provider, and cross-owner operator visibility is + * apps/community-admin. All three talk to the same community API (:5002) + * and share nothing else -- not a login, not a bundle, not a URL space. + * + * Feature parity with the cloud portal: every route here mounts the exact + * same shared component apps/cloud does, at the exact same path, except + * Data Centers and Certificates -- see AppSidebar.tsx's + * communityCustomerNavItems for why those two specifically have no + * equivalent in a plane with no VDC/project tenancy. + */ +import { BrowserRouter, Routes, Route } from "react-router-dom"; + +import { CommunityDashboard } from "@/components/community/CommunityDashboard"; +import { MarketplacePage } from "@/components/community/MarketplacePage"; +import { MyLeasesPage } from "@/components/community/MyLeasesPage"; +import { CloudflarePage } from "@/components/community/CloudflarePage"; +import { RequireActiveLease } from "@/components/RequireActiveLease"; + +// Shared compute product, mounted unchanged. These are the same screens the +// cloud portal serves; what differs is only what they are scoped by, and that +// is answered by hooks/useTenantScope rather than by a community-specific +// copy of each page. +import { InfrastructurePage } from "@/components/InfrastructurePage"; +import { StoragePage } from "@/components/StoragePage"; +import { NetworkDetailsPage } from "@/components/NetworkDetailsPage"; +import { VirtualMachineDetailsWrapper } from "@/components/VirtualMachineDetailsWrapper"; +import { VolumeDetailsWrapper } from "@/components/VolumeDetailsWrapper"; + +// Account-level, same as on cloud -- no lease/VDC dependency, so none of +// these sit behind RequireActiveLease. +import { ExplorePage } from "@/components/ExplorePage"; +import { SSHKeysPage } from "@/components/SSHKeysPage"; +import { AIServicesPage } from "@/components/AIServicesPage"; +import { ServerlessPage } from "@/components/ServerlessPage"; +import { GitDeployPage } from "@/components/GitDeployPage"; +import { BillingPage } from "@/components/BillingPage"; +import { ReferralsPage } from "@/components/ReferralsPage"; + +import NotFound from "@/pages/NotFound"; +import { CommunityShell } from "./CommunityShell"; + +/** Matches cloud/src/App.tsx's own Stub: styled disabled in the sidebar, but + * still routable so a click lands on an explanation rather than a 404. */ +const Stub = ({ title }: { title: string }) => ( +
{title} page coming soon...
+); + +export default function App() { + return ( + + + + } /> + } /> + } /> + } /> + + {/* Every one of these routes through /leases//core/*, so none + can load anything until a lease is selected -- and unlike a + VDC, a lease cannot be created from a blank state (it comes out + of leasing a node on the marketplace). Hence the gate is here, + per-route, rather than around the whole shell: the marketplace + and My Leases have to stay reachable with zero leases. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Nav entries styled disabled, same as cloud's own -- landing on a + stub beats landing on a 404. */} + } /> + } /> + } /> + + } /> + + + + ); +} diff --git a/apps/community/src/CommunityShell.tsx b/apps/community/src/CommunityShell.tsx new file mode 100644 index 0000000..e05d66d --- /dev/null +++ b/apps/community/src/CommunityShell.tsx @@ -0,0 +1,107 @@ +/** + * Chrome for the customer portal, built on the shared community chrome + * (packages/shared/src/CommunityPortalChrome.tsx). Adds the two things only + * this portal needs: a lease is a tenant scope here, so LeaseProvider is + * mounted and its picker sits in the header next to the identity menu. + */ +import { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Toaster } from "@/components/ui/toaster"; +import { CommunityPortalChrome } from "@/CommunityPortalChrome"; +import { communityNavItems, communityCustomerNavItems, generalNavItems } from "@/components/AppSidebar"; +import { PlaneProvider } from "@/plane"; +import { LeaseProvider, useLease } from "@/hooks/useLease"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; + +const queryClient = new QueryClient(); + +const TITLES: Array<[string, string]> = [ + ["/marketplace", "Marketplace"], + ["/leases", "My leases"], + ["/cloudflare", "Public access"], + ["/infrastructure", "Compute"], + ["/storage", "Storage"], + ["/explore", "Explore"], + ["/ssh-keys", "SSH keys"], + ["/ai-services", "AI Services"], + ["/serverless", "Serverless Compute"], + ["/git-deploy", "Git Deploy"], + ["/billing", "Billing"], + ["/referrals", "Referrals"], + ["/my-templates", "My Templates"], + ["/changelog", "Changelog"], + ["/vote", "Vote feature"], + ["/", "Overview"], +]; + +const GROUPS = [ + { label: "Community", items: communityNavItems }, + { label: "Your compute", items: communityCustomerNavItems }, + { label: "General", items: generalNavItems }, +]; + +/** + * Which leased machine the customer screens are working on. + * + * In the header rather than above one page, for the same reason cloud puts + * its VDC picker there: a lease is the tenant unit of every core-owned call + * this portal makes (`/leases//core/*`), so it scopes compute, storage, + * networks and public access all at once. Having it visible on every screen + * is also what lets the create forms drop their own copy of the control -- + * see hooks/useTenantScope's `perResourcePicker`. + * + * Hidden with fewer than two leases: with one there is nothing to choose, and + * with none the customer screens are behind RequireActiveLease anyway. + */ +function LeasePicker() { + const { leases, activeLease, selectLease } = useLease(); + if (leases.length < 2 || !activeLease) return null; + + return ( +
+ Working on + +
+ ); +} + +export function CommunityShell({ children }: { children: ReactNode }) { + return ( + + + + + } + > + {children} + + + + + + + ); +} diff --git a/apps/community/src/main.tsx b/apps/community/src/main.tsx new file mode 100644 index 0000000..b569bfb --- /dev/null +++ b/apps/community/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "@/index.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/community/tailwind.config.ts b/apps/community/tailwind.config.ts new file mode 100644 index 0000000..db5435b --- /dev/null +++ b/apps/community/tailwind.config.ts @@ -0,0 +1,5 @@ +// Re-exports the shared theme so all four apps look identical. Content globs +// are relative to this file, so each app scans its own src plus the shared +// component library. +import shared from "../../packages/shared/tailwind.config"; +export default shared; diff --git a/apps/community/tsconfig.json b/apps/community/tsconfig.json new file mode 100644 index 0000000..60472a7 --- /dev/null +++ b/apps/community/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "skipLibCheck": true, "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "isolatedModules": true, + "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, + "noImplicitAny": false, "strictNullChecks": false, "allowJs": true, + "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] } + }, + "include": ["src", "../../packages/shared/src"] +} diff --git a/apps/community/vite.config.ts b/apps/community/vite.config.ts new file mode 100644 index 0000000..0618940 --- /dev/null +++ b/apps/community/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +// Every app resolves "@/" to the SHARED package, not its own src. That is +// what lets ~110 shared components keep their existing imports untouched +// while each app stays a thin entry point that only picks which portals to +// mount. App-local files import relatively. +const PORT = parseInt(process.env.PORT ?? "8081", 10); + +export default defineConfig({ + server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + plugins: [react()], + resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } }, +}); diff --git a/package.json b/package.json index 5a92129..08b9e84 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,15 @@ "scripts": { "dev:core": "npm run dev -w @xcloudify/core", "dev:cloud": "npm run dev -w @xcloudify/cloud", + "dev:community": "npm run dev -w @xcloudify/community", + "dev:community-provider": "npm run dev -w @xcloudify/community-provider", + "dev:community-admin": "npm run dev -w @xcloudify/community-admin", "build": "npm run build --workspaces --if-present", "build:core": "npm run build -w @xcloudify/core", "build:cloud": "npm run build -w @xcloudify/cloud", + "build:community": "npm run build -w @xcloudify/community", + "build:community-provider": "npm run build -w @xcloudify/community-provider", + "build:community-admin": "npm run build -w @xcloudify/community-admin", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint ." }, diff --git a/packages/shared/src/CommunityPortalChrome.tsx b/packages/shared/src/CommunityPortalChrome.tsx new file mode 100644 index 0000000..bca1b33 --- /dev/null +++ b/packages/shared/src/CommunityPortalChrome.tsx @@ -0,0 +1,260 @@ +/** + * Chrome shared by all three community-plane portals: customer, provider, + * operator. + * + * Three separate deployments, not three sections of one app -- lending a + * machine and renting one are different jobs, done by different people most + * of the time, and each portal should be something you can hand a link to + * without also handing over the other two. (An earlier version of this put + * all three behind one login as nav groups; that was wrong -- the point of + * separate portals is that a provider's bookmark, and what they see when + * they open it, has nothing about customers on it.) + * + * What they share is everything that has nothing to do with which one you + * are: community's own session (`/auth/me`, not cloud's) -- a real sign-in, + * or in development the identity switcher (see community/README.md) -- and the + * same sidebar/header layout the other consoles use so the three read as one + * product family rather than three different apps. + * + * Each portal's own Shell.tsx supplies only what differs: its nav groups, + * its badge, and whether it needs a lease in scope at all (only the customer + * portal does -- see LeaseProvider below). + */ +import { ReactNode, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { ChevronDown, LogOut, RefreshCw, ServerCrash, ShieldCheck, UserCog } from "lucide-react"; + +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { AppSidebar, type NavGroup } from "@/components/AppSidebar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { useCommunityAuth, CommunityAuthUser } from "@/hooks/useCommunityAuth"; +import { getDevUser, setDevUser } from "@/services/communityApi"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { SignInScreen } from "@/components/SignInScreen"; +import { oauthSignOutUrl } from "@/services/xcloudifyApi"; + +function initialsOf(name: string): string { + const letters = name + .split(/[\s@._-]+/) + .filter(Boolean) + .map((part) => part[0]) + .join(""); + return (letters.slice(0, 2) || "?").toUpperCase(); +} + +function Unreachable({ error, portalName }: { error: string; portalName: string }) { + return ( +
+ + +
+ +
+

{portalName} unavailable

+

+ The community service could not be reached: {error} +

+ +
+
+
+ ); +} + +/** + * Act as a different person. + * + * Development only (auth_mode "dev"). The API authenticates nobody there, which makes every browser the same + * account -- and exercising these portals needs at least two identities: + * someone who lends a machine and someone who rents it (you cannot lease + * your own node). Lives in the user menu rather than a banner across the + * top: it is a property of who you are, and the portal should not look like + * a debug build because of it. + */ +function IdentityDialog({ + open, onOpenChange, current, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + current: string; +}) { + const [value, setValue] = useState(getDevUser() ?? current); + + const apply = (email: string | null) => { + setDevUser(email); + // Everything cached belongs to the old identity, so start clean rather + // than reconciling two users' data. + window.location.reload(); + }; + + return ( + + + + Switch identity + + Development mode has no sign-in -- this names which community account you act as. + + +
+ + setValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && apply(value.trim() || null)} + placeholder="someone@example.com" + /> +

+ Sent as a dev header, which the API only honours in development. + The page reloads so no data from the old identity is left on screen. +

+
+ + + + +
+
+ ); +} + +function UserMenu({ user }: { user: CommunityAuthUser }) { + const [switching, setSwitching] = useState(false); + const name = user.name || user.email; + + return ( + <> + + + + + + +
+ {name} + {user.email} + {user.is_platform_admin && ( + + + Operator + + )} +
+
+ + {user.auth_mode === "dev" ? ( + setSwitching(true)}> + + Switch identity + + ) : ( + + + + Sign out + + + )} +
+
+ {user.auth_mode === "dev" && ( + + )} + + ); +} + +export interface CommunityPortalChromeProps { + /** Nav groups for this portal specifically -- one portal, its own sidebar. */ + groups: NavGroup[]; + /** Names the plane under the shared logo, e.g. "Provider", "Operator". */ + badge: string; + /** What to call this portal in the "service unavailable" screen. */ + portalName: string; + /** Header title per route: longest matching prefix wins. */ + titles: Array<[string, string]>; + /** Extra header content right of the title, left of the user menu -- + * the customer portal's lease picker, e.g. */ + headerExtra?: ReactNode; + /** Refused entirely for a signed-in non-operator, e.g. -- checked after + * the loading/unreachable states, before any children render. */ + guard?: (user: CommunityAuthUser) => ReactNode | null; + children: ReactNode; +} + +export function CommunityPortalChrome({ + groups, badge, portalName, titles, headerExtra, guard, children, +}: CommunityPortalChromeProps) { + const { user, loading, error } = useCommunityAuth(); + const { pathname } = useLocation(); + + if (loading) { + return ( +
+ Checking session… +
+ ); + } + if (error) { + return ; + } + if (!user) { + // /auth/me said 401: no session. Only reachable outside dev mode. + return ; + } + const denied = guard?.(user); + if (denied) return <>{denied}; + + const title = titles.find( + ([prefix]) => pathname === prefix || (prefix !== "/" && pathname.startsWith(prefix)), + )?.[1] ?? portalName; + + return ( + + +
+ +
+
+
+ +

{title}

+
+
+ {headerExtra} + +
+
+
{children}
+
+
+
+ ); +} diff --git a/packages/shared/src/components/DeleteConfirmationDialog.tsx b/packages/shared/src/components/DeleteConfirmationDialog.tsx index e1bd919..5ee1a91 100644 --- a/packages/shared/src/components/DeleteConfirmationDialog.tsx +++ b/packages/shared/src/components/DeleteConfirmationDialog.tsx @@ -20,7 +20,11 @@ interface DeleteConfirmationDialogProps { itemName: string; itemType: | "pod" | "container" | "network" | "server group" | "virtual machine" - | "volume" | "universe" | "DNS record"; + | "volume" | "universe" | "DNS record" + // Community: a machine a provider lent to the platform. Withdrawing it is + // destructive in the same way -- credentials are revoked and the worker + // can no longer report in -- so it gets the same confirmation. + | "node"; isLoading?: boolean; } diff --git a/packages/shared/src/components/RequireActiveLease.tsx b/packages/shared/src/components/RequireActiveLease.tsx new file mode 100644 index 0000000..bd45bdf --- /dev/null +++ b/packages/shared/src/components/RequireActiveLease.tsx @@ -0,0 +1,70 @@ +/** + * Gate for the community portal's compute/storage/network pages. + * + * A lease is the tenant unit there (see hooks/useLease.tsx) -- every + * core-owned call routes through /leases//core/*, so nothing under this + * gate can load anything until one is selected. Mirrors AppShell's + * vdc.resolved / vdc.activeVdc gate in shape, but as a route-level wrapper + * rather than a whole-shell gate: unlike a VDC, a lease cannot be created + * from a blank state (it comes out of leasing a node on the marketplace), so + * the other community pages -- Marketplace, My Nodes, My Leases -- must stay + * reachable with zero leases, which ruled out gating the whole shell on this. + * + * Purely a gate. Choosing *which* lease belongs in the shell header, next to + * the identity, where it is visible on every screen and matches how cloud + * presents its VDC picker -- not inline above whichever page happened to + * mount this wrapper. + */ +import { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { Cloud, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useLease } from "@/hooks/useLease"; + +export function RequireActiveLease({ children }: { children: ReactNode }) { + const { leases, activeLease, resolved, error } = useLease(); + + if (!resolved) { + return ( +
+ + Loading your leases… +
+ ); + } + + if (error) { + return ( +
+

Could not load your leases

+

{error}

+
+ ); + } + + if (leases.length === 0) { + return ( +
+
+ +
+

No leased nodes yet

+

+ Compute, storage and network are managed per leased node. Lease one + from the marketplace to get started. +

+ +
+ ); + } + + if (!activeLease) { + // Transient: leases loaded but selectLease's effect hasn't committed an + // id yet. Same frame it always resolves on the next render. + return null; + } + + return <>{children}; +} diff --git a/packages/shared/src/components/community/CloudflarePage.tsx b/packages/shared/src/components/community/CloudflarePage.tsx new file mode 100644 index 0000000..c3a6bc2 --- /dev/null +++ b/packages/shared/src/components/community/CloudflarePage.tsx @@ -0,0 +1,241 @@ +/** + * How a leased workload reaches the internet. + * + * A community node is a NAT'd machine under somebody's desk with no public + * address of its own -- app/regions.py spells this out -- so a tunnel is not + * an add-on here, it is the only way out. The account that pays for that + * tunnel is the *customer's*, not ours and not the node owner's: they bring + * their own Cloudflare zone, and their leased containers get hostnames on it. + * + * One account covers every machine they lease, which is why this is its own + * screen rather than a control repeated per lease (see CloudflareCredential's + * docstring in community/app/models.py for why it is per-user). + */ +import { useCallback, useEffect, useState } from "react"; +import { Cloud, ShieldCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { communityApi, CloudflareCredentialInfo } from "@/services/communityApi"; +import { useVdcExposures } from "@/hooks/useVdcExposures"; +import { useLease } from "@/hooks/useLease"; + +function CloudflareSettingsCard() { + const { toast } = useToast(); + const [cred, setCred] = useState(null); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState({ apiToken: "", accountId: "", zoneId: "" }); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + setCred(await communityApi.getMyCloudflare()); + } catch { + // Best-effort; the page still works with exposure simply unavailable. + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const save = async () => { + setSaving(true); + try { + const saved = await communityApi.setMyCloudflare(form); + setCred(saved); + setDialogOpen(false); + setForm({ apiToken: "", accountId: "", zoneId: "" }); + toast({ title: "Cloudflare account connected", description: `Verified against ${saved.domain}.` }); + } catch (err) { + toast({ + title: "Could not verify Cloudflare credentials", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setSaving(false); + } + }; + + const remove = async () => { + try { + await communityApi.deleteMyCloudflare(); + setCred(null); + toast({ title: "Cloudflare account removed" }); + } catch (err) { + toast({ + title: "Could not remove Cloudflare account", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } + }; + + if (loading) return null; + + return ( + + +
+
+ +
+
+ Cloudflare + + Expose a leased container's port publicly, without the node needing one. + +
+
+ {cred ? ( +
+ + + {cred.domain} + + +
+ ) : ( + + )} +
+ + + + + Connect your Cloudflare account + + Verified against Cloudflare before it is saved. Needs a zone (domain) and an + API token scoped to Zone:DNS:Edit and Account:Cloudflare Tunnel:Edit. + + +
+
+ + setForm((f) => ({ ...f, apiToken: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, accountId: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, zoneId: e.target.value }))} + /> +

+ The domain itself is read back from Cloudflare, not typed here. +

+
+
+ + + +
+
+
+ ); +} + + +/** + * The hostnames that actually exist, as opposed to the ones a port's name + * implies. Provisioning is best-effort -- the gateway deliberately swallows a + * Cloudflare failure rather than turning a working container into an error -- + * so a port that asked to be public and never got a record is a real state + * this screen has to be able to show. + */ +function ExposureList() { + const { exposures, cloudflareDomain, loading, error } = useVdcExposures(); + const { activeLease } = useLease(); + + if (!activeLease) return null; + + return ( + + + Public hostnames + + {cloudflareDomain + ? `Container ports published on ${cloudflareDomain} for ${activeLease.node_name ?? "this lease"}.` + : "Connect a Cloudflare account above, then mark a container port public when you create it."} + + + + + + + Hostname + Container port + + + + {(loading || error || exposures.length === 0) && ( + + + {loading ? "Loading…" + : error ? error + : "No port on this lease is published yet."} + + + )} + {exposures.map((e) => ( + + + + {e.hostname} + + + + {e.internal_port ?? "—"} + + + ))} + +
+
+
+ ); +} + +export function CloudflarePage() { + return ( +
+
+

Public access

+

+ Leased machines have no public address of their own. Bring your own Cloudflare + account and their ports get hostnames on your domain. +

+
+ + +
+ ); +} diff --git a/packages/shared/src/components/community/CommunityDashboard.tsx b/packages/shared/src/components/community/CommunityDashboard.tsx new file mode 100644 index 0000000..4b3b12f --- /dev/null +++ b/packages/shared/src/components/community/CommunityDashboard.tsx @@ -0,0 +1,357 @@ +/** + * The customer portal's front page: what am I renting, and what is out there + * to rent. + * + * Customer-only. This used to answer "what do I have here?" for both halves + * of the product at once -- nodes lent and leases held, side by side -- back + * when lending and renting were two sections of one app. They are now two + * separate deployments (see CommunityPortalChrome.tsx's docstring for why), + * so this dashboard only shows what this app can actually act on. Lending a + * machine is one link away, to the provider portal's own origin -- not a + * route here, a different app entirely. + * + * Every number here is read from the community plane, never derived from a + * guess: the "available" count from the same marketplace listing the + * marketplace page renders, leases from the caller's own /api/leases. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { + ArrowRight, Gauge, KeyRound, MapPin, Server, ShoppingCart, Sparkles, + RefreshCw, type LucideIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { useCommunityAuth } from "@/hooks/useCommunityAuth"; +import { communityApi, CommunityNode, Lease, PROVIDER_PORTAL_URL } from "@/services/communityApi"; +// One description of a node, shared with the node list and the operator +// views, so "seen 5 h ago" reads the same everywhere it appears. +import { nodeLocation, nodeSpecSummary, relativeTime } from "./nodeDisplay"; + +interface Snapshot { + leases: Lease[]; + available: CommunityNode[]; + leasedNodes: Record; +} + +const EMPTY: Snapshot = { leases: [], available: [], leasedNodes: {} }; + +function StatCard({ + icon: Icon, label, value, sub, tone = "default", loading, +}: { + icon: LucideIcon; + label: string; + value: string | number; + sub?: string; + tone?: "default" | "primary"; + loading?: boolean; +}) { + return ( + + + {label} +
+ +
+
+ + {loading ? ( + <> + + + + ) : ( + <> +
{value}
+ {sub &&

{sub}

} + + )} +
+
+ ); +} + +function QuickAction({ + icon: Icon, title, description, to, accent, +}: { + icon: LucideIcon; + title: string; + description: string; + to: string; + accent: string; +}) { + const navigate = useNavigate(); + return ( + navigate(to)} + > +
+
+ +
+
+

{title}

+

{description}

+
+
+
+ ); +} + +function EmptyLine({ icon: Icon, title, hint }: { icon: LucideIcon; title: string; hint: string }) { + return ( +
+ +

{title}

+

{hint}

+
+ ); +} + +export function CommunityDashboard() { + const { user } = useCommunityAuth(); + const [data, setData] = useState(EMPTY); + const [failures, setFailures] = useState>({}); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const errors: Record = {}; + const say = (err: unknown) => (err instanceof Error ? err.message : String(err)); + + const [leases, available] = await Promise.all([ + communityApi.listLeases().catch((err) => { errors["Your leases"] = say(err); return null; }), + communityApi.listAvailableNodes().catch((err) => { errors["Marketplace"] = say(err); return null; }), + ]); + + // Leases carry only a node id. Resolve the ones we can so the panel can + // say "homelab-01 in Kathmandu" rather than showing a bare uuid. + const active = (leases ?? []).filter((l) => l.active && l.customer_id === user?.id); + const resolved = await Promise.all( + active.map((l) => communityApi.getNode(l.node_id).catch(() => null)), + ); + + setData((prev) => ({ + leases: leases ?? prev.leases, + available: available ?? prev.available, + leasedNodes: Object.fromEntries(resolved.filter(Boolean).map((n) => [n!.id, n!])), + })); + setFailures(errors); + setLoading(false); + setRefreshing(false); + }, [user?.id]); + + useEffect(() => { + load(); + // Availability is derived from heartbeats, so it can change while this + // page is open. Poll rather than leaving a stale count on screen. + const timer = setInterval(load, 30000); + return () => clearInterval(timer); + }, [load]); + + const refresh = () => { + setRefreshing(true); + load(); + }; + + const stats = useMemo(() => { + // `customer_id`, not just `active`: GET /leases returns every lease this + // user is party to -- as customer *and* as the owner of the leased node + // (community/app/routes/lease_routes.py:list_leases). Counting both here + // told a provider who also rents that they held leases on their own + // machines, and put those machines in a "reserved to you alone" list that + // links to a page which correctly does not show them. This portal is the + // renting side only; the owner side is the provider portal's job. + const activeLeases = data.leases.filter((l) => l.active && l.customer_id === user?.id); + return { + activeLeases, + countries: new Set(data.available.map((n) => n.country)).size, + }; + }, [data, user?.id]); + + const firstRun = !loading && stats.activeLeases.length === 0 && data.available.length > 0; + const failureCount = Object.keys(failures).length; + + return ( +
+
+
+

Welcome back!

+

+ Lease hardware that somebody else has lent to the community cloud. +

+
+ +
+ + {failureCount > 0 && ( + + )} + +
+ + + +
+ +
+ + +
+ + {firstRun ? ( + + +
+ +
+
+

Nothing reserved yet

+

+ Browse what is available and lease a machine to run virtual machines, + containers, storage and networks on it. +

+
+ +
+
+ ) : ( + + +
+ Your leases + Machines reserved to you alone +
+ +
+ + {loading && data.leases.length === 0 ? ( + Array.from({ length: 3 }).map((_, i) => ) + ) : stats.activeLeases.length === 0 ? ( + + ) : ( + stats.activeLeases.slice(0, 5).map((lease) => { + const node = data.leasedNodes[lease.node_id]; + return ( + +
+

+ {node?.name ?? {lease.node_id}} +

+

+ + {nodeLocation(node)} + {node ? ` · ${nodeSpecSummary(node)}` : ""} +

+
+
+ reserved + {relativeTime(lease.started_at) && ( +

+ since {relativeTime(lease.started_at)} +

+ )} +
+ + ); + }) + )} + {stats.activeLeases.length > 5 && ( +

+{stats.activeLeases.length - 5} more

+ )} +
+
+ )} + + {/* Lending is a different job, done in a different app -- a real + cross-origin link, not a route. See CommunityPortalChrome.tsx. */} + + +
+
+ +
+
+

Have spare hardware?

+

+ Lend a machine to the community cloud in the provider portal. +

+
+
+ +
+
+
+ ); +} diff --git a/packages/shared/src/components/community/MarketplacePage.tsx b/packages/shared/src/components/community/MarketplacePage.tsx new file mode 100644 index 0000000..f408b71 --- /dev/null +++ b/packages/shared/src/components/community/MarketplacePage.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Cpu, Gauge, HardDrive, Loader2, MapPin, MemoryStick, RefreshCw, ShieldAlert, ShoppingCart, WifiOff, Zap, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { useToast } from "@/hooks/use-toast"; +import { useLease } from "@/hooks/useLease"; +import { communityApi, CommunityNode, measurePing } from "@/services/communityApi"; + +interface PingResult { + medianMs: number | null; + path: string; + error?: string; +} + +function SpecRow({ icon: Icon, label }: { icon: typeof Cpu; label: string }) { + return ( +
+ + {label} +
+ ); +} + +export function MarketplacePage() { + const { toast } = useToast(); + const navigate = useNavigate(); + // Leasing changes the session's tenant scope, not just this page's list -- + // see the comment in `lease` below. + const { selectLease, refresh: refreshLeaseScope } = useLease(); + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pings, setPings] = useState>({}); + /** Which node a lease is in flight for -- leasing is exclusive, so a + * double-click would race two claims on the same machine. */ + const [leasing, setLeasing] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + setNodes(await communityApi.listAvailableNodes()); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load nodes"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + /** + * Authorise with the control plane, then measure browser->worker directly. + * The API is not in the data path, so this reflects the latency a workload + * on this node would actually see from here. + */ + const runPing = async (node: CommunityNode) => { + setPings((p) => ({ ...p, [node.id]: "pending" })); + try { + const session = await communityApi.createPingSession(node.id); + const result = await measurePing(session); + setPings((p) => ({ + ...p, + [node.id]: { medianMs: result.medianMs, path: result.path }, + })); + } catch (err) { + setPings((p) => ({ + ...p, + [node.id]: { + medianMs: null, + path: "unknown", + error: err instanceof Error ? err.message : String(err), + }, + })); + } + }; + + /** + * Leasing is the only way a community customer acquires a tenant scope, so + * it has to update LeaseProvider and not just this page: every compute, + * storage and network route is behind RequireActiveLease, and the API + * client routes core-owned calls through `/leases//core/*` using the + * id LeaseProvider holds. Refreshing only the marketplace list left that + * provider still reporting zero leases, so the customer's first lease + * looked like it had not happened until they reloaded the page by hand. + * + * Selected before the refetch rather than after: `selectLease` persists the + * id, and the refetch prefers the remembered lease over the first in the + * list -- so the machine they just took is the one they land on even when + * they already had others. + */ + const lease = async (node: CommunityNode) => { + setLeasing(node.id); + try { + const newLease = await communityApi.leaseNode(node.id); + selectLease(newLease.id); + refreshLeaseScope(); + toast({ + title: `Leased ${node.name}`, + description: "It's yours alone until you end the lease. Opening your leases…", + }); + await load(); + navigate("/leases"); + } catch (err) { + toast({ + title: "Could not lease node", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setLeasing(null); + } + }; + + return ( +
+
+
+

Marketplace

+

+ {loading + ? "Community hardware available to lease. Test latency before you commit." + : `${nodes.length} machine${nodes.length === 1 ? "" : "s"} available to lease. Test latency before you commit.`} +

+
+ +
+ +
+ +
+

Community hardware is operator-owned

+

+ These machines belong to third parties, and the specs below are + self-reported by their owners. Treat this tier as untrusted: avoid + running workloads with sensitive data on it. +

+
+
+ + {error && ( + + )} + + {loading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!loading && !error && nodes.length === 0 && ( + + + +

No nodes available right now

+

+ Every online node is currently leased. Check back shortly. +

+
+
+ )} + +
+ {nodes.map((node) => { + const ping = pings[node.id]; + return ( + + + {node.name} + {node.trust_tier} + + + + + + + {node.specs.gpu_count ? ( + + ) : null} + +
+ + {ping === "pending" && Measuring…} + {ping && ping !== "pending" && ping.medianMs !== null && ( + + {ping.medianMs.toFixed(0)} ms + via {ping.path} + + )} + {ping && ping !== "pending" && ping.error && ( + {ping.error} + )} + {!ping && node.pingable === false && ( + + Worker offline — cannot be measured + + )} + {!ping && node.pingable !== false && ( + Latency not measured + )} +
+ +
+ + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/shared/src/components/community/MyLeasesPage.tsx b/packages/shared/src/components/community/MyLeasesPage.tsx new file mode 100644 index 0000000..a2d0380 --- /dev/null +++ b/packages/shared/src/components/community/MyLeasesPage.tsx @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Cpu, HardDrive, KeyRound, MapPin, Network, Server } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { useToast } from "@/hooks/use-toast"; +import { useCommunityAuth } from "@/hooks/useCommunityAuth"; +import { useLease } from "@/hooks/useLease"; +import { communityApi, CommunityNode, Lease } from "@/services/communityApi"; +import { nodeLocation, nodeSpecSummary, relativeTime } from "./nodeDisplay"; + +function LeaseCard({ + lease, node, onEnded, +}: { + lease: Lease; + node: CommunityNode | undefined; + onEnded: () => void; +}) { + const { toast } = useToast(); + const navigate = useNavigate(); + const { selectLease } = useLease(); + const [ending, setEnding] = useState(false); + + const end = async () => { + setEnding(true); + try { + await communityApi.endLease(lease.id); + toast({ title: "Lease ended" }); + onEnded(); + } catch (err) { + toast({ + title: "Could not end lease", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setEnding(false); + } + }; + + const openIn = (path: string) => { + selectLease(lease.id); + navigate(path); + }; + + const title = node?.name ?? lease.node_name ?? lease.node_id; + const offline = lease.node_status && lease.node_status !== "online"; + + return ( + + +
+ {title} +

+ {node ? ( + <> + + {nodeLocation(node)} + · + {nodeSpecSummary(node)} + + ) : ( + {lease.node_id} + )} +

+
+
+ + + {lease.active + ? relativeTime(lease.started_at) + ? `reserved to you · ${relativeTime(lease.started_at)}` + : "reserved to you" + : "ended"} + + {lease.active && ( + + )} +
+
+ + {lease.active && ( + + {offline && ( +

+ This machine is {lease.node_status}. Compute on it is unavailable + until it comes back online. +

+ )} + {/* `node_enrolled`, not just `region_id`: the region is created when + the owner registers the machine, so it is set long before the + installer has run. Keying only on it meant this notice never + appeared, and the customer clicked through to Compute to meet a + 409 error banner instead. */} + {!offline && (!lease.region_id || !lease.node_enrolled) && ( +

+ This machine is still joining the compute plane. It usually takes + a minute after the installer runs. +

+ )} +
+ + + + +
+
+ )} +
+ ); +} + +export function MyLeasesPage() { + const { user } = useCommunityAuth(); + const { refresh: refreshLeaseScope } = useLease(); + const [leases, setLeases] = useState([]); + const [nodes, setNodes] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!user) return; + try { + const rows = (await communityApi.listLeases()).filter( + (l) => l.customer_id === user.id, + ); + setLeases(rows); + const resolved = await Promise.all( + rows.map(async (l) => { + try { + return await communityApi.getNode(l.node_id); + } catch { + return null; + } + }), + ); + setNodes(Object.fromEntries(resolved.filter(Boolean).map((n) => [n!.id, n!]))); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load leases"); + } finally { + setLoading(false); + } + }, [user?.id]); + + useEffect(() => { + load(); + }, [load]); + + const onEnded = useCallback(() => { + load(); + refreshLeaseScope(); + }, [load, refreshLeaseScope]); + + const active = leases.filter((l) => l.active); + const ended = leases.filter((l) => !l.active); + + return ( +
+
+

Leases

+

+ Machines reserved to you. A leased machine is yours alone until you + end the lease — nobody else can take it. +

+
+ + {error && ( + + )} + + {loading && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ )} + + {!loading && !error && leases.length === 0 && ( + + + +

No leases

+

+ Reserve a machine from the marketplace to get started. +

+ +
+
+ )} + +
+ {active.map((lease) => ( + + ))} +
+ + {ended.length > 0 && ( +
+

+ + Past leases +

+ {ended.map((lease) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/shared/src/components/community/MyNodesPage.tsx b/packages/shared/src/components/community/MyNodesPage.tsx new file mode 100644 index 0000000..bb2c379 --- /dev/null +++ b/packages/shared/src/components/community/MyNodesPage.tsx @@ -0,0 +1,411 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Activity, ArrowRight, Check, Copy, KeyRound, MapPin, Plus, Radio, Server, Trash2, WifiOff } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { communityApi, CommunityNode } from "@/services/communityApi"; +import { + NodeStatusBadge, NodeUsageBar, nodeLocation, nodeSpecSummary, relativeTime, +} from "./nodeDisplay"; +import { PageHeader } from "./admin/shared"; + +/** Live usage and what is actually running, not just enrollment-time specs. */ +function UtilizationPanel({ node }: { node: CommunityNode }) { + const detail = node.status_detail; + if (node.status !== "online") return null; + if (detail === undefined) return null; // not fetched for this listing + if (detail === null) { + return

Live usage unavailable right now.

; + } + return ( +
+
+ + + {detail.workload_count === 0 + ? "No workloads running" + : `${detail.workload_count} workload${detail.workload_count === 1 ? "" : "s"} running`} + +
+ + + +
+ ); +} + +/** Shown while a node is pending: the one command that finishes enrollment. */ +function InstallInstructions({ node }: { node: CommunityNode }) { + const [copied, setCopied] = useState(false); + const command = node.install_command ?? ""; + + const copy = async () => { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+

+ Run this on the machine you want to contribute. It installs the agent and + finishes enrolling the node. +

+
+ {/* whitespace-pre-wrap + break-all, not overflow-x-auto: this string + is one long unbroken token (the enrollment key in the URL), and a + scrollable-but-not-visibly-scrollable box just clips it at the + container edge -- especially inside a narrow grid card, where it + reads as broken rather than as "there's more, scroll right". */} +
+          {command}
+        
+ +
+

+ The key in this command works once. To add a second machine, register another + node and run its own command there. +

+
+ ); +} + +/** + * Reopens the install command for a node that's still pending, outside the + * one-time "Add a node" flow. + * + * Before this, the only place the command ever appeared again was rendered + * raw inline in the node's card -- the exact box above, at 1/3 the width of + * a wide dialog, where a long unbroken token has nowhere to go. Closing the + * registration dialog (or reloading the page) lost access to it entirely, + * short of retiring the node and starting over. This gives it a permanent, + * properly-sized home instead. + */ +function PendingNodeInstall({ node }: { node: CommunityNode }) { + const [open, setOpen] = useState(false); + return ( + + + + + + + Install the agent + + {node.name} is registered. One step left. + + + + + + + + + ); +} + +const EMPTY_FORM = { name: "", country: "", city: "" }; + +export function MyNodesPage() { + const { toast } = useToast(); + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [submitting, setSubmitting] = useState(false); + const [justRegistered, setJustRegistered] = useState(null); + + const load = useCallback(async (isPoll = false) => { + if (isPoll) setRefreshing(true); + try { + setNodes(await communityApi.listMyNodes()); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load nodes"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + load(); + // Status is derived from heartbeats, so a node can go offline while this + // page is open. Poll rather than leaving a stale badge on screen. + const timer = setInterval(() => load(true), 30000); + return () => clearInterval(timer); + }, [load]); + + const countryValid = /^[A-Za-z]{2}$/.test(form.country.trim()); + const canSubmit = form.name.trim().length > 0 && countryValid && !submitting; + + const register = async () => { + if (!canSubmit) return; + setSubmitting(true); + try { + const node = await communityApi.registerNode({ + name: form.name.trim(), + country: form.country.trim().toUpperCase(), + city: form.city.trim() || undefined, + }); + setJustRegistered(node); + setForm(EMPTY_FORM); + await load(); + } catch (err) { + toast({ + title: "Could not register node", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + const retire = async (node: CommunityNode) => { + try { + await communityApi.retireNode(node.id); + toast({ title: `${node.name} retired` }); + await load(); + } catch (err) { + toast({ + title: "Could not retire node", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } + }; + + const active = nodes.filter((n) => n.status !== "retired"); + const online = active.filter((n) => n.status === "online").length; + + return ( +
+ load(true)} + refreshing={refreshing} + actions={ + { + setDialogOpen(open); + if (!open) setJustRegistered(null); + }} + > + + + + + + + {justRegistered ? "Install the agent" : "Register a node"} + + + {justRegistered + ? `${justRegistered.name} is registered. One step left.` + : "Describe the machine. You will get a command to run on it."} + + + + {justRegistered ? ( + + ) : ( +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="homelab-01" + /> +
+
+
+ + setForm((f) => ({ ...f, country: e.target.value.toUpperCase() }))} + placeholder="NP" + /> +

+ Two-letter code. Machines you add in the same country share + one placement region. +

+
+
+ + setForm((f) => ({ ...f, city: e.target.value }))} + placeholder="Kathmandu" + /> +
+
+
+ )} + + + {justRegistered ? ( + + ) : ( + + )} + +
+
+ } + /> + + {error && ( + + )} + + {loading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!loading && !error && active.length === 0 && ( + + + +

No nodes yet

+

+ Add a machine to start contributing spare capacity. +

+
+
+ )} + +
+ {active.map((node) => ( + + + {node.name} + + + +
+ + {nodeLocation(node)} +
+

{nodeSpecSummary(node)}

+ + {node.status !== "pending" && ( +
+ {node.pingable ? ( + <> + + + Reachable for latency tests + + + ) : ( + <> + + + Not reachable for latency tests + + + )} +
+ )} + + {relativeTime(node.last_heartbeat_at) && ( +

+ Last heartbeat {relativeTime(node.last_heartbeat_at)} +

+ )} + + {/* A pending card would otherwise be two lines and a button. + Say what "pending" means so it reads as a step, not a fault. */} + {node.status === "pending" && ( +

+ Registered, waiting on its first check-in. Run the install command + on the machine to finish enrollment. +

+ )} + + {/* Heartbeating over HTTP (community's own liveness signal) and + having joined core's compute plane (able to run workloads) + are two different things that happen a few seconds apart at + worker startup. This is that gap, not a missing feature -- + it should clear itself within a minute of the agent starting. */} + {node.status === "online" && !node.core_workload_host_id && ( +

+ Enrolled and reachable. Still joining the compute plane — + this usually clears within a minute of the agent starting. +

+ )} + + + + {node.status === "pending" && node.install_command && ( + + )} + +
+ {/* Everything else about this machine -- rental history, who + is running what on it, live utilisation over time -- lives + on its own page. The card stays a summary. */} + + +
+
+
+ ))} +
+
+ ); +} diff --git a/packages/shared/src/components/community/admin/AdminAllNodesPage.tsx b/packages/shared/src/components/community/admin/AdminAllNodesPage.tsx new file mode 100644 index 0000000..7d58968 --- /dev/null +++ b/packages/shared/src/components/community/admin/AdminAllNodesPage.tsx @@ -0,0 +1,167 @@ +/** + * Every node on the platform, whoever owns it. + * + * The provider's own /provider/nodes shows the same machines from the other + * side -- theirs, with the install command and the retire button. This one is + * cross-owner and read-only: an operator diagnoses here, they do not withdraw + * somebody else's hardware from under a paying customer. + */ +import { useMemo, useState } from "react"; +import { Activity, Cpu, Search } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { communityApi } from "@/services/communityApi"; +import type { AdminWorkloadRow, CommunityNode } from "@/services/communityApi"; +import { + NodeStatusBadge, NodeUsageBar, nodeLocation, nodeSpecSummary, relativeTime, +} from "../nodeDisplay"; +import { LoadFailed, PageHeader, TableSkeleton, usePolledData } from "./shared"; + +interface Data { + nodes: CommunityNode[]; + workloads: AdminWorkloadRow[]; +} + +export function AdminAllNodesPage() { + const [query, setQuery] = useState(""); + + const { data, loading, refreshing, error, reload } = usePolledData(async () => { + const [nodes, workloads] = await Promise.all([ + communityApi.listAllNodes(), + communityApi.adminWorkloads(), + ]); + return { nodes, workloads }; + }); + + // Memoised, not `data?.nodes ?? []` inline: a fresh [] on every render + // would change the filter's dependency every time and defeat its memo. + const nodes = useMemo(() => data?.nodes ?? [], [data?.nodes]); + + // Keyed by core host id, because that is the only join core gives us + // between a workload and a machine (see community/app/routes/admin_routes.py). + const workloadsByHost = useMemo(() => { + const acc: Record = {}; + for (const w of data?.workloads ?? []) { + if (!w.host_id) continue; + (acc[w.host_id] ??= []).push(w); + } + return acc; + }, [data?.workloads]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return nodes; + return nodes.filter((n) => + [n.name, n.owner_email, n.country, n.city, n.status] + .some((field) => (field ?? "").toLowerCase().includes(q))); + }, [nodes, query]); + + return ( +
+ + + setQuery(e.target.value)} + placeholder="Filter by name, owner, country…" + className="w-64 pl-8" + /> +
+ } + /> + + {error && } + + {loading && nodes.length === 0 && } + + {!loading && nodes.length === 0 && !error && ( + + + +

No nodes registered yet

+

+ A node appears here the moment a provider registers it, before it enrolls. +

+
+
+ )} + + {nodes.length > 0 && filtered.length === 0 && ( + + + No node matches “{query}”. + + + )} + +
+ {filtered.map((node) => { + const detail = node.status_detail; + const hostWorkloads = node.core_workload_host_id + ? workloadsByHost[node.core_workload_host_id] ?? [] + : []; + return ( + + + {node.name} + + + +

{node.owner_email}

+

+ {nodeLocation(node)} · {nodeSpecSummary(node)} +

+ {relativeTime(node.last_heartbeat_at) && ( +

+ Last heartbeat {relativeTime(node.last_heartbeat_at)} +

+ )} +

+ compute plane:{" "} + {node.core_workload_host_id ? ( + {node.core_workload_host_id.slice(0, 8)} + ) : ( + not enrolled + )} +

+ + {node.status === "online" && detail && ( +
+
+ + + {detail.workload_count} workload{detail.workload_count === 1 ? "" : "s"} + +
+ + + +
+ )} + + {hostWorkloads.length > 0 && ( +
+ {hostWorkloads.map((w) => ( +
+ {w.name} + {w.customer_email ?? "—"} +
+ ))} +
+ )} +
+
+ ); + })} +
+ + ); +} diff --git a/packages/shared/src/components/community/admin/AdminLeasesPage.tsx b/packages/shared/src/components/community/admin/AdminLeasesPage.tsx new file mode 100644 index 0000000..a32a2b8 --- /dev/null +++ b/packages/shared/src/components/community/admin/AdminLeasesPage.tsx @@ -0,0 +1,141 @@ +/** + * Every lease ever taken, active or ended. + * + * A lease is the tenancy boundary of this whole plane -- its id is the + * `tenant_id` core stamps on every workload, network and volume created + * through the gateway -- so this table is also the index of who was + * responsible for what, including after the fact. Ended leases stay listed + * for exactly that reason. + */ +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { communityApi } from "@/services/communityApi"; +import type { AdminLeaseRow } from "@/services/communityApi"; +import { formatHours, LoadFailed, PageHeader, TableSkeleton, usePolledData } from "./shared"; + +function when(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso.endsWith("Z") ? iso : `${iso}Z`).toLocaleString(); +} + +/** Elapsed hours, still counting while the lease is live. */ +function elapsedHours(lease: AdminLeaseRow): number { + if (!lease.started_at) return 0; + const start = new Date(lease.started_at.endsWith("Z") ? lease.started_at : `${lease.started_at}Z`); + const endIso = lease.ended_at; + const end = endIso + ? new Date(endIso.endsWith("Z") ? endIso : `${endIso}Z`) + : new Date(); + return Math.max(0, (end.getTime() - start.getTime()) / 3600000); +} + +export function AdminLeasesPage() { + const [query, setQuery] = useState(""); + const [tab, setTab] = useState<"active" | "all">("active"); + const { data, loading, refreshing, error, reload } = + usePolledData(() => communityApi.adminLeases()); + + // Memoised so the filter below keeps a stable dependency; `data ?? []` + // inline hands it a new array on every render. + const leases = useMemo(() => data ?? [], [data]); + const active = leases.filter((l) => l.active).length; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return leases + .filter((l) => (tab === "active" ? l.active : true)) + .filter((l) => !q || [l.node_name, l.customer_email, l.owner_email] + .some((field) => (field ?? "").toLowerCase().includes(q))); + }, [leases, query, tab]); + + return ( +
+ + + setQuery(e.target.value)} + placeholder="Filter by node, customer, provider…" + className="w-64 pl-8" + /> +
+ } + /> + + {error && } + + setTab(v as "active" | "all")}> + + Active ({active}) + All ({leases.length}) + + + + {loading && leases.length === 0 ? ( + + ) : ( + + +
+ + + + Node + Customer + Provider + Started + Duration + Status + + + + {filtered.length === 0 && ( + + + {leases.length === 0 + ? "Nobody has leased a machine yet." + : tab === "active" && !query + ? "No lease is active right now." + : `No lease matches “${query}”.`} + + + )} + {filtered.map((l) => ( + + {l.node_name ?? l.node_id} + {l.customer_email ?? "—"} + {l.owner_email ?? "—"} + {when(l.started_at)} + + {formatHours(elapsedHours(l))} + + + + {l.active ? "active" : "ended"} + + + + ))} + +
+
+
+
+ )} + + ); +} diff --git a/packages/shared/src/components/community/admin/AdminOverviewPage.tsx b/packages/shared/src/components/community/admin/AdminOverviewPage.tsx new file mode 100644 index 0000000..fcd749c --- /dev/null +++ b/packages/shared/src/components/community/admin/AdminOverviewPage.tsx @@ -0,0 +1,121 @@ +/** + * The operator's front page: is the platform healthy, and who is on it. + * + * Deliberately counts and recent activity only. Every number here links to + * the screen that explains it -- an operator who wants the list goes to the + * list, and does not pay for four table loads to read six figures. + */ +import { Link } from "react-router-dom"; +import { Boxes, KeyRound, Server, Users } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { communityApi } from "@/services/communityApi"; +import type { AdminLeaseRow, AdminOverview } from "@/services/communityApi"; +import { LoadFailed, PageHeader, StatCard, TableSkeleton, usePolledData } from "./shared"; + +interface Data { + overview: AdminOverview; + leases: AdminLeaseRow[]; +} + +export function AdminOverviewPage() { + const { data, loading, refreshing, error, reload } = usePolledData(async () => { + const [overview, leases] = await Promise.all([ + communityApi.adminOverview(), + communityApi.adminLeases(), + ]); + return { overview, leases }; + }); + + const overview = data?.overview; + const recent = (data?.leases ?? []).slice(0, 10); + + return ( +
+ + + {error && } + + {loading && !overview ? ( + + ) : overview ? ( +
+ + + + +
+ ) : null} + + {/* The gap between "registered" and "actually schedulable" is the one + number that most often explains a support ticket, so it gets said in + words rather than left as a difference between two tiles. */} + {overview && overview.nodes_total > overview.nodes_enrolled_into_core && ( + + + {overview.nodes_total - overview.nodes_enrolled_into_core} node + {overview.nodes_total - overview.nodes_enrolled_into_core === 1 ? " has" : "s have"} not + finished joining the compute plane, so nothing can be scheduled on them yet.{" "} + See which. + + + )} + + + + Recent leases + + All leases + + + + + + + Node + Customer + Provider + Status + + + + {recent.length === 0 && ( + + + Nobody has leased a machine yet. + + + )} + {recent.map((l) => ( + + {l.node_name ?? l.node_id} + {l.customer_email ?? "—"} + {l.owner_email ?? "—"} + + + {l.active ? "active" : "ended"} + + + + ))} + +
+
+
+
+ ); +} diff --git a/packages/shared/src/components/community/admin/AdminUsersPage.tsx b/packages/shared/src/components/community/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..18b80b7 --- /dev/null +++ b/packages/shared/src/components/community/admin/AdminUsersPage.tsx @@ -0,0 +1,110 @@ +/** + * Everyone on the platform, and what they hold. + * + * There is no signup: an account exists the moment a request names one + * (community/app/auth_utils.py), so this is the only place a full roster is + * visible at all. A row is not "a provider" or "a customer" -- the same + * person can lend one machine and rent another, which is why the counts are + * two columns rather than a role. + */ +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { communityApi } from "@/services/communityApi"; +import type { AdminUserRow } from "@/services/communityApi"; +import { LoadFailed, PageHeader, TableSkeleton, usePolledData } from "./shared"; + +function joined(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso.endsWith("Z") ? iso : `${iso}Z`).toLocaleDateString(); +} + +export function AdminUsersPage() { + const [query, setQuery] = useState(""); + const { data, loading, refreshing, error, reload } = + usePolledData(() => communityApi.adminUsers()); + + // Memoised so the filter below keeps a stable dependency; `data ?? []` + // inline hands it a new array on every render. + const users = useMemo(() => data ?? [], [data]); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return users; + return users.filter((u) => u.email.toLowerCase().includes(q)); + }, [users, query]); + + const providers = users.filter((u) => u.nodes_owned > 0).length; + const customers = users.filter((u) => u.active_leases > 0).length; + + return ( +
+ + + setQuery(e.target.value)} + placeholder="Filter by email…" + className="w-64 pl-8" + /> +
+ } + /> + + {error && } + + {loading && users.length === 0 ? ( + + ) : ( + + + + + + Email + Role + Joined + Nodes lent + Active leases + + + + {filtered.length === 0 && ( + + + {users.length === 0 ? "Nobody has used the platform yet." : `No account matches “${query}”.`} + + + )} + {filtered.map((u) => ( + + {u.email} + + {u.is_platform_admin + ? operator + : —} + + {joined(u.created_at)} + {u.nodes_owned} + {u.active_leases} + + ))} + +
+
+
+ )} + + ); +} diff --git a/packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx b/packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx new file mode 100644 index 0000000..4069031 --- /dev/null +++ b/packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx @@ -0,0 +1,120 @@ +/** + * Every workload on the platform, attributed both ways. + * + * This join does not exist anywhere else. Core stores a `tenant_id` and a + * `workload_host_id` and has never heard of a person; the community plane is + * where those two ids become "this VM, run by this customer, on that + * provider's machine" (community/app/routes/admin_routes.py). + * + * First-party workloads show up too, with no node and no customer, rather + * than being filtered out -- an operator asking "what is running" wants the + * whole answer, and a row that silently vanished would be the one worth + * seeing. + */ +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { communityApi } from "@/services/communityApi"; +import type { AdminWorkloadRow } from "@/services/communityApi"; +import { LoadFailed, PageHeader, TableSkeleton, WorkloadStatusBadge, usePolledData } from "./shared"; + +export function AdminWorkloadsPage() { + const [query, setQuery] = useState(""); + const { data, loading, refreshing, error, reload } = + usePolledData(() => communityApi.adminWorkloads()); + + // Memoised so the filter below keeps a stable dependency; `data ?? []` + // inline hands it a new array on every render. + const workloads = useMemo(() => data ?? [], [data]); + const onCommunity = workloads.filter((w) => w.is_community_hardware).length; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return workloads; + return workloads.filter((w) => + [w.name, w.type, w.status, w.customer_email, w.node?.name, w.node?.owner_email] + .some((field) => (field ?? "").toLowerCase().includes(q))); + }, [workloads, query]); + + return ( +
+ + + setQuery(e.target.value)} + placeholder="Filter by name, customer, node…" + className="w-64 pl-8" + /> +
+ } + /> + + {error && } + + {loading && workloads.length === 0 ? ( + + ) : ( + + +
+ + + + Name + Type + Status + Customer + Running on + Provider + + + + {filtered.length === 0 && ( + + + {workloads.length === 0 + ? "Nothing is running yet." + : `No workload matches “${query}”.`} + + + )} + {filtered.map((w) => ( + + {w.name} + {w.type} + + {w.customer_email ?? "—"} + + {w.node ? w.node.name : ( + {w.host_id ?? "unplaced"} + )} + + + {w.node?.owner_email ?? ( + w.is_community_hardware ? "—" : first-party + )} + + + ))} + +
+
+
+
+ )} + + ); +} diff --git a/packages/shared/src/components/community/admin/shared.tsx b/packages/shared/src/components/community/admin/shared.tsx new file mode 100644 index 0000000..a07f50d --- /dev/null +++ b/packages/shared/src/components/community/admin/shared.tsx @@ -0,0 +1,167 @@ +/** + * Pieces every operator screen uses. + * + * The operator portal is five focused routes rather than one page that loads + * the whole platform: nodes, workloads, users and leases each answer a + * different question, and an operator opening "who is renting what" should + * not pay for a cross-region workload sweep to get there. What they share is + * the chrome -- a header with a refresh, an error banner, a skeleton, a stat + * tile -- so that lives here instead of being copied five times. + */ +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { RefreshCw, type LucideIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; + +/** How often an operator screen re-reads. Slow enough not to hammer core + * (every workload sweep is an upstream call), fast enough that a node going + * offline shows up while you are still looking at the page. */ +export const OPERATOR_POLL_MS = 30000; + +/** + * Load once, then poll, with the loading/error bookkeeping every one of + * these screens would otherwise repeat. + * + * `reload` is deliberately not part of the loading flag after the first + * fetch: a poll that flips the page back to skeletons every 30 seconds is + * worse than a slightly stale table. + */ +export function usePolledData(fetcher: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (isPoll = false) => { + if (isPoll) setRefreshing(true); + try { + const result = await fetcher(); + setData(result); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Request failed"); + } finally { + setLoading(false); + setRefreshing(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + useEffect(() => { + load(); + const timer = setInterval(() => load(true), OPERATOR_POLL_MS); + return () => clearInterval(timer); + }, [load]); + + return { data, loading, refreshing, error, reload: () => load(true) }; +} + +export function PageHeader({ + title, description, onRefresh, refreshing, actions, +}: { + title: string; + description: string; + onRefresh?: () => void; + refreshing?: boolean; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+
+ {actions} + {onRefresh && ( + + )} +
+
+ ); +} + +export function StatCard({ + icon: Icon, label, value, sub, +}: { + icon: LucideIcon; + label: string; + value: number | string; + sub?: string; +}) { + return ( + + +
+ +
+
+

{value}

+

{label}

+ {sub &&

{sub}

} +
+
+
+ ); +} + +export function WorkloadStatusBadge({ status }: { status: string | null }) { + const s = (status || "unknown").toLowerCase(); + const variant = + s === "running" || s === "online" ? "default" + : s === "error" || s.includes("fail") ? "destructive" + : "outline"; + return {status ?? "unknown"}; +} + +export function LoadFailed({ + what, error, onRetry, retrying, +}: { + what: string; + error: string; + onRetry: () => void; + retrying: boolean; +}) { + return ( + + ); +} + +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ); +} + +export function EmptyRow({ colSpan, children }: { colSpan: number; children: ReactNode }) { + return ( + + + {children} + + + ); +} + +/** "3.2 h", "1,204 h" -- hours served, the unit the provider views report in. */ +export function formatHours(hours: number): string { + if (hours < 1) return `${Math.round(hours * 60)} min`; + if (hours < 10) return `${hours.toFixed(1)} h`; + return `${Math.round(hours).toLocaleString()} h`; +} diff --git a/packages/shared/src/components/community/nodeDisplay.tsx b/packages/shared/src/components/community/nodeDisplay.tsx new file mode 100644 index 0000000..8ed3876 --- /dev/null +++ b/packages/shared/src/components/community/nodeDisplay.tsx @@ -0,0 +1,112 @@ +/** + * How a community node is described on screen, in one place. + * + * The three node surfaces -- your own list, the operator's, and the overview -- + * had three copies of this between them, which is how "online" ended up a + * different colour depending on which page you were looking at. They render + * the same machine, so they read it the same way here. + */ +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { CommunityNode, NodeResourceUsage } from "@/services/communityApi"; + +/** Colour carries the state before the word is read. */ +export function NodeStatusBadge({ status }: { status: CommunityNode["status"] }) { + const tone = + status === "online" + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400" + : status === "pending" + ? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400" + : status === "offline" + ? "border-destructive/30 bg-destructive/10 text-destructive" + : "border-border bg-muted text-muted-foreground"; + return ( + + + {status} + + ); +} + +export function nodeLocation(node: CommunityNode | undefined): string { + if (!node) return "—"; + return node.city ? `${node.city}, ${node.country}` : node.country; +} + +/** Enrollment-reported hardware, in the order it is usually asked about. */ +export function nodeSpecSummary(node: CommunityNode): string { + const { cpu_cores, memory_mb, disk_gb, gpu_count, gpu_model } = node.specs; + if (!cpu_cores && !memory_mb) return "Specs are reported by the machine at enrollment"; + const parts = [`${cpu_cores ?? "?"} cores`, `${Math.round((memory_mb ?? 0) / 1024)} GB RAM`]; + if (disk_gb) parts.push(`${disk_gb} GB disk`); + if (gpu_count) parts.push(`${gpu_count}× ${gpu_model ?? "GPU"}`); + return parts.join(" · "); +} + +/** Heartbeats, lease starts -- anything whose age is what matters about it. */ +export function relativeTime(iso: string | null | undefined): string | null { + if (!iso) return null; + // The community API serialises naive UTC (datetime.utcnow().isoformat()), + // which has no zone suffix -- and `new Date("2026-08-30T15:29:45")` is + // parsed as *local* time by every browser. Without the Z, a node + // heartbeating a second ago reads as hours stale or hours in the future, + // depending on which side of UTC the viewer is on. + const seconds = Math.max( + 0, + (Date.now() - new Date(iso.endsWith("Z") ? iso : `${iso}Z`).getTime()) / 1000, + ); + if (seconds < 90) return "just now"; + if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`; + if (seconds < 86400) return `${Math.round(seconds / 3600)} h ago`; + return `${Math.round(seconds / 86400)} d ago`; +} + +export function usagePercent(usage: NodeResourceUsage | undefined): number | null { + if (!usage || !usage.total_quantity) return null; + return Math.min(100, Math.round((usage.quantity_in_use / usage.total_quantity) * 100)); +} + +/** "3.2", "8", "1,024" -- a whole number loses its pointless ".0". */ +function trimNum(n: number): string { + const r = Math.round(n * 10) / 10; + return (Number.isInteger(r) ? r : Number(r.toFixed(1))).toLocaleString(); +} + +/** + * Fold a raw quantity up its unit ladder so a bar reads "13 / 32 GB", never + * "12800.0 / 32768.0 MB". core reports memory in MB and disk in GB; past a + * thousand of either, the next unit up is the one a person actually reads in. + */ +function scaleUsage(inUse: number, total: number, unit: string): [string, string, string] { + const ladder = unit === "MB" ? ["MB", "GB", "TB"] : unit === "GB" ? ["GB", "TB"] : [unit]; + let i = 0; + while (i < ladder.length - 1 && total >= 1024) { + inUse /= 1024; + total /= 1024; + i += 1; + } + return [trimNum(inUse), trimNum(total), ladder[i]]; +} + +export function NodeUsageBar({ + label, usage, unit, +}: { + label: string; + usage: NodeResourceUsage | undefined; + unit: string; +}) { + const pct = usagePercent(usage); + if (pct === null || !usage) return null; + const [used, cap, scaledUnit] = scaleUsage(usage.quantity_in_use, usage.total_quantity, unit); + return ( +
+
+ {label} + + {used} / {cap} {scaledUnit} · {pct}% + +
+ +
+ ); +} diff --git a/packages/shared/src/components/community/provider/NodeDetailPage.tsx b/packages/shared/src/components/community/provider/NodeDetailPage.tsx new file mode 100644 index 0000000..464b4a4 --- /dev/null +++ b/packages/shared/src/components/community/provider/NodeDetailPage.tsx @@ -0,0 +1,350 @@ +/** + * One of my machines, in detail: is it healthy, who is renting it, what is + * running on it. + * + * The workload list here is deliberately thinner than the customer's own view + * of the same containers. A provider gets names, sizes, status and who is + * responsible -- everything needed to run the machine and to answer for it -- + * and not `launch_params`: not the image, not the environment, not the keys. + * Owning the disk a workload sits on is not the same as being handed its + * contents by the control plane. The redaction is done server-side + * (community/app/routes/provider_routes.py); this page could not show more + * even if it tried. + */ +import { useCallback, useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { + Activity, AlertTriangle, ArrowLeft, Clock, Copy, KeyRound, RefreshCw, Trash2, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog"; +import { communityApi } from "@/services/communityApi"; +import type { + CommunityNode, ProviderLeaseRow, ProviderWorkloadRow, +} from "@/services/communityApi"; +import { + NodeStatusBadge, NodeUsageBar, nodeLocation, nodeSpecSummary, relativeTime, +} from "../nodeDisplay"; +import { formatHours, StatCard, WorkloadStatusBadge } from "../admin/shared"; + +export function NodeDetailPage() { + const { nodeId = "" } = useParams(); + const navigate = useNavigate(); + const { toast } = useToast(); + + const [node, setNode] = useState(null); + const [leases, setLeases] = useState([]); + const [workloads, setWorkloads] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [retireOpen, setRetireOpen] = useState(false); + const [retiring, setRetiring] = useState(false); + + const load = useCallback(async (isPoll = false) => { + if (isPoll) setRefreshing(true); + try { + // The node itself must load; the other two are extras that a provider + // can still use the page without. A node whose worker has not enrolled + // has no workloads endpoint answer at all, and that is not an error. + const nodeRes = await communityApi.getNode(nodeId); + setNode(nodeRes); + const [leasesRes, workloadsRes] = await Promise.all([ + communityApi.nodeLeases(nodeId).catch(() => [] as ProviderLeaseRow[]), + communityApi.nodeWorkloads(nodeId).catch(() => [] as ProviderWorkloadRow[]), + ]); + setLeases(leasesRes); + setWorkloads(workloadsRes); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not load this machine"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [nodeId]); + + useEffect(() => { + load(); + const timer = setInterval(() => load(true), 30000); + return () => clearInterval(timer); + }, [load]); + + const activeLease = leases.find((l) => l.active) ?? null; + const hoursServed = leases.reduce((sum, l) => sum + l.hours, 0); + + const retire = async () => { + setRetiring(true); + try { + await communityApi.retireNode(nodeId); + toast({ title: "Machine withdrawn", description: "Its credentials are revoked and it will stop reporting." }); + navigate("/nodes"); + } catch (err) { + toast({ + title: "Could not withdraw this machine", + description: err instanceof Error ? err.message : undefined, + variant: "destructive", + }); + } finally { + setRetiring(false); + setRetireOpen(false); + } + }; + + const copyInstall = () => { + if (!node?.install_command) return; + navigator.clipboard.writeText(node.install_command); + toast({ title: "Install command copied" }); + }; + + if (loading && !node) { + return ( +
+ + + +
+ ); + } + + if (error && !node) { + return load()} isRetrying={refreshing} />; + } + if (!node) return null; + + const detail = node.status_detail; + + return ( +
+
+
+ +
+

{node.name}

+ +
+

+ {nodeLocation(node)} · {nodeSpecSummary(node)} +

+
+
+ + +
+
+ + {error && load(true)} isRetrying={refreshing} />} + + {/* A registered-but-not-installed node is the single most common reason + a provider sees nothing happening, so it leads with the fix. */} + {node.status === "pending" && node.install_command && ( + + + + + This machine has not been installed yet + + + Run this on the machine itself. It joins the compute plane on its own and appears + on the marketplace once it reports in. + + + +
+              {node.install_command}
+            
+ +
+
+ )} + +
+ + + +
+ + + + Health & rental + + +
+ Right now + + {activeLease ? ( + <>Rented by {activeLease.customer_email ?? "someone"} + ) : node.status === "online" ? ( + "Idle · listed on the marketplace" + ) : ( + "Not rentable" + )} + +
+
+ Compute plane + {node.core_workload_host_id ? ( + + + Joined + + ) : ( + Not joined + )} +
+ {node.status === "online" && detail ? ( +
+ + + +
+ ) : ( +

+ {node.status === "online" + ? "Live usage is not being reported right now." + : "Live usage appears once this machine is online."} +

+ )} +
+
+ + + + Running on this machine + + Names and sizes only. What a customer's workload actually contains is theirs. + + + + + + + Name + Type + Status + Customer + + + + {workloads.length === 0 && ( + + + {node.core_workload_host_id + ? "Nothing is running on this machine." + : "This machine has not joined the compute plane yet."} + + + )} + {workloads.map((w) => ( + + + {w.name} + {w.is_sidecar && ( + platform + )} + + {w.type ?? "—"} + + {w.customer_email ?? "—"} + + ))} + +
+
+
+ + + + Rental history + + + + + + Customer + Started + Duration + Status + + + + {leases.length === 0 && ( + + + Nobody has rented this machine yet. + + + )} + {leases.map((l) => ( + + {l.customer_email ?? "—"} + {relativeTime(l.started_at) ?? "—"} + + {formatHours(l.hours)} + + + + {l.active ? "active" : "ended"} + + + + ))} + +
+
+
+ + +
+ ); +} diff --git a/packages/shared/src/components/community/provider/ProviderOverviewPage.tsx b/packages/shared/src/components/community/provider/ProviderOverviewPage.tsx new file mode 100644 index 0000000..f71b7d3 --- /dev/null +++ b/packages/shared/src/components/community/provider/ProviderOverviewPage.tsx @@ -0,0 +1,165 @@ +/** + * The provider's front page: is my hardware working, and is anyone using it. + * + * Reports hours served rather than money. There is no billing plane and no + * price on a node -- migration 0003 dropped `hourly_price` -- so a currency + * figure here would be invented. Hours is the real measured quantity a payout + * would be computed from, and the page says so rather than leaving a provider + * to assume a number means dollars. + */ +import { Link } from "react-router-dom"; +import { Clock, Server, Signal, Users } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { communityApi } from "@/services/communityApi"; +import type { ProviderSummary } from "@/services/communityApi"; +import { relativeTime } from "../nodeDisplay"; +import { + formatHours, LoadFailed, PageHeader, StatCard, TableSkeleton, usePolledData, +} from "../admin/shared"; + +export function ProviderOverviewPage() { + const { data, loading, refreshing, error, reload } = + usePolledData(() => communityApi.providerSummary()); + + return ( +
+ + Manage nodes + + } + /> + + {error && } + + {loading && !data ? ( + + ) : data ? ( + <> +
+ + + 0 + ? `${formatHours(data.hours_served_active)} on live leases` + : "no live lease"} + /> + +
+ + {/* Said plainly rather than implied by a currency symbol nobody set -- + in the same thin-notice card the operator overview uses for its + one caveat, rather than as a line of text floating on its own. */} + {data.hours_served_total > 0 && ( + + + Hours are measured lease time. Payouts are not calculated here — there is no + billing plane yet and no price set on a node. + + + )} + + {data.nodes_total === 0 && ( + + + You have not lent a machine yet + + Register a machine and run the one-line installer it gives you. It joins the + compute plane on its own and appears on the marketplace once it is online. + + + + + + + )} + + {data.nodes_pending > 0 && ( + + + {data.nodes_pending} machine{data.nodes_pending === 1 ? "" : "s"} registered but not + yet installed. Nothing can be scheduled on{" "} + {data.nodes_pending === 1 ? "it" : "them"} until the installer has run.{" "} + + Get the install command + . + + + )} + + + + Recent rentals + Who has taken your machines, and for how long. + + + + + + Customer + Started + Duration + Status + + + + {data.recent_leases.length === 0 && ( + + + Nobody has rented your hardware yet. + + + )} + {data.recent_leases.map((l) => ( + + {l.customer_email ?? "—"} + + {relativeTime(l.started_at) ?? "—"} + + + {formatHours(l.hours)} + + + + {l.active ? "active" : "ended"} + + + + ))} + +
+
+
+ + ) : null} +
+ ); +} diff --git a/packages/shared/src/services/communityApi.ts b/packages/shared/src/services/communityApi.ts new file mode 100644 index 0000000..e846dcb --- /dev/null +++ b/packages/shared/src/services/communityApi.ts @@ -0,0 +1,536 @@ +const normalizeUrl = (url: string) => url.replace(/\/+$/, ""); + +export const COMMUNITY_API_BASE_URL = normalizeUrl( + import.meta.env.VITE_COMMUNITY_API_BASE_URL || "http://localhost:5002/api", +); + +// The other two community-plane portals, for the handful of cross-links that +// need one (the customer dashboard's "lend hardware instead" card, the +// provider/operator apps have no need to link back). Three separate +// deployments -- see CommunityPortalChrome.tsx -- so a Link cannot cross +// between them; these are real origins, not routes. +export const PROVIDER_PORTAL_URL = normalizeUrl( + import.meta.env.VITE_PROVIDER_PORTAL_URL || "http://localhost:8084", +); +export const ADMIN_PORTAL_URL = normalizeUrl( + import.meta.env.VITE_ADMIN_PORTAL_URL || "http://localhost:8086", +); + +export interface NodeSpecs { + cpu_cores: number | null; + memory_mb: number | null; + disk_gb: number | null; + gpu_model: string | null; + gpu_count: number | null; + kernel: string | null; + arch: string | null; + /** Reported by the machine's owner, not verified by us. */ + self_reported: boolean; +} + +export interface CommunityNode { + id: string; + name: string; + owner_id: string; + status: "pending" | "online" | "offline" | "retired"; + trust_tier: string; + is_leasable: boolean; + /** ISO-3166 alpha-2, chosen by the owner at registration. */ + country: string; + city: string | null; + last_heartbeat_at: string | null; + enrolled_at: string | null; + specs: NodeSpecs; + created_at: string | null; + /** + * Whether the node's worker currently holds a signaling session. Distinct + * from `status`: a machine can be heartbeating over HTTP while its worker + * has no way to be reached for a latency test. + */ + pingable?: boolean; + /** Only present for the node's owner, while it is still pending. */ + enrollment_key?: string; + install_command?: string; + /** Only present in the admin listing. */ + owner_email?: string; + core_workload_host_id?: string | null; + /** + * Live usage, not enrollment-time specs. Only present on "my nodes" and the + * admin listing (see node_routes._serialize, with_status) -- fetched from + * core per node, so the public marketplace listing never pays for it and + * never leaks what a node is currently running to a browsing stranger. + * `null` means core was unreachable when this was fetched, not "idle". + */ + status_detail?: NodeStatusDetail | null; +} + +export interface NodeResourceUsage { + total_quantity: number; + quantity_in_use: number; +} + +export interface NodeStatusDetail { + resources: Record; + workload_count: number; +} + +export interface Lease { + id: string; + node_id: string; + customer_id: string; + started_at: string | null; + ended_at: string | null; + active: boolean; + /** The leased machine, denormalised onto the lease so a scope picker can + * name it without a second call per lease. Null on a lease whose node has + * been deleted outright. */ + node_name: string | null; + node_status: "pending" | "online" | "offline" | "retired" | null; + /** The leased machine has finished joining the compute plane -- i.e. core + * knows it as a workload host. False between registering a node and the + * installer completing on it. The lease gateway refuses every forwarded + * call until this is true (community/app/routes/gateway_routes.py's + * `_placement_for`), so it gates the compute screens here too. */ + node_enrolled: boolean; + node_country: string | null; + node_city: string | null; + /** Core region this lease's workloads land in. Null until the node has + * finished enrolling; the gateway 409s on writes until then. */ + region_id: string | null; +} + +export interface PingSession { + token: string; + node_id: string; + expires_at: string; + /** Base WebSocket URL of the signaling server; the client appends its own id. */ + signaling_url: string; + /** Which node the offer should be routed to. */ + worker_id: string; + ice_servers: RTCIceServer[]; + /** Placeholder, replaced with the real ICE path once connected. */ + path: string; +} + +/** One lease as its provider sees it: who took it, and for how long. */ +export interface ProviderLeaseRow { + id: string; + node_id: string; + customer_email: string | null; + started_at: string | null; + ended_at: string | null; + active: boolean; + /** Elapsed hours, still counting while the lease is live. */ + hours: number; +} + +/** The provider dashboard, in one call. */ +export interface ProviderSummary { + nodes_total: number; + nodes_online: number; + nodes_pending: number; + nodes_leased: number; + leases_active: number; + leases_total: number; + /** + * Hours served, not money. There is no billing plane and no price on a + * node, so the backend deliberately reports the measured quantity a payout + * would be computed from rather than inventing a currency figure. See + * community/app/routes/provider_routes.py. + */ + hours_served_total: number; + hours_served_active: number; + customers: number; + recent_leases: ProviderLeaseRow[]; +} + +/** + * A workload on a provider's machine, as the provider is allowed to see it. + * + * Redacted server-side: no launch_params, no environment, no image. Owning + * the disk a customer's workload sits on does not entitle the owner to its + * contents (community/app/models.py's trust model), so this carries size and + * attribution only. + */ +export interface ProviderWorkloadRow { + id: string; + name: string | null; + type: string | null; + status: string | null; + created_at: string | null; + is_sidecar: boolean; + /** Core reports this as a list of {name, used_amount}, one entry per + * resource type -- not a keyed map. See Workload.get_resource_usage. */ + resource_usage: Array<{ name: string; used_amount: number }> | null; + lease_id: string | null; + customer_email: string | null; +} + +interface Envelope { + success: boolean; + code: number; + message: string; + data?: T; + error?: { type: string; details: Record }; +} + +/** + * Dev-only identity switch. + * + * With AUTH_DEV_BYPASS the API authenticates nobody and treats every caller as + * one seeded account, which makes the community plane untestable: registering + * a node and leasing it are two different people by design. This header picks + * which of those everyones you are. The API ignores it entirely once real + * OIDC is on, so leaving it set is harmless. + */ +export const DEV_USER_KEY = "xcloudify.devUser"; + +export function getDevUser(): string | null { + try { + return localStorage.getItem(DEV_USER_KEY); + } catch { + return null; + } +} + +export function setDevUser(email: string | null) { + try { + if (email) localStorage.setItem(DEV_USER_KEY, email); + else localStorage.removeItem(DEV_USER_KEY); + } catch { + /* private mode, or storage disabled -- the switcher just won't stick */ + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const devUser = getDevUser(); + const res = await fetch(`${COMMUNITY_API_BASE_URL}${path}`, { + ...options, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(devUser ? { "X-Dev-User": devUser } : {}), + ...(options.headers || {}), + }, + }); + + let body: Envelope; + try { + body = await res.json(); + } catch { + throw new Error(`${res.status} ${res.statusText}`); + } + + if (!res.ok || !body.success) { + throw new Error(body.message || `Request failed (${res.status})`); + } + return body.data as T; +} + +export interface RegisterNodeInput { + name: string; + /** ISO-3166 alpha-2. Decides which of the owner's regions the node joins. */ + country: string; + city?: string; +} + +export const communityApi = { + listMyNodes: () => request("/nodes"), + + listAvailableNodes: (country?: string) => + request(`/nodes/available${country ? `?country=${encodeURIComponent(country)}` : ""}`), + + /** Operator-only: every node across every owner. */ + listAllNodes: () => request("/admin/nodes"), + + getNode: (nodeId: string) => request(`/nodes/${nodeId}`), + + registerNode: (input: RegisterNodeInput) => + request("/nodes", { + method: "POST", + body: JSON.stringify({ + name: input.name, + country: input.country, + city: input.city || undefined, + }), + }), + + retireNode: (nodeId: string) => + request(`/nodes/${nodeId}`, { method: "DELETE" }), + + createPingSession: (nodeId: string) => + request(`/nodes/${nodeId}/ping-session`, { method: "POST" }), + + leaseNode: (nodeId: string) => + request(`/nodes/${nodeId}/lease`, { method: "POST" }), + + listLeases: () => request("/leases"), + + endLease: (leaseId: string) => + request(`/leases/${leaseId}`, { method: "DELETE" }), + + /** Provider-only: my hardware at a glance. */ + providerSummary: () => request("/provider/summary"), + + /** Provider-only: every lease ever taken on one of my machines. */ + nodeLeases: (nodeId: string) => + request(`/nodes/${nodeId}/leases`), + + /** Provider-only: what is running on my machine right now, redacted. */ + nodeWorkloads: (nodeId: string) => + request(`/nodes/${nodeId}/workloads`), + + /** Operator-only: counts at a glance. */ + adminOverview: () => request("/admin/overview"), + + /** Operator-only: every workload, attributed to its runner and its machine. */ + adminWorkloads: () => request("/admin/workloads"), + + /** Operator-only: every registered person and what they hold. */ + adminUsers: () => request("/admin/users"), + + /** Operator-only: every lease ever taken, active or ended. */ + adminLeases: () => request("/admin/leases"), + + /** The current user's own Cloudflare account, or null if none is on file. */ + getMyCloudflare: () => request("/me/cloudflare"), + + setMyCloudflare: (input: { apiToken: string; accountId: string; zoneId: string }) => + request("/me/cloudflare", { + method: "PUT", + body: JSON.stringify({ + api_token: input.apiToken, + account_id: input.accountId, + zone_id: input.zoneId, + }), + }), + + deleteMyCloudflare: () => request("/me/cloudflare", { method: "DELETE" }), +}; + +/** A customer's saved Cloudflare account, minus the token itself. */ +export interface CloudflareCredentialInfo { + account_id: string; + zone_id: string; + domain: string; + verified_at: string | null; +} + +export interface AdminUserRow { + id: string; + email: string; + is_platform_admin: boolean; + created_at: string | null; + nodes_owned: number; + active_leases: number; +} + +export interface AdminLeaseRow { + id: string; + node_id: string; + node_name: string | null; + owner_email: string | null; + customer_email: string | null; + started_at: string | null; + ended_at: string | null; + active: boolean; +} + +/** One row of /admin/workloads: a workload, whoever runs it, whoever's machine it landed on. */ +export interface AdminWorkloadRow { + workload_id: string; + name: string; + type: string; + status: string | null; + created_at: string | null; + host_id: string | null; + node: { + id: string; + name: string; + country: string; + city: string | null; + status: string; + owner_email: string | null; + } | null; + is_community_hardware: boolean; + lease_id: string | null; + customer_email: string | null; +} + +export interface AdminOverview { + providers: number; + nodes_total: number; + nodes_online: number; + nodes_enrolled_into_core: number; + leases_active: number; + customers: number; +} + +/** Resolve once ICE has finished gathering, so one offer carries every candidate. */ +function waitForIceGathering(pc: RTCPeerConnection): Promise { + if (pc.iceGatheringState === "complete") return Promise.resolve(); + return new Promise((resolve) => { + const handler = () => { + if (pc.iceGatheringState === "complete") { + pc.removeEventListener("icegatheringstatechange", handler); + resolve(); + } + }; + pc.addEventListener("icegatheringstatechange", handler); + }); +} + +/** + * Label the measurement by the route ICE actually chose. "host"/"srflx" is a + * direct path; "relay" means a TURN server is in the middle, which measures + * something materially different. Reporting an unqualified number for both + * would overstate what a relayed result says about the node. + */ +async function selectedPath(pc: RTCPeerConnection): Promise { + try { + const stats = await pc.getStats(); + let pair: RTCIceCandidatePairStats | undefined; + stats.forEach((report: any) => { + if (report.type === "transport" && report.selectedCandidatePairId) { + pair = stats.get(report.selectedCandidatePairId); + } + if (!pair && report.type === "candidate-pair" && report.state === "succeeded" && report.nominated) { + pair = report; + } + }); + if (!pair) return "webrtc"; + const remote: any = stats.get((pair as any).remoteCandidateId); + return remote?.candidateType === "relay" ? "webrtc-relayed" : "webrtc-direct"; + } catch { + return "webrtc"; + } +} + +/** + * Measure round-trip latency to a node's worker over a WebRTC data channel. + * + * The signaling server carries the offer and the answer and then drops out; + * the control plane was only ever consulted to authorise the session. So what + * this measures is browser<->worker, not a round trip through our servers. + * The channel is open before any sample is taken, so handshake and ICE cost + * are excluded and each sample is just the ping/pong. + * + * WebRTC rather than a WebSocket straight to the machine because community + * nodes sit behind their owner's NAT with no public address to dial. + */ +export async function measurePing( + session: PingSession, + samples = 5, + timeoutMs = 20000, +): Promise<{ rttsMs: number[]; medianMs: number | null; path: string }> { + const clientId = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : String(Math.random()).slice(2); + + const pc = new RTCPeerConnection({ iceServers: session.ice_servers }); + const channel = pc.createDataChannel("ping"); + let ws: WebSocket | undefined; + + const cleanup = () => { + try { ws?.close(); } catch { /* already closing */ } + try { pc.close(); } catch { /* already closed */ } + }; + + try { + return await new Promise<{ rttsMs: number[]; medianMs: number | null; path: string }>( + (resolve, reject) => { + const rtts: number[] = []; + let seq = 0; + let sentAt = 0; + + const timer = setTimeout( + () => reject(new Error(`Ping timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + + const finish = async () => { + clearTimeout(timer); + const sorted = [...rtts].sort((a, b) => a - b); + const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : null; + resolve({ rttsMs: rtts, medianMs: median, path: await selectedPath(pc) }); + }; + + const sendPing = () => { + sentAt = performance.now(); + channel.send(JSON.stringify({ type: "PING", id: seq })); + }; + + channel.onopen = () => sendPing(); + channel.onmessage = (event) => { + let msg: { type?: string; id?: number }; + try { + msg = JSON.parse(String(event.data)); + } catch { + return; + } + if (msg.type !== "PONG" || msg.id !== seq) return; + rtts.push(performance.now() - sentAt); + seq += 1; + if (seq >= samples) void finish(); + else sendPing(); + }; + + pc.onconnectionstatechange = () => { + if (pc.connectionState === "failed") { + clearTimeout(timer); + reject(new Error( + "Could not open a direct connection to this node. Its network may block peer-to-peer traffic.", + )); + } + }; + + try { + ws = new WebSocket(`${session.signaling_url}/browser/${clientId}`); + } catch (err) { + clearTimeout(timer); + reject(new Error(`Could not reach the signaling server: ${String(err)}`)); + return; + } + + ws.onerror = () => { + clearTimeout(timer); + reject(new Error("Could not reach the signaling server.")); + }; + + ws.onopen = async () => { + await pc.setLocalDescription(await pc.createOffer()); + await waitForIceGathering(pc); + ws!.send(JSON.stringify({ + type: "offer", + worker_id: session.worker_id, + token: session.token, + sdp: pc.localDescription!.sdp, + })); + }; + + ws.onmessage = async (event) => { + let msg: { type?: string; sdp?: string; message?: string }; + try { + msg = JSON.parse(String(event.data)); + } catch { + return; + } + if (msg.type === "answer" && msg.sdp) { + await pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }); + // Signaling has done its whole job; from here the measurement is + // peer-to-peer and this socket being open would only be noise. + ws!.close(); + return; + } + if (msg.type === "error") { + clearTimeout(timer); + reject(new Error(msg.message || "The node refused the ping session.")); + } + }; + }, + ); + } finally { + cleanup(); + } +}