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
This commit is contained in:
2026-09-01 23:05:54 +05:45
parent 980572b3fb
commit 51667bd965
52 changed files with 4496 additions and 1 deletions
+5
View File
@@ -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
+18
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xCloudify Community Admin</title>
<meta name="description" content="Operator console for the community plane: every node, every workload, every customer, across all owners." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+12
View File
@@ -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": "*" }
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+62
View File
@@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-2 bg-background p-12 text-center">
<p className="font-medium">Operator access required</p>
<p className="text-sm text-muted-foreground">
This portal manages community infrastructure across every owner. Your account
does not have operator access.
</p>
</div>
);
}
export function AdminShell({ children }: { children: ReactNode }) {
return (
<PlaneProvider plane="community">
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CommunityPortalChrome
groups={GROUPS}
badge="Operator"
portalName="Operator service"
titles={TITLES}
guard={(user) => (user.is_platform_admin ? null : <NotAnOperator />)}
>
{children}
</CommunityPortalChrome>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
</PlaneProvider>
);
}
+41
View File
@@ -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 (
<BrowserRouter>
<AdminShell>
<Routes>
<Route path="/" element={<AdminOverviewPage />} />
<Route path="/nodes" element={<AdminAllNodesPage />} />
<Route path="/workloads" element={<AdminWorkloadsPage />} />
<Route path="/leases" element={<AdminLeasesPage />} />
<Route path="/users" element={<AdminUsersPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</AdminShell>
</BrowserRouter>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "@/index.css";
createRoot(document.getElementById("root")!).render(<App />);
+5
View File
@@ -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;
+13
View File
@@ -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"]
}
+16
View File
@@ -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") } },
});
+5
View File
@@ -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
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xCloudify Provider</title>
<meta name="description" content="Lend hardware to the community cloud and track what it earns." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+12
View File
@@ -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": "*" }
}
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+32
View File
@@ -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 (
<BrowserRouter>
<ProviderShell>
<Routes>
<Route path="/" element={<ProviderOverviewPage />} />
<Route path="/nodes" element={<MyNodesPage />} />
<Route path="/nodes/:nodeId" element={<NodeDetailPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ProviderShell>
</BrowserRouter>
);
}
@@ -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 (
<PlaneProvider plane="community">
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CommunityPortalChrome
groups={GROUPS}
badge="Provider"
portalName="Provider service"
titles={TITLES}
>
{children}
</CommunityPortalChrome>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
</PlaneProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "@/index.css";
createRoot(document.getElementById("root")!).render(<App />);
@@ -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;
+13
View File
@@ -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"]
}
+16
View File
@@ -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") } },
});
+12
View File
@@ -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
+19
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xCloudify Community</title>
<meta name="description" content="Core, public cloud, and community cloud on one platform" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+12
View File
@@ -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": "*" }
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+97
View File
@@ -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 }) => (
<div className="p-4">{title} page coming soon...</div>
);
export default function App() {
return (
<BrowserRouter>
<CommunityShell>
<Routes>
<Route path="/" element={<CommunityDashboard />} />
<Route path="/explore" element={<ExplorePage />} />
<Route path="/marketplace" element={<MarketplacePage />} />
<Route path="/leases" element={<MyLeasesPage />} />
{/* Every one of these routes through /leases/<id>/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. */}
<Route path="/infrastructure" element={<RequireActiveLease><InfrastructurePage /></RequireActiveLease>} />
<Route path="/infrastructure/:resourceType" element={<RequireActiveLease><InfrastructurePage /></RequireActiveLease>} />
<Route path="/infrastructure/:resourceType/:resourceId" element={<RequireActiveLease><InfrastructurePage /></RequireActiveLease>} />
<Route path="/infrastructure/virtual-machines/:vmId/details" element={<RequireActiveLease><VirtualMachineDetailsWrapper /></RequireActiveLease>} />
<Route path="/infrastructure/networks/:networkId/details" element={<RequireActiveLease><NetworkDetailsPage /></RequireActiveLease>} />
<Route path="/storage" element={<RequireActiveLease><StoragePage /></RequireActiveLease>} />
<Route path="/storage/:volumeId/details" element={<RequireActiveLease><VolumeDetailsWrapper /></RequireActiveLease>} />
<Route path="/cloudflare" element={<RequireActiveLease><CloudflarePage /></RequireActiveLease>} />
<Route path="/ssh-keys" element={<SSHKeysPage />} />
<Route path="/ai-services" element={<AIServicesPage />} />
<Route path="/serverless" element={<ServerlessPage />} />
<Route path="/git-deploy" element={<GitDeployPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/referrals" element={<ReferralsPage />} />
{/* Nav entries styled disabled, same as cloud's own -- landing on a
stub beats landing on a 404. */}
<Route path="/my-templates" element={<Stub title="My Templates" />} />
<Route path="/changelog" element={<Stub title="Changelog" />} />
<Route path="/vote" element={<Stub title="Vote feature" />} />
<Route path="*" element={<NotFound />} />
</Routes>
</CommunityShell>
</BrowserRouter>
);
}
+107
View File
@@ -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/<id>/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 (
<div className="hidden items-center gap-2 md:flex">
<span className="text-sm text-muted-foreground">Working on</span>
<Select value={activeLease.id} onValueChange={selectLease}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{leases.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.node_name ?? `Lease ${l.id.slice(0, 8)}`}
{l.node_status && l.node_status !== "online" && (
<span className="ml-2 text-muted-foreground">({l.node_status})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function CommunityShell({ children }: { children: ReactNode }) {
return (
<PlaneProvider plane="community">
<QueryClientProvider client={queryClient}>
<LeaseProvider>
<TooltipProvider>
<CommunityPortalChrome
groups={GROUPS}
badge="Community"
portalName="Community service"
titles={TITLES}
headerExtra={<LeasePicker />}
>
{children}
</CommunityPortalChrome>
<Toaster />
</TooltipProvider>
</LeaseProvider>
</QueryClientProvider>
</PlaneProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "@/index.css";
createRoot(document.getElementById("root")!).render(<App />);
+5
View File
@@ -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;
+13
View File
@@ -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"]
}
+16
View File
@@ -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") } },
});
+6
View File
@@ -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 ."
},
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<Card className="max-w-md">
<CardContent className="flex flex-col items-center gap-3 p-8 text-center">
<div className="rounded-full bg-destructive/10 p-3">
<ServerCrash className="h-6 w-6 text-destructive" />
</div>
<h1 className="text-lg font-semibold">{portalName} unavailable</h1>
<p className="text-sm text-muted-foreground">
The community service could not be reached: {error}
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Try again
</Button>
</CardContent>
</Card>
</div>
);
}
/**
* 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Switch identity</DialogTitle>
<DialogDescription>
Development mode has no sign-in -- this names which community account you act as.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="dev-user">Act as</Label>
<Input
id="dev-user"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && apply(value.trim() || null)}
placeholder="someone@example.com"
/>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => apply(null)}>
Reset
</Button>
<Button onClick={() => apply(value.trim() || null)}>Switch</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function UserMenu({ user }: { user: CommunityAuthUser }) {
const [switching, setSwitching] = useState(false);
const name = user.name || user.email;
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex h-auto items-center gap-3 px-3 py-2">
<Avatar className="h-8 w-8">
{user.avatar_url && <AvatarImage src={user.avatar_url} alt={name} />}
<AvatarFallback className="bg-primary text-sm text-primary-foreground">
{initialsOf(name)}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium sm:inline">{name}</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 w-64 border border-border bg-background shadow-lg">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{name}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
{user.is_platform_admin && (
<Badge variant="secondary" className="mt-1 w-fit gap-1">
<ShieldCheck className="h-3 w-3" />
Operator
</Badge>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{user.auth_mode === "dev" ? (
<DropdownMenuItem onSelect={() => setSwitching(true)}>
<UserCog className="mr-2 h-4 w-4" />
Switch identity
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild>
<a href={oauthSignOutUrl()}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</a>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{user.auth_mode === "dev" && (
<IdentityDialog open={switching} onOpenChange={setSwitching} current={user.email} />
)}
</>
);
}
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 (
<div className="flex min-h-screen items-center justify-center bg-background text-muted-foreground">
Checking session…
</div>
);
}
if (error) {
return <Unreachable error={error} portalName={portalName} />;
}
if (!user) {
// /auth/me said 401: no session. Only reachable outside dev mode.
return <SignInScreen title={portalName} />;
}
const denied = guard?.(user);
if (denied) return <>{denied}</>;
const title = titles.find(
([prefix]) => pathname === prefix || (prefix !== "/" && pathname.startsWith(prefix)),
)?.[1] ?? portalName;
return (
<SidebarProvider>
<Sonner />
<div className="flex min-h-screen w-full bg-background">
<AppSidebar groups={groups} badge={badge} />
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4">
<div className="flex items-center">
<SidebarTrigger className="mr-4" />
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
<div className="flex items-center gap-3">
{headerExtra}
<UserMenu user={user} />
</div>
</header>
<main className="flex-1 overflow-auto p-6 pb-10">{children}</main>
</div>
</div>
</SidebarProvider>
);
}
@@ -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;
}
@@ -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/<id>/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 (
<div className="flex items-center justify-center gap-2 p-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading your leases…
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center gap-2 p-12">
<p className="text-destructive font-medium">Could not load your leases</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
);
}
if (leases.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 p-12 text-center">
<div className="rounded-full bg-muted p-3">
<Cloud className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">No leased nodes yet</h2>
<p className="max-w-sm text-sm text-muted-foreground">
Compute, storage and network are managed per leased node. Lease one
from the marketplace to get started.
</p>
<Button asChild size="sm">
<Link to="/marketplace">Browse the marketplace</Link>
</Button>
</div>
);
}
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}</>;
}
@@ -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<CloudflareCredentialInfo | null>(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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<div className="rounded-md bg-orange-100 p-1.5 text-orange-600 dark:bg-orange-900 dark:text-orange-400">
<Cloud className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base">Cloudflare</CardTitle>
<CardDescription>
Expose a leased container's port publicly, without the node needing one.
</CardDescription>
</div>
</div>
{cred ? (
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
{cred.domain}
</Badge>
<Button variant="outline" size="sm" onClick={remove}>Disconnect</Button>
</div>
) : (
<Button size="sm" onClick={() => setDialogOpen(true)}>Connect Cloudflare</Button>
)}
</CardHeader>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect your Cloudflare account</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cf-token">API token</Label>
<Input
id="cf-token" type="password" value={form.apiToken}
onChange={(e) => setForm((f) => ({ ...f, apiToken: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-account">Account ID</Label>
<Input
id="cf-account" value={form.accountId} className="font-mono"
onChange={(e) => setForm((f) => ({ ...f, accountId: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-zone">Zone ID</Label>
<Input
id="cf-zone" value={form.zoneId} className="font-mono"
onChange={(e) => setForm((f) => ({ ...f, zoneId: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
The domain itself is read back from Cloudflare, not typed here.
</p>
</div>
</div>
<DialogFooter>
<Button
onClick={save}
disabled={saving || !form.apiToken.trim() || !form.accountId.trim() || !form.zoneId.trim()}
>
{saving ? "Verifying…" : "Verify and save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
/**
* 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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Public hostnames</CardTitle>
<CardDescription>
{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."}
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Hostname</TableHead>
<TableHead className="text-right">Container port</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(loading || error || exposures.length === 0) && (
<TableRow>
<TableCell colSpan={2} className="py-8 text-center text-sm text-muted-foreground">
{loading ? "Loading…"
: error ? error
: "No port on this lease is published yet."}
</TableCell>
</TableRow>
)}
{exposures.map((e) => (
<TableRow key={e.id}>
<TableCell className="font-medium">
<a href={e.url} target="_blank" rel="noreferrer" className="text-primary hover:underline">
{e.hostname}
</a>
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{e.internal_port ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export function CloudflarePage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Public access</h1>
<p className="text-sm text-muted-foreground">
Leased machines have no public address of their own. Bring your own Cloudflare
account and their ports get hostnames on your domain.
</p>
</div>
<CloudflareSettingsCard />
<ExposureList />
</div>
);
}
@@ -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<string, CommunityNode>;
}
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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<div className={tone === "primary" ? "rounded-md bg-primary/10 p-1.5" : "rounded-md bg-muted p-1.5"}>
<Icon className={`h-4 w-4 ${tone === "primary" ? "text-primary" : "text-muted-foreground"}`} />
</div>
</CardHeader>
<CardContent>
{loading ? (
<>
<Skeleton className="mb-2 h-8 w-16" />
<Skeleton className="h-3 w-28" />
</>
) : (
<>
<div className="text-2xl font-bold tabular-nums text-foreground">{value}</div>
{sub && <p className="mt-0.5 text-xs text-muted-foreground">{sub}</p>}
</>
)}
</CardContent>
</Card>
);
}
function QuickAction({
icon: Icon, title, description, to, accent,
}: {
icon: LucideIcon;
title: string;
description: string;
to: string;
accent: string;
}) {
const navigate = useNavigate();
return (
<Card
className="cursor-pointer p-4 transition-shadow hover:shadow-md"
onClick={() => navigate(to)}
>
<div className="flex items-center space-x-3">
<div className={`rounded-lg p-2 ${accent}`}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium">{title}</h3>
<p className="truncate text-xs text-muted-foreground">{description}</p>
</div>
</div>
</Card>
);
}
function EmptyLine({ icon: Icon, title, hint }: { icon: LucideIcon; title: string; hint: string }) {
return (
<div className="flex flex-col items-center gap-1.5 py-8 text-center">
<Icon className="h-7 w-7 text-muted-foreground" />
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
);
}
export function CommunityDashboard() {
const { user } = useCommunityAuth();
const [data, setData] = useState<Snapshot>(EMPTY);
const [failures, setFailures] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const errors: Record<string, string> = {};
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 (
<div className="space-y-6">
<div className="flex flex-col items-start justify-between gap-4 lg:flex-row lg:items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Welcome back!</h1>
<p className="mt-1 text-muted-foreground">
Lease hardware that somebody else has lent to the community cloud.
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh} disabled={refreshing}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{failureCount > 0 && (
<ErrorBanner
title={failureCount === 2 ? "Couldn't reach the community service" : "Some of this page could not be loaded"}
details={failures}
onRetry={refresh}
isRetrying={refreshing}
/>
)}
<div className="grid gap-4 md:grid-cols-3">
<QuickAction
icon={ShoppingCart}
title="Browse marketplace"
description="Find hardware to lease"
to="/marketplace"
accent="bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400"
/>
<QuickAction
icon={KeyRound}
title="My leases"
description="Machines reserved to you"
to="/leases"
accent="bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400"
/>
<QuickAction
icon={Gauge}
title="Test latency"
description="Measure a node from here"
to="/marketplace"
accent="bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-400"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<StatCard
icon={KeyRound}
label="Active leases"
value={stats.activeLeases.length}
sub={stats.activeLeases.length === 0 ? "Nothing reserved to you" : "machines reserved to you alone"}
loading={loading}
tone="primary"
/>
<StatCard
icon={ShoppingCart}
label="Available to lease"
value={data.available.length}
sub={
data.available.length === 0
? "Nothing online right now"
: `across ${stats.countries} ${stats.countries === 1 ? "country" : "countries"}`
}
loading={loading}
/>
</div>
{firstRun ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<div className="rounded-full bg-primary/10 p-3">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<p className="text-lg font-medium">Nothing reserved yet</p>
<p className="max-w-md text-sm text-muted-foreground">
Browse what is available and lease a machine to run virtual machines,
containers, storage and networks on it.
</p>
</div>
<Button asChild>
<Link to="/marketplace">
<ShoppingCart className="mr-2 h-4 w-4" />
Browse the marketplace
</Link>
</Button>
</CardContent>
</Card>
) : (
<Card className="flex flex-col">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div>
<CardTitle className="text-base">Your leases</CardTitle>
<CardDescription>Machines reserved to you alone</CardDescription>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link to="/leases">
Manage
<ArrowRight className="ml-1.5 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="flex-1 space-y-2">
{loading && data.leases.length === 0 ? (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)
) : stats.activeLeases.length === 0 ? (
<EmptyLine
icon={KeyRound}
title="No active leases"
hint="Reserve a machine from the marketplace to run workloads on it."
/>
) : (
stats.activeLeases.slice(0, 5).map((lease) => {
const node = data.leasedNodes[lease.node_id];
return (
<Link
key={lease.id}
to="/leases"
className="flex items-center justify-between gap-3 rounded-md border bg-card px-3 py-2.5 transition-colors hover:bg-muted/50"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{node?.name ?? <span className="font-mono text-xs">{lease.node_id}</span>}
</p>
<p className="flex items-center gap-1 truncate text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" />
{nodeLocation(node)}
{node ? ` · ${nodeSpecSummary(node)}` : ""}
</p>
</div>
<div className="shrink-0 text-right">
<Badge>reserved</Badge>
{relativeTime(lease.started_at) && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
since {relativeTime(lease.started_at)}
</p>
)}
</div>
</Link>
);
})
)}
{stats.activeLeases.length > 5 && (
<p className="pt-1 text-xs text-muted-foreground">+{stats.activeLeases.length - 5} more</p>
)}
</CardContent>
</Card>
)}
{/* Lending is a different job, done in a different app -- a real
cross-origin link, not a route. See CommunityPortalChrome.tsx. */}
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8 text-center sm:flex-row sm:justify-between sm:text-left">
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-100 p-2 text-blue-600 dark:bg-blue-900 dark:text-blue-400">
<Server className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-medium">Have spare hardware?</p>
<p className="text-sm text-muted-foreground">
Lend a machine to the community cloud in the provider portal.
</p>
</div>
</div>
<Button asChild variant="outline" className="shrink-0">
<a href={PROVIDER_PORTAL_URL} target="_blank" rel="noreferrer">
Open provider portal
<ArrowRight className="ml-1.5 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -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 (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon className="h-4 w-4 shrink-0" />
<span>{label}</span>
</div>
);
}
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<CommunityNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pings, setPings] = useState<Record<string, PingResult | "pending">>({});
/** 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<string | null>(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/<id>/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 (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Marketplace</h1>
<p className="text-sm text-muted-foreground">
{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.`}
</p>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="text-sm">
<p className="font-medium">Community hardware is operator-owned</p>
<p className="text-muted-foreground">
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.
</p>
</div>
</div>
{error && (
<ErrorBanner
title="Couldn't load the marketplace"
message={error}
onRetry={load}
isRetrying={loading}
/>
)}
{loading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
)}
{!loading && !error && nodes.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<ShoppingCart className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">No nodes available right now</p>
<p className="text-sm text-muted-foreground">
Every online node is currently leased. Check back shortly.
</p>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{nodes.map((node) => {
const ping = pings[node.id];
return (
<Card key={node.id} className="flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{node.name}</CardTitle>
<Badge variant="outline" className="capitalize">{node.trust_tier}</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col space-y-3">
<SpecRow icon={Cpu} label={`${node.specs.cpu_cores ?? "?"} CPU cores`} />
<SpecRow
icon={MemoryStick}
label={`${Math.round((node.specs.memory_mb ?? 0) / 1024)} GB memory`}
/>
<SpecRow icon={HardDrive} label={`${node.specs.disk_gb ?? "?"} GB disk`} />
<SpecRow
icon={MapPin}
label={node.city ? `${node.city}, ${node.country}` : node.country}
/>
{node.specs.gpu_count ? (
<SpecRow
icon={Zap}
label={`${node.specs.gpu_count}× ${node.specs.gpu_model ?? "GPU"}`}
/>
) : null}
<div className="flex items-center gap-2 border-t pt-3 text-sm">
<Gauge className="h-4 w-4 text-muted-foreground" />
{ping === "pending" && <span className="text-muted-foreground">Measuring…</span>}
{ping && ping !== "pending" && ping.medianMs !== null && (
<span>
{ping.medianMs.toFixed(0)} ms
<span className="text-muted-foreground"> via {ping.path}</span>
</span>
)}
{ping && ping !== "pending" && ping.error && (
<span className="text-destructive text-xs">{ping.error}</span>
)}
{!ping && node.pingable === false && (
<span className="text-muted-foreground">
Worker offline — cannot be measured
</span>
)}
{!ping && node.pingable !== false && (
<span className="text-muted-foreground">Latency not measured</span>
)}
</div>
<div className="flex gap-2 pt-1 mt-auto">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => runPing(node)}
disabled={ping === "pending" || node.pingable === false}
>
{node.pingable === false ? (
<><WifiOff className="mr-2 h-4 w-4" />Unreachable</>
) : (
"Ping"
)}
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => lease(node)}
disabled={leasing !== null}
>
{leasing === node.id ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Leasing…</>
) : (
"Lease"
)}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
@@ -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 (
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div>
<CardTitle className="text-base">{title}</CardTitle>
<p className="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
{node ? (
<>
<MapPin className="h-3.5 w-3.5 shrink-0" />
{nodeLocation(node)}
<span aria-hidden>·</span>
{nodeSpecSummary(node)}
</>
) : (
<span className="font-mono text-xs">{lease.node_id}</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={
lease.active
? "gap-1.5 border-emerald-500/30 bg-emerald-500/10 font-medium text-emerald-700 dark:text-emerald-400"
: "gap-1.5 font-medium text-muted-foreground"
}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
{lease.active
? relativeTime(lease.started_at)
? `reserved to you · ${relativeTime(lease.started_at)}`
: "reserved to you"
: "ended"}
</Badge>
{lease.active && (
<Button variant="outline" size="sm" onClick={end} disabled={ending}>
{ending ? "Ending…" : "End lease"}
</Button>
)}
</div>
</CardHeader>
{lease.active && (
<CardContent className="space-y-3">
{offline && (
<p className="text-sm text-destructive">
This machine is {lease.node_status}. Compute on it is unavailable
until it comes back online.
</p>
)}
{/* `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) && (
<p className="text-sm text-muted-foreground">
This machine is still joining the compute plane. It usually takes
a minute after the installer runs.
</p>
)}
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => openIn("/infrastructure")}>
<Server className="mr-2 h-4 w-4" />
Compute
</Button>
<Button variant="outline" size="sm" onClick={() => openIn("/infrastructure/networks")}>
<Network className="mr-2 h-4 w-4" />
Networks
</Button>
<Button variant="outline" size="sm" onClick={() => openIn("/storage")}>
<HardDrive className="mr-2 h-4 w-4" />
Storage
</Button>
<Button variant="outline" size="sm" onClick={() => openIn("/cloudflare")}>
<Cpu className="mr-2 h-4 w-4" />
Public access
</Button>
</div>
</CardContent>
)}
</Card>
);
}
export function MyLeasesPage() {
const { user } = useCommunityAuth();
const { refresh: refreshLeaseScope } = useLease();
const [leases, setLeases] = useState<Lease[]>([]);
const [nodes, setNodes] = useState<Record<string, CommunityNode>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Leases</h1>
<p className="text-sm text-muted-foreground">
Machines reserved to you. A leased machine is yours alone until you
end the lease — nobody else can take it.
</p>
</div>
{error && (
<ErrorBanner title="Couldn't load your leases" message={error} onRetry={load} />
)}
{loading && (
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-40 w-full" />
))}
</div>
)}
{!loading && !error && leases.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<KeyRound className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">No leases</p>
<p className="text-sm text-muted-foreground">
Reserve a machine from the marketplace to get started.
</p>
<Button asChild variant="outline" size="sm" className="mt-2">
<Link to="/marketplace">Browse the marketplace</Link>
</Button>
</CardContent>
</Card>
)}
<div className="space-y-4">
{active.map((lease) => (
<LeaseCard key={lease.id} lease={lease} node={nodes[lease.node_id]} onEnded={onEnded} />
))}
</div>
{ended.length > 0 && (
<div className="space-y-2 pt-2">
<h2 className="flex items-center gap-2 border-b pb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<Server className="h-4 w-4" />
Past leases
</h2>
{ended.map((lease) => (
<LeaseCard key={lease.id} lease={lease} node={nodes[lease.node_id]} onEnded={onEnded} />
))}
</div>
)}
</div>
);
}
@@ -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 <p className="text-xs text-muted-foreground">Live usage unavailable right now.</p>;
}
return (
<div className="space-y-2 border-t pt-3">
<div className="flex items-center gap-2 text-sm">
<Activity className="h-4 w-4 text-muted-foreground" />
<span>
{detail.workload_count === 0
? "No workloads running"
: `${detail.workload_count} workload${detail.workload_count === 1 ? "" : "s"} running`}
</span>
</div>
<NodeUsageBar label="CPU" usage={detail.resources.cpu} unit="cores" />
<NodeUsageBar label="Memory" usage={detail.resources.ram} unit="MB" />
<NodeUsageBar label="Disk" usage={detail.resources.disk} unit="GB" />
</div>
);
}
/** 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 (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Run this on the machine you want to contribute. It installs the agent and
finishes enrolling the node.
</p>
<div className="relative">
{/* 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". */}
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-3 pr-12 text-xs leading-relaxed">
<code>{command}</code>
</pre>
<Button size="icon" variant="ghost" className="absolute right-1 top-1" onClick={copy}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
The key in this command works once. To add a second machine, register another
node and run its own command there.
</p>
</div>
);
}
/**
* 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<KeyRound className="mr-2 h-4 w-4" />
View install command
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install the agent</DialogTitle>
<DialogDescription>
{node.name} is registered. One step left.
</DialogDescription>
</DialogHeader>
<InstallInstructions node={node} />
<DialogFooter>
<Button onClick={() => setOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const EMPTY_FORM = { name: "", country: "", city: "" };
export function MyNodesPage() {
const { toast } = useToast();
const [nodes, setNodes] = useState<CommunityNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const [justRegistered, setJustRegistered] = useState<CommunityNode | null>(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 (
<div className="space-y-6">
<PageHeader
title="My nodes"
description={
active.length === 0
? "Machines you contribute to the community cloud."
: `${online} of ${active.length} online.`
}
onRefresh={() => load(true)}
refreshing={refreshing}
actions={
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setJustRegistered(null);
}}
>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Add a node
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{justRegistered ? "Install the agent" : "Register a node"}
</DialogTitle>
<DialogDescription>
{justRegistered
? `${justRegistered.name} is registered. One step left.`
: "Describe the machine. You will get a command to run on it."}
</DialogDescription>
</DialogHeader>
{justRegistered ? (
<InstallInstructions node={justRegistered} />
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="node-name">Node name</Label>
<Input
id="node-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="homelab-01"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="node-country">Country</Label>
<Input
id="node-country"
value={form.country}
maxLength={2}
onChange={(e) => setForm((f) => ({ ...f, country: e.target.value.toUpperCase() }))}
placeholder="NP"
/>
<p className="text-xs text-muted-foreground">
Two-letter code. Machines you add in the same country share
one placement region.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="node-city">City (optional)</Label>
<Input
id="node-city"
value={form.city}
onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))}
placeholder="Kathmandu"
/>
</div>
</div>
</div>
)}
<DialogFooter>
{justRegistered ? (
<Button onClick={() => setDialogOpen(false)}>Done</Button>
) : (
<Button onClick={register} disabled={!canSubmit}>
{submitting ? "Registering…" : "Register"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
}
/>
{error && (
<ErrorBanner
title="Couldn't load your nodes"
message={error}
onRetry={load}
/>
)}
{loading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-56 w-full" />
))}
</div>
)}
{!loading && !error && active.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12">
<Server className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">No nodes yet</p>
<p className="text-sm text-muted-foreground">
Add a machine to start contributing spare capacity.
</p>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{active.map((node) => (
<Card key={node.id} className="transition-shadow hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{node.name}</CardTitle>
<NodeStatusBadge status={node.status} />
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" />
<span>{nodeLocation(node)}</span>
</div>
<p className="text-sm text-muted-foreground">{nodeSpecSummary(node)}</p>
{node.status !== "pending" && (
<div className="flex items-center gap-2 text-sm">
{node.pingable ? (
<>
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Reachable for latency tests
</span>
</>
) : (
<>
<WifiOff className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Not reachable for latency tests
</span>
</>
)}
</div>
)}
{relativeTime(node.last_heartbeat_at) && (
<p className="text-xs text-muted-foreground">
Last heartbeat {relativeTime(node.last_heartbeat_at)}
</p>
)}
{/* 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" && (
<p className="rounded-md bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400">
Registered, waiting on its first check-in. Run the install command
on the machine to finish enrollment.
</p>
)}
{/* 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 && (
<p className="rounded-md bg-muted p-2 text-xs text-muted-foreground">
Enrolled and reachable. Still joining the compute plane —
this usually clears within a minute of the agent starting.
</p>
)}
<UtilizationPanel node={node} />
{node.status === "pending" && node.install_command && (
<PendingNodeInstall node={node} />
)}
<div className="flex gap-2">
{/* 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. */}
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link to={`/nodes/${node.id}`}>
Details
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => retire(node)}
>
<Trash2 className="mr-2 h-4 w-4" />
Retire
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
@@ -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<Data>(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<string, AdminWorkloadRow[]> = {};
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 (
<div className="space-y-6">
<PageHeader
title="All nodes"
description="Every machine lent to the platform, and what it is doing right now."
onRefresh={reload}
refreshing={refreshing}
actions={
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by name, owner, country…"
className="w-64 pl-8"
/>
</div>
}
/>
{error && <LoadFailed what="the node inventory" error={error} onRetry={reload} retrying={refreshing} />}
{loading && nodes.length === 0 && <TableSkeleton rows={6} />}
{!loading && nodes.length === 0 && !error && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12">
<Cpu className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">No nodes registered yet</p>
<p className="text-sm text-muted-foreground">
A node appears here the moment a provider registers it, before it enrolls.
</p>
</CardContent>
</Card>
)}
{nodes.length > 0 && filtered.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
No node matches “{query}”.
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((node) => {
const detail = node.status_detail;
const hostWorkloads = node.core_workload_host_id
? workloadsByHost[node.core_workload_host_id] ?? []
: [];
return (
<Card key={node.id} className="transition-shadow hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="truncate text-base">{node.name}</CardTitle>
<NodeStatusBadge status={node.status} />
</CardHeader>
<CardContent className="space-y-3">
<p className="truncate text-sm font-medium">{node.owner_email}</p>
<p className="text-sm text-muted-foreground">
{nodeLocation(node)} · {nodeSpecSummary(node)}
</p>
{relativeTime(node.last_heartbeat_at) && (
<p className="text-xs text-muted-foreground">
Last heartbeat {relativeTime(node.last_heartbeat_at)}
</p>
)}
<p className="text-xs text-muted-foreground">
compute plane:{" "}
{node.core_workload_host_id ? (
<span className="font-mono">{node.core_workload_host_id.slice(0, 8)}</span>
) : (
<Badge variant="outline">not enrolled</Badge>
)}
</p>
{node.status === "online" && detail && (
<div className="space-y-2 border-t pt-3">
<div className="flex items-center gap-2 text-sm">
<Activity className="h-4 w-4 text-muted-foreground" />
<span>
{detail.workload_count} workload{detail.workload_count === 1 ? "" : "s"}
</span>
</div>
<NodeUsageBar label="CPU" usage={detail.resources.cpu} unit="cores" />
<NodeUsageBar label="Memory" usage={detail.resources.ram} unit="MB" />
<NodeUsageBar label="Disk" usage={detail.resources.disk} unit="GB" />
</div>
)}
{hostWorkloads.length > 0 && (
<div className="space-y-1 border-t pt-3">
{hostWorkloads.map((w) => (
<div key={w.workload_id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">{w.name}</span>
<span className="shrink-0 text-muted-foreground">{w.customer_email ?? "—"}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
@@ -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<AdminLeaseRow[]>(() => 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 (
<div className="space-y-6">
<PageHeader
title="Leases"
description={`${active} active · ${leases.length} ever taken`}
onRefresh={reload}
refreshing={refreshing}
actions={
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by node, customer, provider…"
className="w-64 pl-8"
/>
</div>
}
/>
{error && <LoadFailed what="the lease list" error={error} onRetry={reload} retrying={refreshing} />}
<Tabs value={tab} onValueChange={(v) => setTab(v as "active" | "all")}>
<TabsList>
<TabsTrigger value="active">Active ({active})</TabsTrigger>
<TabsTrigger value="all">All ({leases.length})</TabsTrigger>
</TabsList>
</Tabs>
{loading && leases.length === 0 ? (
<TableSkeleton rows={6} />
) : (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Node</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-right">Duration</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-sm text-muted-foreground">
{leases.length === 0
? "Nobody has leased a machine yet."
: tab === "active" && !query
? "No lease is active right now."
: `No lease matches “${query}”.`}
</TableCell>
</TableRow>
)}
{filtered.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.node_name ?? l.node_id}</TableCell>
<TableCell className="text-muted-foreground">{l.customer_email ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">{l.owner_email ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">{when(l.started_at)}</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatHours(elapsedHours(l))}
</TableCell>
<TableCell className="text-right">
<Badge variant={l.active ? "default" : "outline"}>
{l.active ? "active" : "ended"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -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<Data>(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 (
<div className="space-y-6">
<PageHeader
title="Platform overview"
description="Every provider, every customer, every machine — across all owners."
onRefresh={reload}
refreshing={refreshing}
/>
{error && <LoadFailed what="the platform overview" error={error} onRetry={reload} retrying={refreshing} />}
{loading && !overview ? (
<TableSkeleton rows={4} />
) : overview ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Server}
label="Nodes"
value={overview.nodes_total}
sub={`${overview.nodes_online} online · ${overview.nodes_enrolled_into_core} on the compute plane`}
/>
<StatCard icon={Users} label="Providers" value={overview.providers} />
<StatCard icon={KeyRound} label="Active leases" value={overview.leases_active} />
<StatCard icon={Boxes} label="Customers" value={overview.customers} />
</div>
) : 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 && (
<Card>
<CardContent className="py-4 text-sm text-muted-foreground">
{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.{" "}
<Link to="/nodes" className="text-primary hover:underline">See which</Link>.
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-base">Recent leases</CardTitle>
<Link to="/leases" className="text-sm text-primary hover:underline">
All leases
</Link>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Node</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recent.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-muted-foreground">
Nobody has leased a machine yet.
</TableCell>
</TableRow>
)}
{recent.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.node_name ?? l.node_id}</TableCell>
<TableCell className="text-muted-foreground">{l.customer_email ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">{l.owner_email ?? "—"}</TableCell>
<TableCell className="text-right">
<Badge variant={l.active ? "default" : "outline"}>
{l.active ? "active" : "ended"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -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<AdminUserRow[]>(() => 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 (
<div className="space-y-6">
<PageHeader
title="People"
description={`${users.length} accounts · ${providers} lending hardware · ${customers} renting it`}
onRefresh={reload}
refreshing={refreshing}
actions={
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by email…"
className="w-64 pl-8"
/>
</div>
}
/>
{error && <LoadFailed what="the user list" error={error} onRetry={reload} retrying={refreshing} />}
{loading && users.length === 0 ? (
<TableSkeleton rows={6} />
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="text-right">Nodes lent</TableHead>
<TableHead className="text-right">Active leases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-sm text-muted-foreground">
{users.length === 0 ? "Nobody has used the platform yet." : `No account matches “${query}”.`}
</TableCell>
</TableRow>
)}
{filtered.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.email}</TableCell>
<TableCell>
{u.is_platform_admin
? <Badge>operator</Badge>
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="text-muted-foreground">{joined(u.created_at)}</TableCell>
<TableCell className="text-right tabular-nums">{u.nodes_owned}</TableCell>
<TableCell className="text-right tabular-nums">{u.active_leases}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
@@ -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<AdminWorkloadRow[]>(() => 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 (
<div className="space-y-6">
<PageHeader
title="All workloads"
description={`${workloads.length} total · ${onCommunity} on community hardware`}
onRefresh={reload}
refreshing={refreshing}
actions={
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by name, customer, node…"
className="w-64 pl-8"
/>
</div>
}
/>
{error && <LoadFailed what="the workload list" error={error} onRetry={reload} retrying={refreshing} />}
{loading && workloads.length === 0 ? (
<TableSkeleton rows={8} />
) : (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Running on</TableHead>
<TableHead>Provider</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-sm text-muted-foreground">
{workloads.length === 0
? "Nothing is running yet."
: `No workload matches “${query}”.`}
</TableCell>
</TableRow>
)}
{filtered.map((w) => (
<TableRow key={w.workload_id}>
<TableCell className="font-medium">{w.name}</TableCell>
<TableCell className="text-muted-foreground">{w.type}</TableCell>
<TableCell><WorkloadStatusBadge status={w.status} /></TableCell>
<TableCell className="text-muted-foreground">{w.customer_email ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">
{w.node ? w.node.name : (
<span className="font-mono text-xs">{w.host_id ?? "unplaced"}</span>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{w.node?.owner_email ?? (
w.is_community_hardware ? "—" : <Badge variant="outline">first-party</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -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<T>(fetcher: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-2">
{actions}
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
)}
</div>
</div>
);
}
export function StatCard({
icon: Icon, label, value, sub,
}: {
icon: LucideIcon;
label: string;
value: number | string;
sub?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="rounded-md bg-primary/10 p-2">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<p className="text-2xl font-bold leading-none tabular-nums">{value}</p>
<p className="text-sm text-muted-foreground">{label}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
</CardContent>
</Card>
);
}
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 <Badge variant={variant}>{status ?? "unknown"}</Badge>;
}
export function LoadFailed({
what, error, onRetry, retrying,
}: {
what: string;
error: string;
onRetry: () => void;
retrying: boolean;
}) {
return (
<ErrorBanner
title={`Couldn't load ${what}`}
message={error}
onRetry={onRetry}
isRetrying={retrying}
/>
);
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
);
}
export function EmptyRow({ colSpan, children }: { colSpan: number; children: ReactNode }) {
return (
<tr>
<td colSpan={colSpan} className="py-8 text-center text-sm text-muted-foreground">
{children}
</td>
</tr>
);
}
/** "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`;
}
@@ -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 (
<Badge variant="outline" className={`gap-1.5 font-medium capitalize ${tone}`}>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
{status}
</Badge>
);
}
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 (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="tabular-nums">
{used} / {cap} {scaledUnit} · {pct}%
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
);
}
@@ -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<CommunityNode | null>(null);
const [leases, setLeases] = useState<ProviderLeaseRow[]>([]);
const [workloads, setWorkloads] = useState<ProviderWorkloadRow[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (error && !node) {
return <ErrorBanner title="Couldn't load this machine" message={error} onRetry={() => load()} isRetrying={refreshing} />;
}
if (!node) return null;
const detail = node.status_detail;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<Button variant="ghost" size="sm" className="-ml-2" asChild>
<Link to="/nodes">
<ArrowLeft className="mr-2 h-4 w-4" />
My nodes
</Link>
</Button>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">{node.name}</h1>
<NodeStatusBadge status={node.status} />
</div>
<p className="text-sm text-muted-foreground">
{nodeLocation(node)} · {nodeSpecSummary(node)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => load(true)} disabled={refreshing}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRetireOpen(true)}
disabled={Boolean(activeLease)}
title={activeLease ? "End the active lease before withdrawing this machine" : undefined}
>
<Trash2 className="mr-2 h-4 w-4" />
Withdraw
</Button>
</div>
</div>
{error && <ErrorBanner title="Some details could not be refreshed" message={error} onRetry={() => 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 && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600" />
This machine has not been installed yet
</CardTitle>
<CardDescription>
Run this on the machine itself. It joins the compute plane on its own and appears
on the marketplace once it reports in.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
{node.install_command}
</pre>
<Button size="sm" variant="outline" onClick={copyInstall}>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-3">
<StatCard icon={KeyRound} label="Rentals to date" value={leases.length} />
<StatCard icon={Clock} label="Hours served" value={formatHours(hoursServed)} />
<StatCard
icon={Activity}
label="Last heartbeat"
value={relativeTime(node.last_heartbeat_at) ?? "never"}
sub={`enrolled ${relativeTime(node.enrolled_at) ?? "not yet"}`}
/>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Health &amp; rental</CardTitle>
</CardHeader>
<CardContent className="max-w-2xl space-y-3 text-sm">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">Right now</span>
<span className="text-right">
{activeLease ? (
<>Rented by <span className="font-medium">{activeLease.customer_email ?? "someone"}</span></>
) : node.status === "online" ? (
"Idle · listed on the marketplace"
) : (
"Not rentable"
)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Compute plane</span>
{node.core_workload_host_id ? (
<Badge
variant="outline"
className="gap-1.5 border-emerald-500/30 bg-emerald-500/10 font-medium text-emerald-700 dark:text-emerald-400"
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
Joined
</Badge>
) : (
<Badge variant="outline">Not joined</Badge>
)}
</div>
{node.status === "online" && detail ? (
<div className="space-y-2 border-t pt-3">
<NodeUsageBar label="CPU" usage={detail.resources.cpu} unit="cores" />
<NodeUsageBar label="Memory" usage={detail.resources.ram} unit="MB" />
<NodeUsageBar label="Disk" usage={detail.resources.disk} unit="GB" />
</div>
) : (
<p className="border-t pt-3 text-muted-foreground">
{node.status === "online"
? "Live usage is not being reported right now."
: "Live usage appears once this machine is online."}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Running on this machine</CardTitle>
<CardDescription>
Names and sizes only. What a customer's workload actually contains is theirs.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Customer</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workloads.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-muted-foreground">
{node.core_workload_host_id
? "Nothing is running on this machine."
: "This machine has not joined the compute plane yet."}
</TableCell>
</TableRow>
)}
{workloads.map((w) => (
<TableRow key={w.id}>
<TableCell className="font-medium">
{w.name}
{w.is_sidecar && (
<Badge variant="outline" className="ml-2">platform</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">{w.type ?? "—"}</TableCell>
<TableCell><WorkloadStatusBadge status={w.status} /></TableCell>
<TableCell className="text-muted-foreground">{w.customer_email ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Rental history</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-right">Duration</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leases.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-muted-foreground">
Nobody has rented this machine yet.
</TableCell>
</TableRow>
)}
{leases.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.customer_email ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">{relativeTime(l.started_at) ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatHours(l.hours)}
</TableCell>
<TableCell className="text-right">
<Badge variant={l.active ? "default" : "outline"}>
{l.active ? "active" : "ended"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<DeleteConfirmationDialog
open={retireOpen}
onOpenChange={setRetireOpen}
onConfirm={retire}
isLoading={retiring}
itemName={node.name}
itemType="node"
title="Withdraw this machine?"
description={
`"${node.name}" stops being offered and its credentials are revoked, so its worker ` +
"can no longer report in. You would have to register it again and re-run the installer " +
"to bring it back."
}
/>
</div>
);
}
@@ -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<ProviderSummary>(() => communityApi.providerSummary());
return (
<div className="space-y-6">
<PageHeader
title="Your hardware"
description="What you have lent to the platform, and who is using it."
onRefresh={reload}
refreshing={refreshing}
actions={
<Button asChild size="sm">
<Link to="/nodes">Manage nodes</Link>
</Button>
}
/>
{error && <LoadFailed what="your provider summary" error={error} onRetry={reload} retrying={refreshing} />}
{loading && !data ? (
<TableSkeleton rows={4} />
) : data ? (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Server}
label="Machines lent"
value={data.nodes_total}
sub={`${data.nodes_online} online${data.nodes_pending ? ` · ${data.nodes_pending} awaiting install` : ""}`}
/>
<StatCard
icon={Signal}
label="Currently rented"
value={data.nodes_leased}
sub={data.nodes_online
? `${data.nodes_online - data.nodes_leased} idle and available`
: "no machine is online"}
/>
<StatCard
icon={Clock}
label="Hours served"
value={formatHours(data.hours_served_total)}
sub={data.hours_served_active > 0
? `${formatHours(data.hours_served_active)} on live leases`
: "no live lease"}
/>
<StatCard icon={Users} label="Customers served" value={data.customers} />
</div>
{/* 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 && (
<Card>
<CardContent className="py-4 text-sm text-muted-foreground">
Hours are measured lease time. Payouts are not calculated here — there is no
billing plane yet and no price set on a node.
</CardContent>
</Card>
)}
{data.nodes_total === 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">You have not lent a machine yet</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/nodes">Register a machine</Link>
</Button>
</CardContent>
</Card>
)}
{data.nodes_pending > 0 && (
<Card>
<CardContent className="py-4 text-sm text-muted-foreground">
{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.{" "}
<Link to="/nodes" className="text-primary hover:underline">
Get the install command
</Link>.
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent rentals</CardTitle>
<CardDescription>Who has taken your machines, and for how long.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-right">Duration</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recent_leases.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-muted-foreground">
Nobody has rented your hardware yet.
</TableCell>
</TableRow>
)}
{data.recent_leases.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.customer_email ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">
{relativeTime(l.started_at) ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatHours(l.hours)}
</TableCell>
<TableCell className="text-right">
<Badge variant={l.active ? "default" : "outline"}>
{l.active ? "active" : "ended"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
) : null}
</div>
);
}
@@ -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<string, NodeResourceUsage>;
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<T> {
success: boolean;
code: number;
message: string;
data?: T;
error?: { type: string; details: Record<string, unknown> };
}
/**
* 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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>;
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<CommunityNode[]>("/nodes"),
listAvailableNodes: (country?: string) =>
request<CommunityNode[]>(`/nodes/available${country ? `?country=${encodeURIComponent(country)}` : ""}`),
/** Operator-only: every node across every owner. */
listAllNodes: () => request<CommunityNode[]>("/admin/nodes"),
getNode: (nodeId: string) => request<CommunityNode>(`/nodes/${nodeId}`),
registerNode: (input: RegisterNodeInput) =>
request<CommunityNode>("/nodes", {
method: "POST",
body: JSON.stringify({
name: input.name,
country: input.country,
city: input.city || undefined,
}),
}),
retireNode: (nodeId: string) =>
request<null>(`/nodes/${nodeId}`, { method: "DELETE" }),
createPingSession: (nodeId: string) =>
request<PingSession>(`/nodes/${nodeId}/ping-session`, { method: "POST" }),
leaseNode: (nodeId: string) =>
request<Lease>(`/nodes/${nodeId}/lease`, { method: "POST" }),
listLeases: () => request<Lease[]>("/leases"),
endLease: (leaseId: string) =>
request<Lease>(`/leases/${leaseId}`, { method: "DELETE" }),
/** Provider-only: my hardware at a glance. */
providerSummary: () => request<ProviderSummary>("/provider/summary"),
/** Provider-only: every lease ever taken on one of my machines. */
nodeLeases: (nodeId: string) =>
request<ProviderLeaseRow[]>(`/nodes/${nodeId}/leases`),
/** Provider-only: what is running on my machine right now, redacted. */
nodeWorkloads: (nodeId: string) =>
request<ProviderWorkloadRow[]>(`/nodes/${nodeId}/workloads`),
/** Operator-only: counts at a glance. */
adminOverview: () => request<AdminOverview>("/admin/overview"),
/** Operator-only: every workload, attributed to its runner and its machine. */
adminWorkloads: () => request<AdminWorkloadRow[]>("/admin/workloads"),
/** Operator-only: every registered person and what they hold. */
adminUsers: () => request<AdminUserRow[]>("/admin/users"),
/** Operator-only: every lease ever taken, active or ended. */
adminLeases: () => request<AdminLeaseRow[]>("/admin/leases"),
/** The current user's own Cloudflare account, or null if none is on file. */
getMyCloudflare: () => request<CloudflareCredentialInfo | null>("/me/cloudflare"),
setMyCloudflare: (input: { apiToken: string; accountId: string; zoneId: string }) =>
request<CloudflareCredentialInfo>("/me/cloudflare", {
method: "PUT",
body: JSON.stringify({
api_token: input.apiToken,
account_id: input.accountId,
zone_id: input.zoneId,
}),
}),
deleteMyCloudflare: () => request<null>("/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<void> {
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<string> {
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();
}
}