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:
@@ -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,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"
|
||||
@@ -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>
|
||||
@@ -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": "*" }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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") } },
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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: {} } };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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") } },
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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": "*" }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,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 & 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user