diff --git a/apps/community-admin/.env.example b/apps/community-admin/.env.example
new file mode 100644
index 0000000..f117917
--- /dev/null
+++ b/apps/community-admin/.env.example
@@ -0,0 +1,5 @@
+# The community plane's API. This portal talks to nothing else -- not core,
+# not cloud; they are separate services with their own user tables.
+VITE_COMMUNITY_API_BASE_URL=http://localhost:5002/api
+
+ALLOWED_HOST=localhost
diff --git a/apps/community-admin/docker-compose.yml b/apps/community-admin/docker-compose.yml
new file mode 100644
index 0000000..68672c1
--- /dev/null
+++ b/apps/community-admin/docker-compose.yml
@@ -0,0 +1,18 @@
+# Community operator portal: every node, every workload, every customer,
+# across all owners. Separate from the customer and provider portals -- this
+# is the one surface where cross-owner data (who is running what, on whose
+# hardware) is visible at all, and it must not be reachable by ordinary
+# accounts just because they happen to load the same bundle.
+services:
+ portal:
+ build:
+ context: ../..
+ dockerfile: Dockerfile
+ container_name: xcloudify-community-admin-portal
+ environment:
+ APP: community-admin
+ PORT: "8086"
+ ALLOWED_HOST: ${ALLOWED_HOST:-localhost}
+ VITE_COMMUNITY_API_BASE_URL: ${VITE_COMMUNITY_API_BASE_URL:-http://localhost:5002/api}
+ ports:
+ - "8086:8086"
diff --git a/apps/community-admin/index.html b/apps/community-admin/index.html
new file mode 100644
index 0000000..54af7af
--- /dev/null
+++ b/apps/community-admin/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ xCloudify Community Admin
+
+
+
+
+
+
+
diff --git a/apps/community-admin/package.json b/apps/community-admin/package.json
new file mode 100644
index 0000000..ebe6287
--- /dev/null
+++ b/apps/community-admin/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@xcloudify/community-admin",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": { "@xcloudify/shared": "*" }
+}
diff --git a/apps/community-admin/postcss.config.js b/apps/community-admin/postcss.config.js
new file mode 100644
index 0000000..99417eb
--- /dev/null
+++ b/apps/community-admin/postcss.config.js
@@ -0,0 +1 @@
+export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
diff --git a/apps/community-admin/src/AdminShell.tsx b/apps/community-admin/src/AdminShell.tsx
new file mode 100644
index 0000000..db7dc51
--- /dev/null
+++ b/apps/community-admin/src/AdminShell.tsx
@@ -0,0 +1,62 @@
+/**
+ * Chrome for the operator portal, built on the shared community chrome
+ * (packages/shared/src/CommunityPortalChrome.tsx).
+ *
+ * The one thing this shell adds that the other two don't: a `guard` that
+ * refuses a signed-in non-operator entirely, before any route renders. Every
+ * route in this app is operator-only, so the gate belongs once here rather
+ * than wrapping each route -- see App.tsx's docstring for why that matters.
+ */
+import { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { Toaster } from "@/components/ui/toaster";
+import { CommunityPortalChrome } from "@/CommunityPortalChrome";
+import { communityOperatorNavItems } from "@/components/AppSidebar";
+import { PlaneProvider } from "@/plane";
+
+const queryClient = new QueryClient();
+
+const TITLES: Array<[string, string]> = [
+ ["/workloads", "All workloads"],
+ ["/nodes", "All nodes"],
+ ["/leases", "Leases"],
+ ["/users", "People"],
+ ["/", "Platform overview"],
+];
+
+const GROUPS = [{ label: "Operator", items: communityOperatorNavItems }];
+
+function NotAnOperator() {
+ return (
+
+
Operator access required
+
+ This portal manages community infrastructure across every owner. Your account
+ does not have operator access.
+
+
+ );
+}
+
+export function AdminShell({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ (user.is_platform_admin ? null : )}
+ >
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/apps/community-admin/src/App.tsx b/apps/community-admin/src/App.tsx
new file mode 100644
index 0000000..73d0221
--- /dev/null
+++ b/apps/community-admin/src/App.tsx
@@ -0,0 +1,41 @@
+/**
+ * Operator portal: every node, every workload, every customer, across all
+ * owners.
+ *
+ * One of three separate deployments on the community plane -- see
+ * packages/shared/src/CommunityPortalChrome.tsx. The whole app is gated on
+ * community's own `is_platform_admin`, once in AdminShell rather than once
+ * per route -- this is the one surface where cross-owner data is visible at
+ * all, and nothing here should be reachable by an ordinary account just
+ * because it happened to load the same bundle.
+ *
+ * The gate is a usability guard, not the security boundary: every backend
+ * route this calls (`community/app/routes/admin_routes.py`) checks
+ * is_platform_admin itself regardless of what this UI renders.
+ */
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+
+import { AdminOverviewPage } from "@/components/community/admin/AdminOverviewPage";
+import { AdminAllNodesPage } from "@/components/community/admin/AdminAllNodesPage";
+import { AdminWorkloadsPage } from "@/components/community/admin/AdminWorkloadsPage";
+import { AdminLeasesPage } from "@/components/community/admin/AdminLeasesPage";
+import { AdminUsersPage } from "@/components/community/admin/AdminUsersPage";
+import NotFound from "@/pages/NotFound";
+import { AdminShell } from "./AdminShell";
+
+export default function App() {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/apps/community-admin/src/main.tsx b/apps/community-admin/src/main.tsx
new file mode 100644
index 0000000..b569bfb
--- /dev/null
+++ b/apps/community-admin/src/main.tsx
@@ -0,0 +1,5 @@
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "@/index.css";
+
+createRoot(document.getElementById("root")!).render();
diff --git a/apps/community-admin/tailwind.config.ts b/apps/community-admin/tailwind.config.ts
new file mode 100644
index 0000000..db5435b
--- /dev/null
+++ b/apps/community-admin/tailwind.config.ts
@@ -0,0 +1,5 @@
+// Re-exports the shared theme so all four apps look identical. Content globs
+// are relative to this file, so each app scans its own src plus the shared
+// component library.
+import shared from "../../packages/shared/tailwind.config";
+export default shared;
diff --git a/apps/community-admin/tsconfig.json b/apps/community-admin/tsconfig.json
new file mode 100644
index 0000000..60472a7
--- /dev/null
+++ b/apps/community-admin/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2020", "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext",
+ "skipLibCheck": true, "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true, "isolatedModules": true,
+ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx",
+ "strict": false, "noUnusedLocals": false, "noUnusedParameters": false,
+ "noImplicitAny": false, "strictNullChecks": false, "allowJs": true,
+ "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] }
+ },
+ "include": ["src", "../../packages/shared/src"]
+}
diff --git a/apps/community-admin/vite.config.ts b/apps/community-admin/vite.config.ts
new file mode 100644
index 0000000..1380f57
--- /dev/null
+++ b/apps/community-admin/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import path from "path";
+
+// Every app resolves "@/" to the SHARED package, not its own src. That is
+// what lets ~110 shared components keep their existing imports untouched
+// while each app stays a thin entry point that only picks which portals to
+// mount. App-local files import relatively.
+const PORT = parseInt(process.env.PORT ?? "8086", 10);
+
+export default defineConfig({
+ server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
+ preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
+ plugins: [react()],
+ resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } },
+});
diff --git a/apps/community-provider/.env.example b/apps/community-provider/.env.example
new file mode 100644
index 0000000..f117917
--- /dev/null
+++ b/apps/community-provider/.env.example
@@ -0,0 +1,5 @@
+# The community plane's API. This portal talks to nothing else -- not core,
+# not cloud; they are separate services with their own user tables.
+VITE_COMMUNITY_API_BASE_URL=http://localhost:5002/api
+
+ALLOWED_HOST=localhost
diff --git a/apps/community-provider/docker-compose.yml b/apps/community-provider/docker-compose.yml
new file mode 100644
index 0000000..bfe7570
--- /dev/null
+++ b/apps/community-provider/docker-compose.yml
@@ -0,0 +1,17 @@
+# Provider portal: node owners register hardware, watch its status and what
+# it has earned. A separate deployment from the customer portal -- lending a
+# machine and renting one are different jobs done by different audiences most
+# of the time, even though the same account can do both (community/README.md).
+services:
+ portal:
+ build:
+ context: ../..
+ dockerfile: Dockerfile
+ container_name: xcloudify-community-provider-portal
+ environment:
+ APP: community-provider
+ PORT: "8084"
+ ALLOWED_HOST: ${ALLOWED_HOST:-localhost}
+ VITE_COMMUNITY_API_BASE_URL: ${VITE_COMMUNITY_API_BASE_URL:-http://localhost:5002/api}
+ ports:
+ - "8084:8084"
diff --git a/apps/community-provider/index.html b/apps/community-provider/index.html
new file mode 100644
index 0000000..5aa0b4e
--- /dev/null
+++ b/apps/community-provider/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ xCloudify Provider
+
+
+
+
+
+
+
diff --git a/apps/community-provider/package.json b/apps/community-provider/package.json
new file mode 100644
index 0000000..63dc0e1
--- /dev/null
+++ b/apps/community-provider/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@xcloudify/community-provider",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": { "@xcloudify/shared": "*" }
+}
diff --git a/apps/community-provider/postcss.config.js b/apps/community-provider/postcss.config.js
new file mode 100644
index 0000000..99417eb
--- /dev/null
+++ b/apps/community-provider/postcss.config.js
@@ -0,0 +1 @@
+export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
diff --git a/apps/community-provider/src/App.tsx b/apps/community-provider/src/App.tsx
new file mode 100644
index 0000000..50c6d4c
--- /dev/null
+++ b/apps/community-provider/src/App.tsx
@@ -0,0 +1,32 @@
+/**
+ * Provider portal: lend hardware to the community cloud, watch its status
+ * and what it has earned.
+ *
+ * One of three separate deployments on the community plane -- see
+ * packages/shared/src/CommunityPortalChrome.tsx. Reachable by any account
+ * (registering the first node is the entry point, so nothing here is
+ * gated) -- it only ever shows the caller's own hardware, never anyone
+ * else's. Cross-owner visibility is apps/community-admin.
+ */
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+
+import { ProviderOverviewPage } from "@/components/community/provider/ProviderOverviewPage";
+import { MyNodesPage } from "@/components/community/MyNodesPage";
+import { NodeDetailPage } from "@/components/community/provider/NodeDetailPage";
+import NotFound from "@/pages/NotFound";
+import { ProviderShell } from "./ProviderShell";
+
+export default function App() {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/apps/community-provider/src/ProviderShell.tsx b/apps/community-provider/src/ProviderShell.tsx
new file mode 100644
index 0000000..b00f14e
--- /dev/null
+++ b/apps/community-provider/src/ProviderShell.tsx
@@ -0,0 +1,43 @@
+/**
+ * Chrome for the provider portal, built on the shared community chrome
+ * (packages/shared/src/CommunityPortalChrome.tsx). Nothing extra: unlike the
+ * customer portal there is no tenant scope to pick (a provider's own nodes
+ * are simply all of them), so this is the thinnest of the three shells.
+ */
+import { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { Toaster } from "@/components/ui/toaster";
+import { CommunityPortalChrome } from "@/CommunityPortalChrome";
+import { communityProviderNavItems } from "@/components/AppSidebar";
+import { PlaneProvider } from "@/plane";
+
+const queryClient = new QueryClient();
+
+const TITLES: Array<[string, string]> = [
+ ["/nodes", "My nodes"],
+ ["/", "Your hardware"],
+];
+
+const GROUPS = [{ label: "Your hardware", items: communityProviderNavItems }];
+
+export function ProviderShell({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/apps/community-provider/src/main.tsx b/apps/community-provider/src/main.tsx
new file mode 100644
index 0000000..b569bfb
--- /dev/null
+++ b/apps/community-provider/src/main.tsx
@@ -0,0 +1,5 @@
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "@/index.css";
+
+createRoot(document.getElementById("root")!).render();
diff --git a/apps/community-provider/tailwind.config.ts b/apps/community-provider/tailwind.config.ts
new file mode 100644
index 0000000..db5435b
--- /dev/null
+++ b/apps/community-provider/tailwind.config.ts
@@ -0,0 +1,5 @@
+// Re-exports the shared theme so all four apps look identical. Content globs
+// are relative to this file, so each app scans its own src plus the shared
+// component library.
+import shared from "../../packages/shared/tailwind.config";
+export default shared;
diff --git a/apps/community-provider/tsconfig.json b/apps/community-provider/tsconfig.json
new file mode 100644
index 0000000..60472a7
--- /dev/null
+++ b/apps/community-provider/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2020", "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext",
+ "skipLibCheck": true, "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true, "isolatedModules": true,
+ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx",
+ "strict": false, "noUnusedLocals": false, "noUnusedParameters": false,
+ "noImplicitAny": false, "strictNullChecks": false, "allowJs": true,
+ "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] }
+ },
+ "include": ["src", "../../packages/shared/src"]
+}
diff --git a/apps/community-provider/vite.config.ts b/apps/community-provider/vite.config.ts
new file mode 100644
index 0000000..f5dedb8
--- /dev/null
+++ b/apps/community-provider/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import path from "path";
+
+// Every app resolves "@/" to the SHARED package, not its own src. That is
+// what lets ~110 shared components keep their existing imports untouched
+// while each app stays a thin entry point that only picks which portals to
+// mount. App-local files import relatively.
+const PORT = parseInt(process.env.PORT ?? "8084", 10);
+
+export default defineConfig({
+ server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
+ preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
+ plugins: [react()],
+ resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } },
+});
diff --git a/apps/community/.env.example b/apps/community/.env.example
new file mode 100644
index 0000000..4abac3c
--- /dev/null
+++ b/apps/community/.env.example
@@ -0,0 +1,12 @@
+# The community plane's API. This portal talks to nothing else -- not core,
+# not cloud; they are separate services with their own user tables.
+VITE_COMMUNITY_API_BASE_URL=http://localhost:5002/api
+
+# xcloudifyApi.ts's own base URL, shared with cloud/core. It defaults to
+# cloud's port (5001) when unset, which is wrong here -- the compute/storage/
+# network pages (InfrastructurePage, StoragePage, NetworkDetailsPage) go
+# through the same client, scoped to a lease instead of a VDC (see
+# hooks/useLease.tsx), and need to land on this same community backend.
+VITE_API_BASE_URL=http://localhost:5002/api
+
+ALLOWED_HOST=localhost
diff --git a/apps/community/docker-compose.yml b/apps/community/docker-compose.yml
new file mode 100644
index 0000000..588a2f3
--- /dev/null
+++ b/apps/community/docker-compose.yml
@@ -0,0 +1,19 @@
+# Community cloud portal: node owners register hardware, customers reserve it.
+services:
+ portal:
+ build:
+ context: ../..
+ dockerfile: Dockerfile
+ container_name: xcloudify-community-portal
+ environment:
+ APP: community
+ PORT: "8081"
+ ALLOWED_HOST: ${ALLOWED_HOST:-localhost}
+ VITE_COMMUNITY_API_BASE_URL: ${VITE_COMMUNITY_API_BASE_URL:-http://localhost:5002/api}
+ # xcloudifyApi.ts's own base URL, shared with cloud/core -- defaults to
+ # cloud's port (5001) when unset. The compute/storage/network pages
+ # (InfrastructurePage, StoragePage, NetworkDetailsPage) go through that
+ # client, scoped by lease instead of VDC, and need this same backend.
+ VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5002/api}
+ ports:
+ - "8081:8081"
diff --git a/apps/community/index.html b/apps/community/index.html
new file mode 100644
index 0000000..369941e
--- /dev/null
+++ b/apps/community/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ xCloudify Community
+
+
+
+
+
+
+
diff --git a/apps/community/package.json b/apps/community/package.json
new file mode 100644
index 0000000..27c84b5
--- /dev/null
+++ b/apps/community/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@xcloudify/community",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": { "@xcloudify/shared": "*" }
+}
diff --git a/apps/community/postcss.config.js b/apps/community/postcss.config.js
new file mode 100644
index 0000000..99417eb
--- /dev/null
+++ b/apps/community/postcss.config.js
@@ -0,0 +1 @@
+export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
diff --git a/apps/community/src/App.tsx b/apps/community/src/App.tsx
new file mode 100644
index 0000000..480ac19
--- /dev/null
+++ b/apps/community/src/App.tsx
@@ -0,0 +1,97 @@
+/**
+ * Customer portal: rent somebody else's machine and run work on it.
+ *
+ * One of three separate deployments on the community plane -- see
+ * packages/shared/src/CommunityPortalChrome.tsx for why they are not one app
+ * with nav sections. This one is for the renting audience only; lending a
+ * machine is apps/community-provider, and cross-owner operator visibility is
+ * apps/community-admin. All three talk to the same community API (:5002)
+ * and share nothing else -- not a login, not a bundle, not a URL space.
+ *
+ * Feature parity with the cloud portal: every route here mounts the exact
+ * same shared component apps/cloud does, at the exact same path, except
+ * Data Centers and Certificates -- see AppSidebar.tsx's
+ * communityCustomerNavItems for why those two specifically have no
+ * equivalent in a plane with no VDC/project tenancy.
+ */
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+
+import { CommunityDashboard } from "@/components/community/CommunityDashboard";
+import { MarketplacePage } from "@/components/community/MarketplacePage";
+import { MyLeasesPage } from "@/components/community/MyLeasesPage";
+import { CloudflarePage } from "@/components/community/CloudflarePage";
+import { RequireActiveLease } from "@/components/RequireActiveLease";
+
+// Shared compute product, mounted unchanged. These are the same screens the
+// cloud portal serves; what differs is only what they are scoped by, and that
+// is answered by hooks/useTenantScope rather than by a community-specific
+// copy of each page.
+import { InfrastructurePage } from "@/components/InfrastructurePage";
+import { StoragePage } from "@/components/StoragePage";
+import { NetworkDetailsPage } from "@/components/NetworkDetailsPage";
+import { VirtualMachineDetailsWrapper } from "@/components/VirtualMachineDetailsWrapper";
+import { VolumeDetailsWrapper } from "@/components/VolumeDetailsWrapper";
+
+// Account-level, same as on cloud -- no lease/VDC dependency, so none of
+// these sit behind RequireActiveLease.
+import { ExplorePage } from "@/components/ExplorePage";
+import { SSHKeysPage } from "@/components/SSHKeysPage";
+import { AIServicesPage } from "@/components/AIServicesPage";
+import { ServerlessPage } from "@/components/ServerlessPage";
+import { GitDeployPage } from "@/components/GitDeployPage";
+import { BillingPage } from "@/components/BillingPage";
+import { ReferralsPage } from "@/components/ReferralsPage";
+
+import NotFound from "@/pages/NotFound";
+import { CommunityShell } from "./CommunityShell";
+
+/** Matches cloud/src/App.tsx's own Stub: styled disabled in the sidebar, but
+ * still routable so a click lands on an explanation rather than a 404. */
+const Stub = ({ title }: { title: string }) => (
+
{title} page coming soon...
+);
+
+export default function App() {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+ {/* Every one of these routes through /leases//core/*, so none
+ can load anything until a lease is selected -- and unlike a
+ VDC, a lease cannot be created from a blank state (it comes out
+ of leasing a node on the marketplace). Hence the gate is here,
+ per-route, rather than around the whole shell: the marketplace
+ and My Leases have to stay reachable with zero leases. */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Nav entries styled disabled, same as cloud's own -- landing on a
+ stub beats landing on a 404. */}
+ } />
+ } />
+ } />
+
+ } />
+
+
+
+ );
+}
diff --git a/apps/community/src/CommunityShell.tsx b/apps/community/src/CommunityShell.tsx
new file mode 100644
index 0000000..e05d66d
--- /dev/null
+++ b/apps/community/src/CommunityShell.tsx
@@ -0,0 +1,107 @@
+/**
+ * Chrome for the customer portal, built on the shared community chrome
+ * (packages/shared/src/CommunityPortalChrome.tsx). Adds the two things only
+ * this portal needs: a lease is a tenant scope here, so LeaseProvider is
+ * mounted and its picker sits in the header next to the identity menu.
+ */
+import { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { Toaster } from "@/components/ui/toaster";
+import { CommunityPortalChrome } from "@/CommunityPortalChrome";
+import { communityNavItems, communityCustomerNavItems, generalNavItems } from "@/components/AppSidebar";
+import { PlaneProvider } from "@/plane";
+import { LeaseProvider, useLease } from "@/hooks/useLease";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+
+const queryClient = new QueryClient();
+
+const TITLES: Array<[string, string]> = [
+ ["/marketplace", "Marketplace"],
+ ["/leases", "My leases"],
+ ["/cloudflare", "Public access"],
+ ["/infrastructure", "Compute"],
+ ["/storage", "Storage"],
+ ["/explore", "Explore"],
+ ["/ssh-keys", "SSH keys"],
+ ["/ai-services", "AI Services"],
+ ["/serverless", "Serverless Compute"],
+ ["/git-deploy", "Git Deploy"],
+ ["/billing", "Billing"],
+ ["/referrals", "Referrals"],
+ ["/my-templates", "My Templates"],
+ ["/changelog", "Changelog"],
+ ["/vote", "Vote feature"],
+ ["/", "Overview"],
+];
+
+const GROUPS = [
+ { label: "Community", items: communityNavItems },
+ { label: "Your compute", items: communityCustomerNavItems },
+ { label: "General", items: generalNavItems },
+];
+
+/**
+ * Which leased machine the customer screens are working on.
+ *
+ * In the header rather than above one page, for the same reason cloud puts
+ * its VDC picker there: a lease is the tenant unit of every core-owned call
+ * this portal makes (`/leases//core/*`), so it scopes compute, storage,
+ * networks and public access all at once. Having it visible on every screen
+ * is also what lets the create forms drop their own copy of the control --
+ * see hooks/useTenantScope's `perResourcePicker`.
+ *
+ * Hidden with fewer than two leases: with one there is nothing to choose, and
+ * with none the customer screens are behind RequireActiveLease anyway.
+ */
+function LeasePicker() {
+ const { leases, activeLease, selectLease } = useLease();
+ if (leases.length < 2 || !activeLease) return null;
+
+ return (
+
+ Working on
+
+
+ );
+}
+
+export function CommunityShell({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+ }
+ >
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/community/src/main.tsx b/apps/community/src/main.tsx
new file mode 100644
index 0000000..b569bfb
--- /dev/null
+++ b/apps/community/src/main.tsx
@@ -0,0 +1,5 @@
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "@/index.css";
+
+createRoot(document.getElementById("root")!).render();
diff --git a/apps/community/tailwind.config.ts b/apps/community/tailwind.config.ts
new file mode 100644
index 0000000..db5435b
--- /dev/null
+++ b/apps/community/tailwind.config.ts
@@ -0,0 +1,5 @@
+// Re-exports the shared theme so all four apps look identical. Content globs
+// are relative to this file, so each app scans its own src plus the shared
+// component library.
+import shared from "../../packages/shared/tailwind.config";
+export default shared;
diff --git a/apps/community/tsconfig.json b/apps/community/tsconfig.json
new file mode 100644
index 0000000..60472a7
--- /dev/null
+++ b/apps/community/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2020", "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext",
+ "skipLibCheck": true, "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true, "isolatedModules": true,
+ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx",
+ "strict": false, "noUnusedLocals": false, "noUnusedParameters": false,
+ "noImplicitAny": false, "strictNullChecks": false, "allowJs": true,
+ "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] }
+ },
+ "include": ["src", "../../packages/shared/src"]
+}
diff --git a/apps/community/vite.config.ts b/apps/community/vite.config.ts
new file mode 100644
index 0000000..0618940
--- /dev/null
+++ b/apps/community/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import path from "path";
+
+// Every app resolves "@/" to the SHARED package, not its own src. That is
+// what lets ~110 shared components keep their existing imports untouched
+// while each app stays a thin entry point that only picks which portals to
+// mount. App-local files import relatively.
+const PORT = parseInt(process.env.PORT ?? "8081", 10);
+
+export default defineConfig({
+ server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
+ preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
+ plugins: [react()],
+ resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } },
+});
diff --git a/package.json b/package.json
index 5a92129..08b9e84 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,15 @@
"scripts": {
"dev:core": "npm run dev -w @xcloudify/core",
"dev:cloud": "npm run dev -w @xcloudify/cloud",
+ "dev:community": "npm run dev -w @xcloudify/community",
+ "dev:community-provider": "npm run dev -w @xcloudify/community-provider",
+ "dev:community-admin": "npm run dev -w @xcloudify/community-admin",
"build": "npm run build --workspaces --if-present",
"build:core": "npm run build -w @xcloudify/core",
"build:cloud": "npm run build -w @xcloudify/cloud",
+ "build:community": "npm run build -w @xcloudify/community",
+ "build:community-provider": "npm run build -w @xcloudify/community-provider",
+ "build:community-admin": "npm run build -w @xcloudify/community-admin",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint ."
},
diff --git a/packages/shared/src/CommunityPortalChrome.tsx b/packages/shared/src/CommunityPortalChrome.tsx
new file mode 100644
index 0000000..bca1b33
--- /dev/null
+++ b/packages/shared/src/CommunityPortalChrome.tsx
@@ -0,0 +1,260 @@
+/**
+ * Chrome shared by all three community-plane portals: customer, provider,
+ * operator.
+ *
+ * Three separate deployments, not three sections of one app -- lending a
+ * machine and renting one are different jobs, done by different people most
+ * of the time, and each portal should be something you can hand a link to
+ * without also handing over the other two. (An earlier version of this put
+ * all three behind one login as nav groups; that was wrong -- the point of
+ * separate portals is that a provider's bookmark, and what they see when
+ * they open it, has nothing about customers on it.)
+ *
+ * What they share is everything that has nothing to do with which one you
+ * are: community's own session (`/auth/me`, not cloud's) -- a real sign-in,
+ * or in development the identity switcher (see community/README.md) -- and the
+ * same sidebar/header layout the other consoles use so the three read as one
+ * product family rather than three different apps.
+ *
+ * Each portal's own Shell.tsx supplies only what differs: its nav groups,
+ * its badge, and whether it needs a lease in scope at all (only the customer
+ * portal does -- see LeaseProvider below).
+ */
+import { ReactNode, useState } from "react";
+import { useLocation } from "react-router-dom";
+import { ChevronDown, LogOut, RefreshCw, ServerCrash, ShieldCheck, UserCog } from "lucide-react";
+
+import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
+import { AppSidebar, type NavGroup } from "@/components/AppSidebar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
+ DropdownMenuSeparator, DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import { useCommunityAuth, CommunityAuthUser } from "@/hooks/useCommunityAuth";
+import { getDevUser, setDevUser } from "@/services/communityApi";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Toaster as Sonner } from "@/components/ui/sonner";
+import { SignInScreen } from "@/components/SignInScreen";
+import { oauthSignOutUrl } from "@/services/xcloudifyApi";
+
+function initialsOf(name: string): string {
+ const letters = name
+ .split(/[\s@._-]+/)
+ .filter(Boolean)
+ .map((part) => part[0])
+ .join("");
+ return (letters.slice(0, 2) || "?").toUpperCase();
+}
+
+function Unreachable({ error, portalName }: { error: string; portalName: string }) {
+ return (
+
+
+
+
+
+
+
{portalName} unavailable
+
+ The community service could not be reached: {error}
+
+
+
+
+
+ );
+}
+
+/**
+ * Act as a different person.
+ *
+ * Development only (auth_mode "dev"). The API authenticates nobody there, which makes every browser the same
+ * account -- and exercising these portals needs at least two identities:
+ * someone who lends a machine and someone who rents it (you cannot lease
+ * your own node). Lives in the user menu rather than a banner across the
+ * top: it is a property of who you are, and the portal should not look like
+ * a debug build because of it.
+ */
+function IdentityDialog({
+ open, onOpenChange, current,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ current: string;
+}) {
+ const [value, setValue] = useState(getDevUser() ?? current);
+
+ const apply = (email: string | null) => {
+ setDevUser(email);
+ // Everything cached belongs to the old identity, so start clean rather
+ // than reconciling two users' data.
+ window.location.reload();
+ };
+
+ return (
+
+ );
+}
+
+function UserMenu({ user }: { user: CommunityAuthUser }) {
+ const [switching, setSwitching] = useState(false);
+ const name = user.name || user.email;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {user.auth_mode === "dev" ? (
+ setSwitching(true)}>
+
+ Switch identity
+
+ ) : (
+
+
+
+ Sign out
+
+
+ )}
+
+
+ {user.auth_mode === "dev" && (
+
+ )}
+ >
+ );
+}
+
+export interface CommunityPortalChromeProps {
+ /** Nav groups for this portal specifically -- one portal, its own sidebar. */
+ groups: NavGroup[];
+ /** Names the plane under the shared logo, e.g. "Provider", "Operator". */
+ badge: string;
+ /** What to call this portal in the "service unavailable" screen. */
+ portalName: string;
+ /** Header title per route: longest matching prefix wins. */
+ titles: Array<[string, string]>;
+ /** Extra header content right of the title, left of the user menu --
+ * the customer portal's lease picker, e.g. */
+ headerExtra?: ReactNode;
+ /** Refused entirely for a signed-in non-operator, e.g. -- checked after
+ * the loading/unreachable states, before any children render. */
+ guard?: (user: CommunityAuthUser) => ReactNode | null;
+ children: ReactNode;
+}
+
+export function CommunityPortalChrome({
+ groups, badge, portalName, titles, headerExtra, guard, children,
+}: CommunityPortalChromeProps) {
+ const { user, loading, error } = useCommunityAuth();
+ const { pathname } = useLocation();
+
+ if (loading) {
+ return (
+
+ Checking session…
+
+ );
+ }
+ if (error) {
+ return ;
+ }
+ if (!user) {
+ // /auth/me said 401: no session. Only reachable outside dev mode.
+ return ;
+ }
+ const denied = guard?.(user);
+ if (denied) return <>{denied}>;
+
+ const title = titles.find(
+ ([prefix]) => pathname === prefix || (prefix !== "/" && pathname.startsWith(prefix)),
+ )?.[1] ?? portalName;
+
+ return (
+
+
+
+
+
+
+
+
+
{title}
+
+
+ {headerExtra}
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/DeleteConfirmationDialog.tsx b/packages/shared/src/components/DeleteConfirmationDialog.tsx
index e1bd919..5ee1a91 100644
--- a/packages/shared/src/components/DeleteConfirmationDialog.tsx
+++ b/packages/shared/src/components/DeleteConfirmationDialog.tsx
@@ -20,7 +20,11 @@ interface DeleteConfirmationDialogProps {
itemName: string;
itemType:
| "pod" | "container" | "network" | "server group" | "virtual machine"
- | "volume" | "universe" | "DNS record";
+ | "volume" | "universe" | "DNS record"
+ // Community: a machine a provider lent to the platform. Withdrawing it is
+ // destructive in the same way -- credentials are revoked and the worker
+ // can no longer report in -- so it gets the same confirmation.
+ | "node";
isLoading?: boolean;
}
diff --git a/packages/shared/src/components/RequireActiveLease.tsx b/packages/shared/src/components/RequireActiveLease.tsx
new file mode 100644
index 0000000..bd45bdf
--- /dev/null
+++ b/packages/shared/src/components/RequireActiveLease.tsx
@@ -0,0 +1,70 @@
+/**
+ * Gate for the community portal's compute/storage/network pages.
+ *
+ * A lease is the tenant unit there (see hooks/useLease.tsx) -- every
+ * core-owned call routes through /leases//core/*, so nothing under this
+ * gate can load anything until one is selected. Mirrors AppShell's
+ * vdc.resolved / vdc.activeVdc gate in shape, but as a route-level wrapper
+ * rather than a whole-shell gate: unlike a VDC, a lease cannot be created
+ * from a blank state (it comes out of leasing a node on the marketplace), so
+ * the other community pages -- Marketplace, My Nodes, My Leases -- must stay
+ * reachable with zero leases, which ruled out gating the whole shell on this.
+ *
+ * Purely a gate. Choosing *which* lease belongs in the shell header, next to
+ * the identity, where it is visible on every screen and matches how cloud
+ * presents its VDC picker -- not inline above whichever page happened to
+ * mount this wrapper.
+ */
+import { ReactNode } from "react";
+import { Link } from "react-router-dom";
+import { Cloud, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useLease } from "@/hooks/useLease";
+
+export function RequireActiveLease({ children }: { children: ReactNode }) {
+ const { leases, activeLease, resolved, error } = useLease();
+
+ if (!resolved) {
+ return (
+
+ Compute, storage and network are managed per leased node. Lease one
+ from the marketplace to get started.
+
+
+
+ );
+ }
+
+ if (!activeLease) {
+ // Transient: leases loaded but selectLease's effect hasn't committed an
+ // id yet. Same frame it always resolves on the next render.
+ return null;
+ }
+
+ return <>{children}>;
+}
diff --git a/packages/shared/src/components/community/CloudflarePage.tsx b/packages/shared/src/components/community/CloudflarePage.tsx
new file mode 100644
index 0000000..c3a6bc2
--- /dev/null
+++ b/packages/shared/src/components/community/CloudflarePage.tsx
@@ -0,0 +1,241 @@
+/**
+ * How a leased workload reaches the internet.
+ *
+ * A community node is a NAT'd machine under somebody's desk with no public
+ * address of its own -- app/regions.py spells this out -- so a tunnel is not
+ * an add-on here, it is the only way out. The account that pays for that
+ * tunnel is the *customer's*, not ours and not the node owner's: they bring
+ * their own Cloudflare zone, and their leased containers get hostnames on it.
+ *
+ * One account covers every machine they lease, which is why this is its own
+ * screen rather than a control repeated per lease (see CloudflareCredential's
+ * docstring in community/app/models.py for why it is per-user).
+ */
+import { useCallback, useEffect, useState } from "react";
+import { Cloud, ShieldCheck } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { useToast } from "@/hooks/use-toast";
+import { communityApi, CloudflareCredentialInfo } from "@/services/communityApi";
+import { useVdcExposures } from "@/hooks/useVdcExposures";
+import { useLease } from "@/hooks/useLease";
+
+function CloudflareSettingsCard() {
+ const { toast } = useToast();
+ const [cred, setCred] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState({ apiToken: "", accountId: "", zoneId: "" });
+ const [saving, setSaving] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ setCred(await communityApi.getMyCloudflare());
+ } catch {
+ // Best-effort; the page still works with exposure simply unavailable.
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const save = async () => {
+ setSaving(true);
+ try {
+ const saved = await communityApi.setMyCloudflare(form);
+ setCred(saved);
+ setDialogOpen(false);
+ setForm({ apiToken: "", accountId: "", zoneId: "" });
+ toast({ title: "Cloudflare account connected", description: `Verified against ${saved.domain}.` });
+ } catch (err) {
+ toast({
+ title: "Could not verify Cloudflare credentials",
+ description: err instanceof Error ? err.message : String(err),
+ variant: "destructive",
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const remove = async () => {
+ try {
+ await communityApi.deleteMyCloudflare();
+ setCred(null);
+ toast({ title: "Cloudflare account removed" });
+ } catch (err) {
+ toast({
+ title: "Could not remove Cloudflare account",
+ description: err instanceof Error ? err.message : String(err),
+ variant: "destructive",
+ });
+ }
+ };
+
+ if (loading) return null;
+
+ return (
+
+
+
+
+
+
+
+ Cloudflare
+
+ Expose a leased container's port publicly, without the node needing one.
+
+
+
+ {cred ? (
+
+
+
+ {cred.domain}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+
+/**
+ * The hostnames that actually exist, as opposed to the ones a port's name
+ * implies. Provisioning is best-effort -- the gateway deliberately swallows a
+ * Cloudflare failure rather than turning a working container into an error --
+ * so a port that asked to be public and never got a record is a real state
+ * this screen has to be able to show.
+ */
+function ExposureList() {
+ const { exposures, cloudflareDomain, loading, error } = useVdcExposures();
+ const { activeLease } = useLease();
+
+ if (!activeLease) return null;
+
+ return (
+
+
+ Public hostnames
+
+ {cloudflareDomain
+ ? `Container ports published on ${cloudflareDomain} for ${activeLease.node_name ?? "this lease"}.`
+ : "Connect a Cloudflare account above, then mark a container port public when you create it."}
+
+
+
+
+
+
+ Hostname
+ Container port
+
+
+
+ {(loading || error || exposures.length === 0) && (
+
+
+ {loading ? "Loading…"
+ : error ? error
+ : "No port on this lease is published yet."}
+
+
+ )}
+ {exposures.map((e) => (
+
+
+
+ {e.hostname}
+
+
+
+ {e.internal_port ?? "—"}
+
+
+ ))}
+
+
+ Leased machines have no public address of their own. Bring your own Cloudflare
+ account and their ports get hostnames on your domain.
+
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/community/CommunityDashboard.tsx b/packages/shared/src/components/community/CommunityDashboard.tsx
new file mode 100644
index 0000000..4b3b12f
--- /dev/null
+++ b/packages/shared/src/components/community/CommunityDashboard.tsx
@@ -0,0 +1,357 @@
+/**
+ * The customer portal's front page: what am I renting, and what is out there
+ * to rent.
+ *
+ * Customer-only. This used to answer "what do I have here?" for both halves
+ * of the product at once -- nodes lent and leases held, side by side -- back
+ * when lending and renting were two sections of one app. They are now two
+ * separate deployments (see CommunityPortalChrome.tsx's docstring for why),
+ * so this dashboard only shows what this app can actually act on. Lending a
+ * machine is one link away, to the provider portal's own origin -- not a
+ * route here, a different app entirely.
+ *
+ * Every number here is read from the community plane, never derived from a
+ * guess: the "available" count from the same marketplace listing the
+ * marketplace page renders, leases from the caller's own /api/leases.
+ */
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import {
+ ArrowRight, Gauge, KeyRound, MapPin, Server, ShoppingCart, Sparkles,
+ RefreshCw, type LucideIcon,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ErrorBanner } from "@/components/ui/error-banner";
+import { useCommunityAuth } from "@/hooks/useCommunityAuth";
+import { communityApi, CommunityNode, Lease, PROVIDER_PORTAL_URL } from "@/services/communityApi";
+// One description of a node, shared with the node list and the operator
+// views, so "seen 5 h ago" reads the same everywhere it appears.
+import { nodeLocation, nodeSpecSummary, relativeTime } from "./nodeDisplay";
+
+interface Snapshot {
+ leases: Lease[];
+ available: CommunityNode[];
+ leasedNodes: Record;
+}
+
+const EMPTY: Snapshot = { leases: [], available: [], leasedNodes: {} };
+
+function StatCard({
+ icon: Icon, label, value, sub, tone = "default", loading,
+}: {
+ icon: LucideIcon;
+ label: string;
+ value: string | number;
+ sub?: string;
+ tone?: "default" | "primary";
+ loading?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function CommunityDashboard() {
+ const { user } = useCommunityAuth();
+ const [data, setData] = useState(EMPTY);
+ const [failures, setFailures] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const load = useCallback(async () => {
+ const errors: Record = {};
+ const say = (err: unknown) => (err instanceof Error ? err.message : String(err));
+
+ const [leases, available] = await Promise.all([
+ communityApi.listLeases().catch((err) => { errors["Your leases"] = say(err); return null; }),
+ communityApi.listAvailableNodes().catch((err) => { errors["Marketplace"] = say(err); return null; }),
+ ]);
+
+ // Leases carry only a node id. Resolve the ones we can so the panel can
+ // say "homelab-01 in Kathmandu" rather than showing a bare uuid.
+ const active = (leases ?? []).filter((l) => l.active && l.customer_id === user?.id);
+ const resolved = await Promise.all(
+ active.map((l) => communityApi.getNode(l.node_id).catch(() => null)),
+ );
+
+ setData((prev) => ({
+ leases: leases ?? prev.leases,
+ available: available ?? prev.available,
+ leasedNodes: Object.fromEntries(resolved.filter(Boolean).map((n) => [n!.id, n!])),
+ }));
+ setFailures(errors);
+ setLoading(false);
+ setRefreshing(false);
+ }, [user?.id]);
+
+ useEffect(() => {
+ load();
+ // Availability is derived from heartbeats, so it can change while this
+ // page is open. Poll rather than leaving a stale count on screen.
+ const timer = setInterval(load, 30000);
+ return () => clearInterval(timer);
+ }, [load]);
+
+ const refresh = () => {
+ setRefreshing(true);
+ load();
+ };
+
+ const stats = useMemo(() => {
+ // `customer_id`, not just `active`: GET /leases returns every lease this
+ // user is party to -- as customer *and* as the owner of the leased node
+ // (community/app/routes/lease_routes.py:list_leases). Counting both here
+ // told a provider who also rents that they held leases on their own
+ // machines, and put those machines in a "reserved to you alone" list that
+ // links to a page which correctly does not show them. This portal is the
+ // renting side only; the owner side is the provider portal's job.
+ const activeLeases = data.leases.filter((l) => l.active && l.customer_id === user?.id);
+ return {
+ activeLeases,
+ countries: new Set(data.available.map((n) => n.country)).size,
+ };
+ }, [data, user?.id]);
+
+ const firstRun = !loading && stats.activeLeases.length === 0 && data.available.length > 0;
+ const failureCount = Object.keys(failures).length;
+
+ return (
+
+
+
+
Welcome back!
+
+ Lease hardware that somebody else has lent to the community cloud.
+
+
+
+
+
+ {failureCount > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {firstRun ? (
+
+
+
+
+
+
+
Nothing reserved yet
+
+ Browse what is available and lease a machine to run virtual machines,
+ containers, storage and networks on it.
+
+ )}
+
+
+ )}
+
+ {/* Lending is a different job, done in a different app -- a real
+ cross-origin link, not a route. See CommunityPortalChrome.tsx. */}
+
+
+
+
+
+
+
+
Have spare hardware?
+
+ Lend a machine to the community cloud in the provider portal.
+
+ );
+}
+
+export function MarketplacePage() {
+ const { toast } = useToast();
+ const navigate = useNavigate();
+ // Leasing changes the session's tenant scope, not just this page's list --
+ // see the comment in `lease` below.
+ const { selectLease, refresh: refreshLeaseScope } = useLease();
+ const [nodes, setNodes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [pings, setPings] = useState>({});
+ /** Which node a lease is in flight for -- leasing is exclusive, so a
+ * double-click would race two claims on the same machine. */
+ const [leasing, setLeasing] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ setNodes(await communityApi.listAvailableNodes());
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load nodes");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ /**
+ * Authorise with the control plane, then measure browser->worker directly.
+ * The API is not in the data path, so this reflects the latency a workload
+ * on this node would actually see from here.
+ */
+ const runPing = async (node: CommunityNode) => {
+ setPings((p) => ({ ...p, [node.id]: "pending" }));
+ try {
+ const session = await communityApi.createPingSession(node.id);
+ const result = await measurePing(session);
+ setPings((p) => ({
+ ...p,
+ [node.id]: { medianMs: result.medianMs, path: result.path },
+ }));
+ } catch (err) {
+ setPings((p) => ({
+ ...p,
+ [node.id]: {
+ medianMs: null,
+ path: "unknown",
+ error: err instanceof Error ? err.message : String(err),
+ },
+ }));
+ }
+ };
+
+ /**
+ * Leasing is the only way a community customer acquires a tenant scope, so
+ * it has to update LeaseProvider and not just this page: every compute,
+ * storage and network route is behind RequireActiveLease, and the API
+ * client routes core-owned calls through `/leases//core/*` using the
+ * id LeaseProvider holds. Refreshing only the marketplace list left that
+ * provider still reporting zero leases, so the customer's first lease
+ * looked like it had not happened until they reloaded the page by hand.
+ *
+ * Selected before the refetch rather than after: `selectLease` persists the
+ * id, and the refetch prefers the remembered lease over the first in the
+ * list -- so the machine they just took is the one they land on even when
+ * they already had others.
+ */
+ const lease = async (node: CommunityNode) => {
+ setLeasing(node.id);
+ try {
+ const newLease = await communityApi.leaseNode(node.id);
+ selectLease(newLease.id);
+ refreshLeaseScope();
+ toast({
+ title: `Leased ${node.name}`,
+ description: "It's yours alone until you end the lease. Opening your leases…",
+ });
+ await load();
+ navigate("/leases");
+ } catch (err) {
+ toast({
+ title: "Could not lease node",
+ description: err instanceof Error ? err.message : String(err),
+ variant: "destructive",
+ });
+ } finally {
+ setLeasing(null);
+ }
+ };
+
+ return (
+
+
+
+
Marketplace
+
+ {loading
+ ? "Community hardware available to lease. Test latency before you commit."
+ : `${nodes.length} machine${nodes.length === 1 ? "" : "s"} available to lease. Test latency before you commit.`}
+
+
+
+
+
+
+
+
+
Community hardware is operator-owned
+
+ These machines belong to third parties, and the specs below are
+ self-reported by their owners. Treat this tier as untrusted: avoid
+ running workloads with sensitive data on it.
+
+
+
+ {lease.active
+ ? relativeTime(lease.started_at)
+ ? `reserved to you · ${relativeTime(lease.started_at)}`
+ : "reserved to you"
+ : "ended"}
+
+ {lease.active && (
+
+ )}
+
+
+
+ {lease.active && (
+
+ {offline && (
+
+ This machine is {lease.node_status}. Compute on it is unavailable
+ until it comes back online.
+
+ )}
+ {/* `node_enrolled`, not just `region_id`: the region is created when
+ the owner registers the machine, so it is set long before the
+ installer has run. Keying only on it meant this notice never
+ appeared, and the customer clicked through to Compute to meet a
+ 409 error banner instead. */}
+ {!offline && (!lease.region_id || !lease.node_enrolled) && (
+
+ This machine is still joining the compute plane. It usually takes
+ a minute after the installer runs.
+
+ Run this on the machine you want to contribute. It installs the agent and
+ finishes enrolling the node.
+
+
+ {/* whitespace-pre-wrap + break-all, not overflow-x-auto: this string
+ is one long unbroken token (the enrollment key in the URL), and a
+ scrollable-but-not-visibly-scrollable box just clips it at the
+ container edge -- especially inside a narrow grid card, where it
+ reads as broken rather than as "there's more, scroll right". */}
+
+ {command}
+
+
+
+
+ The key in this command works once. To add a second machine, register another
+ node and run its own command there.
+
+
+ );
+}
+
+/**
+ * Reopens the install command for a node that's still pending, outside the
+ * one-time "Add a node" flow.
+ *
+ * Before this, the only place the command ever appeared again was rendered
+ * raw inline in the node's card -- the exact box above, at 1/3 the width of
+ * a wide dialog, where a long unbroken token has nowhere to go. Closing the
+ * registration dialog (or reloading the page) lost access to it entirely,
+ * short of retiring the node and starting over. This gives it a permanent,
+ * properly-sized home instead.
+ */
+function PendingNodeInstall({ node }: { node: CommunityNode }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+ );
+}
+
+const EMPTY_FORM = { name: "", country: "", city: "" };
+
+export function MyNodesPage() {
+ const { toast } = useToast();
+ const [nodes, setNodes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [submitting, setSubmitting] = useState(false);
+ const [justRegistered, setJustRegistered] = useState(null);
+
+ const load = useCallback(async (isPoll = false) => {
+ if (isPoll) setRefreshing(true);
+ try {
+ setNodes(await communityApi.listMyNodes());
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load nodes");
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ // Status is derived from heartbeats, so a node can go offline while this
+ // page is open. Poll rather than leaving a stale badge on screen.
+ const timer = setInterval(() => load(true), 30000);
+ return () => clearInterval(timer);
+ }, [load]);
+
+ const countryValid = /^[A-Za-z]{2}$/.test(form.country.trim());
+ const canSubmit = form.name.trim().length > 0 && countryValid && !submitting;
+
+ const register = async () => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ try {
+ const node = await communityApi.registerNode({
+ name: form.name.trim(),
+ country: form.country.trim().toUpperCase(),
+ city: form.city.trim() || undefined,
+ });
+ setJustRegistered(node);
+ setForm(EMPTY_FORM);
+ await load();
+ } catch (err) {
+ toast({
+ title: "Could not register node",
+ description: err instanceof Error ? err.message : String(err),
+ variant: "destructive",
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const retire = async (node: CommunityNode) => {
+ try {
+ await communityApi.retireNode(node.id);
+ toast({ title: `${node.name} retired` });
+ await load();
+ } catch (err) {
+ toast({
+ title: "Could not retire node",
+ description: err instanceof Error ? err.message : String(err),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const active = nodes.filter((n) => n.status !== "retired");
+ const online = active.filter((n) => n.status === "online").length;
+
+ return (
+
+ Last heartbeat {relativeTime(node.last_heartbeat_at)}
+
+ )}
+
+ {/* A pending card would otherwise be two lines and a button.
+ Say what "pending" means so it reads as a step, not a fault. */}
+ {node.status === "pending" && (
+
+ Registered, waiting on its first check-in. Run the install command
+ on the machine to finish enrollment.
+
+ )}
+
+ {/* Heartbeating over HTTP (community's own liveness signal) and
+ having joined core's compute plane (able to run workloads)
+ are two different things that happen a few seconds apart at
+ worker startup. This is that gap, not a missing feature --
+ it should clear itself within a minute of the agent starting. */}
+ {node.status === "online" && !node.core_workload_host_id && (
+
+ Enrolled and reachable. Still joining the compute plane —
+ this usually clears within a minute of the agent starting.
+
+ {/* Everything else about this machine -- rental history, who
+ is running what on it, live utilisation over time -- lives
+ on its own page. The card stays a summary. */}
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/packages/shared/src/components/community/admin/AdminAllNodesPage.tsx b/packages/shared/src/components/community/admin/AdminAllNodesPage.tsx
new file mode 100644
index 0000000..7d58968
--- /dev/null
+++ b/packages/shared/src/components/community/admin/AdminAllNodesPage.tsx
@@ -0,0 +1,167 @@
+/**
+ * Every node on the platform, whoever owns it.
+ *
+ * The provider's own /provider/nodes shows the same machines from the other
+ * side -- theirs, with the install command and the retire button. This one is
+ * cross-owner and read-only: an operator diagnoses here, they do not withdraw
+ * somebody else's hardware from under a paying customer.
+ */
+import { useMemo, useState } from "react";
+import { Activity, Cpu, Search } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { communityApi } from "@/services/communityApi";
+import type { AdminWorkloadRow, CommunityNode } from "@/services/communityApi";
+import {
+ NodeStatusBadge, NodeUsageBar, nodeLocation, nodeSpecSummary, relativeTime,
+} from "../nodeDisplay";
+import { LoadFailed, PageHeader, TableSkeleton, usePolledData } from "./shared";
+
+interface Data {
+ nodes: CommunityNode[];
+ workloads: AdminWorkloadRow[];
+}
+
+export function AdminAllNodesPage() {
+ const [query, setQuery] = useState("");
+
+ const { data, loading, refreshing, error, reload } = usePolledData(async () => {
+ const [nodes, workloads] = await Promise.all([
+ communityApi.listAllNodes(),
+ communityApi.adminWorkloads(),
+ ]);
+ return { nodes, workloads };
+ });
+
+ // Memoised, not `data?.nodes ?? []` inline: a fresh [] on every render
+ // would change the filter's dependency every time and defeat its memo.
+ const nodes = useMemo(() => data?.nodes ?? [], [data?.nodes]);
+
+ // Keyed by core host id, because that is the only join core gives us
+ // between a workload and a machine (see community/app/routes/admin_routes.py).
+ const workloadsByHost = useMemo(() => {
+ const acc: Record = {};
+ for (const w of data?.workloads ?? []) {
+ if (!w.host_id) continue;
+ (acc[w.host_id] ??= []).push(w);
+ }
+ return acc;
+ }, [data?.workloads]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return nodes;
+ return nodes.filter((n) =>
+ [n.name, n.owner_email, n.country, n.city, n.status]
+ .some((field) => (field ?? "").toLowerCase().includes(q)));
+ }, [nodes, query]);
+
+ return (
+
+ ) : null}
+
+ {/* The gap between "registered" and "actually schedulable" is the one
+ number that most often explains a support ticket, so it gets said in
+ words rather than left as a difference between two tiles. */}
+ {overview && overview.nodes_total > overview.nodes_enrolled_into_core && (
+
+
+ {overview.nodes_total - overview.nodes_enrolled_into_core} node
+ {overview.nodes_total - overview.nodes_enrolled_into_core === 1 ? " has" : "s have"} not
+ finished joining the compute plane, so nothing can be scheduled on them yet.{" "}
+ See which.
+
+
+ )}
+
+
+
+ Recent leases
+
+ All leases
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/community/admin/AdminUsersPage.tsx b/packages/shared/src/components/community/admin/AdminUsersPage.tsx
new file mode 100644
index 0000000..18b80b7
--- /dev/null
+++ b/packages/shared/src/components/community/admin/AdminUsersPage.tsx
@@ -0,0 +1,110 @@
+/**
+ * Everyone on the platform, and what they hold.
+ *
+ * There is no signup: an account exists the moment a request names one
+ * (community/app/auth_utils.py), so this is the only place a full roster is
+ * visible at all. A row is not "a provider" or "a customer" -- the same
+ * person can lend one machine and rent another, which is why the counts are
+ * two columns rather than a role.
+ */
+import { useMemo, useState } from "react";
+import { Search } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { communityApi } from "@/services/communityApi";
+import type { AdminUserRow } from "@/services/communityApi";
+import { LoadFailed, PageHeader, TableSkeleton, usePolledData } from "./shared";
+
+function joined(iso: string | null): string {
+ if (!iso) return "—";
+ return new Date(iso.endsWith("Z") ? iso : `${iso}Z`).toLocaleDateString();
+}
+
+export function AdminUsersPage() {
+ const [query, setQuery] = useState("");
+ const { data, loading, refreshing, error, reload } =
+ usePolledData(() => communityApi.adminUsers());
+
+ // Memoised so the filter below keeps a stable dependency; `data ?? []`
+ // inline hands it a new array on every render.
+ const users = useMemo(() => data ?? [], [data]);
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return users;
+ return users.filter((u) => u.email.toLowerCase().includes(q));
+ }, [users, query]);
+
+ const providers = users.filter((u) => u.nodes_owned > 0).length;
+ const customers = users.filter((u) => u.active_leases > 0).length;
+
+ return (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx b/packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx
new file mode 100644
index 0000000..4069031
--- /dev/null
+++ b/packages/shared/src/components/community/admin/AdminWorkloadsPage.tsx
@@ -0,0 +1,120 @@
+/**
+ * Every workload on the platform, attributed both ways.
+ *
+ * This join does not exist anywhere else. Core stores a `tenant_id` and a
+ * `workload_host_id` and has never heard of a person; the community plane is
+ * where those two ids become "this VM, run by this customer, on that
+ * provider's machine" (community/app/routes/admin_routes.py).
+ *
+ * First-party workloads show up too, with no node and no customer, rather
+ * than being filtered out -- an operator asking "what is running" wants the
+ * whole answer, and a row that silently vanished would be the one worth
+ * seeing.
+ */
+import { useMemo, useState } from "react";
+import { Search } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { communityApi } from "@/services/communityApi";
+import type { AdminWorkloadRow } from "@/services/communityApi";
+import { LoadFailed, PageHeader, TableSkeleton, WorkloadStatusBadge, usePolledData } from "./shared";
+
+export function AdminWorkloadsPage() {
+ const [query, setQuery] = useState("");
+ const { data, loading, refreshing, error, reload } =
+ usePolledData(() => communityApi.adminWorkloads());
+
+ // Memoised so the filter below keeps a stable dependency; `data ?? []`
+ // inline hands it a new array on every render.
+ const workloads = useMemo(() => data ?? [], [data]);
+ const onCommunity = workloads.filter((w) => w.is_community_hardware).length;
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return workloads;
+ return workloads.filter((w) =>
+ [w.name, w.type, w.status, w.customer_email, w.node?.name, w.node?.owner_email]
+ .some((field) => (field ?? "").toLowerCase().includes(q)));
+ }, [workloads, query]);
+
+ return (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/shared/src/components/community/admin/shared.tsx b/packages/shared/src/components/community/admin/shared.tsx
new file mode 100644
index 0000000..a07f50d
--- /dev/null
+++ b/packages/shared/src/components/community/admin/shared.tsx
@@ -0,0 +1,167 @@
+/**
+ * Pieces every operator screen uses.
+ *
+ * The operator portal is five focused routes rather than one page that loads
+ * the whole platform: nodes, workloads, users and leases each answer a
+ * different question, and an operator opening "who is renting what" should
+ * not pay for a cross-region workload sweep to get there. What they share is
+ * the chrome -- a header with a refresh, an error banner, a skeleton, a stat
+ * tile -- so that lives here instead of being copied five times.
+ */
+import { ReactNode, useCallback, useEffect, useState } from "react";
+import { RefreshCw, type LucideIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ErrorBanner } from "@/components/ui/error-banner";
+
+/** How often an operator screen re-reads. Slow enough not to hammer core
+ * (every workload sweep is an upstream call), fast enough that a node going
+ * offline shows up while you are still looking at the page. */
+export const OPERATOR_POLL_MS = 30000;
+
+/**
+ * Load once, then poll, with the loading/error bookkeeping every one of
+ * these screens would otherwise repeat.
+ *
+ * `reload` is deliberately not part of the loading flag after the first
+ * fetch: a poll that flips the page back to skeletons every 30 seconds is
+ * worse than a slightly stale table.
+ */
+export function usePolledData(fetcher: () => Promise, deps: unknown[] = []) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async (isPoll = false) => {
+ if (isPoll) setRefreshing(true);
+ try {
+ const result = await fetcher();
+ setData(result);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Request failed");
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+
+ useEffect(() => {
+ load();
+ const timer = setInterval(() => load(true), OPERATOR_POLL_MS);
+ return () => clearInterval(timer);
+ }, [load]);
+
+ return { data, loading, refreshing, error, reload: () => load(true) };
+}
+
+export function PageHeader({
+ title, description, onRefresh, refreshing, actions,
+}: {
+ title: string;
+ description: string;
+ onRefresh?: () => void;
+ refreshing?: boolean;
+ actions?: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/** "3.2 h", "1,204 h" -- hours served, the unit the provider views report in. */
+export function formatHours(hours: number): string {
+ if (hours < 1) return `${Math.round(hours * 60)} min`;
+ if (hours < 10) return `${hours.toFixed(1)} h`;
+ return `${Math.round(hours).toLocaleString()} h`;
+}
diff --git a/packages/shared/src/components/community/nodeDisplay.tsx b/packages/shared/src/components/community/nodeDisplay.tsx
new file mode 100644
index 0000000..8ed3876
--- /dev/null
+++ b/packages/shared/src/components/community/nodeDisplay.tsx
@@ -0,0 +1,112 @@
+/**
+ * How a community node is described on screen, in one place.
+ *
+ * The three node surfaces -- your own list, the operator's, and the overview --
+ * had three copies of this between them, which is how "online" ended up a
+ * different colour depending on which page you were looking at. They render
+ * the same machine, so they read it the same way here.
+ */
+import { Badge } from "@/components/ui/badge";
+import { Progress } from "@/components/ui/progress";
+import { CommunityNode, NodeResourceUsage } from "@/services/communityApi";
+
+/** Colour carries the state before the word is read. */
+export function NodeStatusBadge({ status }: { status: CommunityNode["status"] }) {
+ const tone =
+ status === "online"
+ ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
+ : status === "pending"
+ ? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400"
+ : status === "offline"
+ ? "border-destructive/30 bg-destructive/10 text-destructive"
+ : "border-border bg-muted text-muted-foreground";
+ return (
+
+
+ {status}
+
+ );
+}
+
+export function nodeLocation(node: CommunityNode | undefined): string {
+ if (!node) return "—";
+ return node.city ? `${node.city}, ${node.country}` : node.country;
+}
+
+/** Enrollment-reported hardware, in the order it is usually asked about. */
+export function nodeSpecSummary(node: CommunityNode): string {
+ const { cpu_cores, memory_mb, disk_gb, gpu_count, gpu_model } = node.specs;
+ if (!cpu_cores && !memory_mb) return "Specs are reported by the machine at enrollment";
+ const parts = [`${cpu_cores ?? "?"} cores`, `${Math.round((memory_mb ?? 0) / 1024)} GB RAM`];
+ if (disk_gb) parts.push(`${disk_gb} GB disk`);
+ if (gpu_count) parts.push(`${gpu_count}× ${gpu_model ?? "GPU"}`);
+ return parts.join(" · ");
+}
+
+/** Heartbeats, lease starts -- anything whose age is what matters about it. */
+export function relativeTime(iso: string | null | undefined): string | null {
+ if (!iso) return null;
+ // The community API serialises naive UTC (datetime.utcnow().isoformat()),
+ // which has no zone suffix -- and `new Date("2026-08-30T15:29:45")` is
+ // parsed as *local* time by every browser. Without the Z, a node
+ // heartbeating a second ago reads as hours stale or hours in the future,
+ // depending on which side of UTC the viewer is on.
+ const seconds = Math.max(
+ 0,
+ (Date.now() - new Date(iso.endsWith("Z") ? iso : `${iso}Z`).getTime()) / 1000,
+ );
+ if (seconds < 90) return "just now";
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
+ if (seconds < 86400) return `${Math.round(seconds / 3600)} h ago`;
+ return `${Math.round(seconds / 86400)} d ago`;
+}
+
+export function usagePercent(usage: NodeResourceUsage | undefined): number | null {
+ if (!usage || !usage.total_quantity) return null;
+ return Math.min(100, Math.round((usage.quantity_in_use / usage.total_quantity) * 100));
+}
+
+/** "3.2", "8", "1,024" -- a whole number loses its pointless ".0". */
+function trimNum(n: number): string {
+ const r = Math.round(n * 10) / 10;
+ return (Number.isInteger(r) ? r : Number(r.toFixed(1))).toLocaleString();
+}
+
+/**
+ * Fold a raw quantity up its unit ladder so a bar reads "13 / 32 GB", never
+ * "12800.0 / 32768.0 MB". core reports memory in MB and disk in GB; past a
+ * thousand of either, the next unit up is the one a person actually reads in.
+ */
+function scaleUsage(inUse: number, total: number, unit: string): [string, string, string] {
+ const ladder = unit === "MB" ? ["MB", "GB", "TB"] : unit === "GB" ? ["GB", "TB"] : [unit];
+ let i = 0;
+ while (i < ladder.length - 1 && total >= 1024) {
+ inUse /= 1024;
+ total /= 1024;
+ i += 1;
+ }
+ return [trimNum(inUse), trimNum(total), ladder[i]];
+}
+
+export function NodeUsageBar({
+ label, usage, unit,
+}: {
+ label: string;
+ usage: NodeResourceUsage | undefined;
+ unit: string;
+}) {
+ const pct = usagePercent(usage);
+ if (pct === null || !usage) return null;
+ const [used, cap, scaledUnit] = scaleUsage(usage.quantity_in_use, usage.total_quantity, unit);
+ return (
+
+
+ {error && load(true)} isRetrying={refreshing} />}
+
+ {/* A registered-but-not-installed node is the single most common reason
+ a provider sees nothing happening, so it leads with the fix. */}
+ {node.status === "pending" && node.install_command && (
+
+
+
+
+ This machine has not been installed yet
+
+
+ Run this on the machine itself. It joins the compute plane on its own and appears
+ on the marketplace once it reports in.
+
+
+
+
+ {node.install_command}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ Health & rental
+
+
+
+ Right now
+
+ {activeLease ? (
+ <>Rented by {activeLease.customer_email ?? "someone"}>
+ ) : node.status === "online" ? (
+ "Idle · listed on the marketplace"
+ ) : (
+ "Not rentable"
+ )}
+
+
+
+ Compute plane
+ {node.core_workload_host_id ? (
+
+
+ Joined
+
+ ) : (
+ Not joined
+ )}
+
+ {node.status === "online" && detail ? (
+
+
+
+
+
+ ) : (
+
+ {node.status === "online"
+ ? "Live usage is not being reported right now."
+ : "Live usage appears once this machine is online."}
+
+ )}
+
+
+
+
+
+ Running on this machine
+
+ Names and sizes only. What a customer's workload actually contains is theirs.
+
+
+
+
+
+
+ Name
+ Type
+ Status
+ Customer
+
+
+
+ {workloads.length === 0 && (
+
+
+ {node.core_workload_host_id
+ ? "Nothing is running on this machine."
+ : "This machine has not joined the compute plane yet."}
+
+
+ )}
+ {workloads.map((w) => (
+
+
+ {w.name}
+ {w.is_sidecar && (
+ platform
+ )}
+
+ {w.type ?? "—"}
+
+ {w.customer_email ?? "—"}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/shared/src/components/community/provider/ProviderOverviewPage.tsx b/packages/shared/src/components/community/provider/ProviderOverviewPage.tsx
new file mode 100644
index 0000000..f71b7d3
--- /dev/null
+++ b/packages/shared/src/components/community/provider/ProviderOverviewPage.tsx
@@ -0,0 +1,165 @@
+/**
+ * The provider's front page: is my hardware working, and is anyone using it.
+ *
+ * Reports hours served rather than money. There is no billing plane and no
+ * price on a node -- migration 0003 dropped `hourly_price` -- so a currency
+ * figure here would be invented. Hours is the real measured quantity a payout
+ * would be computed from, and the page says so rather than leaving a provider
+ * to assume a number means dollars.
+ */
+import { Link } from "react-router-dom";
+import { Clock, Server, Signal, Users } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { communityApi } from "@/services/communityApi";
+import type { ProviderSummary } from "@/services/communityApi";
+import { relativeTime } from "../nodeDisplay";
+import {
+ formatHours, LoadFailed, PageHeader, StatCard, TableSkeleton, usePolledData,
+} from "../admin/shared";
+
+export function ProviderOverviewPage() {
+ const { data, loading, refreshing, error, reload } =
+ usePolledData(() => communityApi.providerSummary());
+
+ return (
+
+
+
+ 0
+ ? `${formatHours(data.hours_served_active)} on live leases`
+ : "no live lease"}
+ />
+
+
+
+ {/* Said plainly rather than implied by a currency symbol nobody set --
+ in the same thin-notice card the operator overview uses for its
+ one caveat, rather than as a line of text floating on its own. */}
+ {data.hours_served_total > 0 && (
+
+
+ Hours are measured lease time. Payouts are not calculated here — there is no
+ billing plane yet and no price set on a node.
+
+
+ )}
+
+ {data.nodes_total === 0 && (
+
+
+ You have not lent a machine yet
+
+ Register a machine and run the one-line installer it gives you. It joins the
+ compute plane on its own and appears on the marketplace once it is online.
+
+
+
+
+
+
+ )}
+
+ {data.nodes_pending > 0 && (
+
+
+ {data.nodes_pending} machine{data.nodes_pending === 1 ? "" : "s"} registered but not
+ yet installed. Nothing can be scheduled on{" "}
+ {data.nodes_pending === 1 ? "it" : "them"} until the installer has run.{" "}
+
+ Get the install command
+ .
+
+
+ )}
+
+
+
+ Recent rentals
+ Who has taken your machines, and for how long.
+
+
+