better/compute
Clients

Console

The Next.js console runs Better IAM and Better Compute on one origin, built on console-kit, framework-free stores with live updates and permission-aware actions.

The console is a web UI for people: tenants reserve GPUs, create instances and open terminals; platform operators watch nodes, rollouts and fleet drift. It comes in two layers you can use separately:

  • apps/console, a Next.js 15 app that runs Better IAM and Better Compute in one process, on one origin. It is private to the repository (not published) and serves as the reference deployment.
  • @better-compute/console-kit, the framework-free view models the console's pages are built on: resource stores, the live event stream, permission-aware actions, forms, charts and a terminal. Use it to build your own UI in React, Solid, Vue or anything else.

Ahead of its phase

PLAN.md schedules the console for v0.5. Both layers are written and tested today (tests/console-kit*.test.ts), but the console's configuration is a development-first reference, not a hardened product.

One origin, one sign-in

Better IAM is mounted at /api/iam and Better Compute at /api/compute on the same origin, so the host-only IAM session cookie authenticates both. Compute is built with acceptIamCookie: true: it accepts the cookie only on POSTs that carry X-Better-Compute: 1 and an Origin equal to the console's own. The compute IAM bridge installs compute's action catalog in Better IAM and serves compute's decisions from its projection, as in any embedded deployment.

server.mjs runs Next.js plus the one thing a route handler cannot do: the WebSocket upgrade of /api/compute/tunnels/{ticket} for browser terminals and node consoles, served by handleTunnel and relayed over mTLS to the gateway that holds the node's session. Tunnels open only from the console's own origin, and a signed-in browser can only use its own person's tickets.

Run it

cd apps/console
node server.mjs --dev      # or: pnpm dev

--dev sets CONSOLE_DEV=1, the only mode in which the console generates secrets for itself: the session secret, the exec ticket key, the break-glass and sandbox SSH CA keys (under .console-data/) and, on the first start, the root administrator's password, printed once. It also allows gateways without TLS. Open http://localhost:3100/login and sign in as admin@console.local. Members of an organization sign in at /login?org=<slug>.

For production, build and start without --dev; every secret then comes from the environment:

cd apps/console
pnpm build                   # next build
CONSOLE_ORIGIN=https://console.example.com CONSOLE_SECRET=… CONSOLE_ADMIN_PASSWORD=… \
BETTER_COMPUTE_DATABASE_URL=postgres://… CONSOLE_IAM_DATABASE_URL=postgres://… \
node server.mjs --prod       # or: pnpm start

Run it from apps/console: the stack file and the data directory are resolved against the working directory.

VariableWith --devOtherwise
CONSOLE_ORIGINhttp://localhost:3100set it to the public origin: cookie, origin and tunnel checks compare against it
CONSOLE_SECRETgenerated into .console-data/secretrequired, at least 32 random characters
CONSOLE_DATA_DIR.console-datathe same
CONSOLE_IAM_DATABASE_URLa libSQL file, .console-data/iam.dbPostgreSQL for Better IAM when set
BETTER_COMPUTE_DATABASE_URLPGlite in .console-data/computepostgres://… for compute
CONSOLE_ADMIN_EMAIL, CONSOLE_ADMIN_PASSWORDadmin@console.local, a generated passwordthe password is required on the first start
CONSOLE_EXEC_TICKET_KEY_FILEa generated exec-ticket.pemunset: tunnels are off
CONSOLE_SSH_CA_KEY_FILEa generated ssh-ca.pemunset: no break-glass SSH
CONSOLE_SANDBOX_SSH_CA_KEY_FILEa generated sandbox-ssh-ca.pem (Ed25519)unset: no sandboxes.exec (UNSUPPORTED), and sandbox guests trust no CA
CONSOLE_GATEWAY_TLS_CERT, _KEY, _CAnone needed (h2c gateways)all three or none; tunnels need them
CONSOLE_AUDIT_KEY_FILEa generated audit-checkpoint.pem (P-256 PKCS#8)unset: no audit checkpoints; every replica shares the key
CONSOLE_RELEASE_PINS, CONSOLE_DOWNLOADS_URL, CONSOLE_RELEASE_THRESHOLDunset: no rollout moduleset the first two to mount rollouts
CONSOLE_GATEWAY_URL, CONSOLE_GATEWAY_SERVER_NAMEunset: no gateway sectionpublished in /.well-known/better-compute as gateway for bcd join --server; the console has no PKI bundle, so no serving-CA pin is published
CONSOLE_CONFIGconsole.config.mjsanother stack file
PORT, HOSTNAME3100, localhostthe same

Every replica and gateway must share the exec ticket key, and nodes pin its public key and the SSH CA's, so production supplies the key files rather than generating them. The sandbox SSH CA is a separate key, so a sandbox certificate never opens a node and a break-glass one never opens a sandbox; the console passes its public key to vps({ sandboxExec }) so every sandbox guest trusts it, and certifies each guest's host key with it (certifyHostKey), so sandbox exec against the console pins guests instead of trusting them on first use. Every replica must share it too. The stack is built once per process from console.config.mjs (loaded at run time, never bundled): Better IAM, then compute with the vps, fleet and events (webhooks) modules and retentionModule() (the console builds betterCompute() itself, so the CLI's automatic retention does not apply), audit when an audit key is available, and rollout when release pins are set.

Before the workers start, the stack calls installIamHandlers(compute, { bridge, streams: [watches] }), as serve does for host.iam: a revoked delegation or suspended agent closes its tunnels on every gateway (over the console's gateway mTLS) and its /api/compute/watch streams in this process, and revocations drop the IAM caches. See tunnels.

Screens

Every page needs a session; without one the layout redirects to /login. Pages render in the browser over console-kit, for the tenant the session acts in. Platform pages appear only for the root tenant.

PagePathShows and does
Overview/capacity per pool, quota alerts
Reservations/reservations, /reservations/[id]the form with a capacity preview, the earliest start and approvals, optional host CPUs and memory (GiB), and a Kubernetes checkbox only for a pool the tenant owns with policy.kubernetes (4533265); the list and each reservation's actions
Instances/instances, /instances/[id], /instances/[id]/terminal, /instances/[id]/consolethe form with sizes per pool; list, detail, a browser terminal (op exec) and the instance's serial console (op console, 191898b), linked as Open a terminal and Serial console, both decided as compute:instances:exec. bcd refuses instance exec until guests have an agent, so the serial console is the terminal that works on real hosts
Sandboxes/sandboxesa create form over the tenant's templates (each with its flavor and warm pool size) and a lifetime in minutes, capped at the template's maximum; the person's own sandboxes, or everyone's, with state, a countdown to expiry (a warning in the last 5 minutes), +30 min, Snapshot and Delete, and the better-compute sandbox exec line for a shell; templates, with their volumes (attach and detach, dd5555b)
Volumes/volumessandbox volumes (50add82): a create form (pool, name, size in GiB); the person's own volumes, or everyone's (e68aba7), with id, device path, pool, host, size and state; Grow and Delete (below)
Nodes/nodes, /nodes/[id], /nodes/[id]/consolea filterable table; conditions, cordon, uncordon and drain, the node's labels (key, value, source) and taints with forms to set or remove an operator label and add or remove a taint (nodes.label, nodes.taint, gated like the other node actions on compute:nodes:update; keys, values and taints are checked against the API's patterns before the button enables; 3ff9fb3), recent commands, the node console, and the break-glass SSH panel (below). A node in a bare-metal provision state (not adopting or adopted) also gets a Lifecycle panel (0cd1d79): manage, inspect, provide, rescue, unrescue, abort, recover, undeploy and retire, each offered as the provision state machine and IAM (compute:nodes:provision) allow; deploy and power stay in the CLI (metal deploy, metal power), and PXE MAC confirmation has its own page (below)
PXE discovery/discoverya site picker and what the site's agents saw PXE-booting from MACs with no boot entry (metal.discovered): MAC and request count, product UUID, arch and relay, last seen, and what each row is (a candidate for a machine, not enrolled, or an enrolled node); Confirm on a candidate and a form to bind a MAC by hand (metal.confirmMac), each behind a browser confirmation (15c11ee; below)
Batches/batchesdestructive batches held for the approver quorum: pending by default (or approved, expired, cancelled, all), each with its reason, command count, approvals of required, state and the window left; a batch's scope by kind, who approved, the better-compute batch approve line, and Cancel (below)
Pools & capacity/pools, /pools/[id]pools owned or offered, a 7-day booking calendar, join tokens and join requests
Quotas/quotasused of max per pool and resource
Usage & spend/usageusage per meter, day, project and person, with a CSV export
Activity/activitythe live event stream: the last 300 events this session received, filterable by type
Webhooks/webhooksthe tenant's endpoints: create one (URL, event patterns, description) with its signing secret shown once to copy, pause and resume, ping, rotate the secret with a 1 h overlap, recent deliveries with redelivery of dead ones, delete
Rollouts (root)/admin/rolloutsdaemon rollouts wave by wave; pause, resume and abort
Fleet drift (root)/admin/fleetdrift of the fleet from its applied FleetConfig

A "live" badge in the navigation shows the event stream's state. While the stream is down, the stores a page has loaded refresh every 20 seconds instead, and only while the tab is visible.

Buttons follow two checks. A button is enabled only when the item's state allows the action (a released reservation offers nothing) and Better IAM would allow it for the signed-in person. The second check is advisory: a server action batches up to 50 checks through Better IAM's can, and the compute API still decides the real call. A delegated call held back for approval shows "awaiting approval" with the confirmation id.

Actions that destroy or release something ask first, naming what happens: draining a node, stopping or deleting an instance, releasing or cancelling a reservation (d40af29, 0919f86), a bare-metal node's undeploy, retire, abort and provide (0cd1d79), and binding a machine's PXE MAC, after which it boots from that MAC (15c11ee).

Sandboxes

The Sandboxes page (createSandboxesView in console-kit) works on sandboxes of the session's tenant. With no template, the create form says an administrator creates them. Create sends sandboxes.create with a fresh console-… idempotency key and waits up to 20 seconds for the sandbox to leave starting. The list shows the person's own sandboxes, or, with Everyone's sandboxes, the tenant's (scope: 'all'), each with its template name, warm and the snapshot it was restored from. A memory restore whose identityRebound is false (console-kit's SandboxRow carries it) shows a warning that it still answers to its source's SSH identity, so exec waits until the source and sibling restores are gone or the host gives it its own (9958865). The countdown ticks every second from expiresAt and reads expired at zero.

Its buttons are not gated by IAM checks in advance: the call is decided and an error is shown in place. +30 min is sandboxes.extend with by of 30 minutes, Snapshot takes a memory snapshot that goes with the sandbox, and Delete asks for confirmation first. A shell is not opened in the browser: the page shows the better-compute sandbox exec sbx_… command, which runs OpenSSH through a tunnel. The console's own stack configures the sandbox SSH CA but no snapshot store, so Snapshot answers INVALID_CONFIG there until you give vps({ snapshots }) a store in console.config.mjs. On that answer the page says snapshots are not configured on this deployment and hides the button for the rest of the session.

A Templates section serves tenant administrators (compute:sandboxes:manage; the calls are decided, not the section hidden). It lists the tenant's templates with pool, flavor, image, lifetime and warm pool, with Warm +1, Warm −1 (sandboxTemplates.update of warm) and delete, and a form that creates one from a name, pool, flavor, image, warm minimum and longest lifetime in hours. console-kit's SandboxesView exposes the same as createTemplate, updateTemplate and deleteTemplate. Templates can also come from better-compute sandbox-template create or a FleetConfig document.

Since dd5555b the templates table also shows each template's volumes (volume name, mount path, (read-only)), each with Detach, and an Attach a volume form picks a template, a ready volume of that template's pool, a mount path (default /data; it must start with /) and Read-only. Every sandbox from the template then mounts the volume and runs on the volume's host. Both send the template's whole volume list through sandboxTemplates.update. Warm +1 is disabled for a template with volumes, since the server keeps no warm pool beside volumes. The volume picker lists everyone's volumes (sandboxVolumes.list with scope: 'all'); without the right to list them it is empty.

Volumes

The Volumes page (50add82) works on the session tenant's sandbox volumes, an agent's persistent memory, through the API directly (sandboxVolumes.*; there is no console-kit view for it). The create form takes a pool from the tenant's pools, a name (lowercase letters, digits, ., _ and -, at most 63, checked before the button enables) and a size from 1 to 16,384 GiB, and sends sandboxVolumes.create with a fresh console-… idempotency key. The list shows the person's own volumes (scope: 'mine'), or, with Everyone's volumes, the tenant's (scope: 'all', e68aba7), each with its id and /dev/disk/by-id/virtio-<serial> path, pool and host ("not placed yet" without one), size and state. For a ready volume, Grow asks for the new size and calls sandboxVolumes.resize only with a larger one, and Delete asks for confirmation, warning that the data is wiped, before sandboxVolumes.delete. As on the Sandboxes page, the buttons are not gated by IAM checks in advance; the call is decided and an error is shown in place. A volume is attached to a template on the Sandboxes page's Templates section (dd5555b, above), or with better-compute sandbox-template update --volumes, the API or FleetConfig.

PXE discovery

The PXE discovery page (/discovery, 15c11ee) is the console's side of discovering new machines: a machine whose BMC cannot name its NICs (IPMI), enrolled with only its product UUID, waits in metal.ensureRamdisk until an operator confirms its PXE MAC. The page is built on console-kit's createPxeDiscoveryView.

A site picker lists the session tenant's sites (sites/list, the first one selected), and the table shows what that site's agents saw PXE-booting from MACs with no boot entry in the last 24 hours (metal.discovered; only the tenant that owns the site sees it). Refresh reloads it. pxeCandidates orders the rows by what needs a decision first: candidates, then unknown machines, then enrolled ones, newest first within each.

ColumnShows
MACthe MAC, and how many requests it sent
Product UUIDthe SMBIOS UUID from DHCP, or a dash
Arch / relaythe client architecture, and via <relays> for a relayed request or agent segment for one on the agent's own segment
Last seenjust now, N min ago or N h ago (seenAgo)
Machinewhat the row is to the operator: candidate for <node>, not enrolled, or the enrolled node's id

Nothing on the page binds a MAC by itself, since discovery data is unauthenticated DHCP. A candidate row (the MAC metal saw under the machine's UUID right after power-cycling it) has a Confirm button. It first asks, in a browser confirmation, "Bind <mac> as the PXE MAC of <node>? The machine will then boot from it.", and only then calls metal.confirmMac and reloads the list. Unknown and enrolled rows have no button: an unknown machine is never answered or enrolled from here.

Bind a MAC by hand serves a machine enrolled without MACs whose PXE MAC you know from its label or its BMC. It takes a node id (it must start with node_) and a MAC; normalizeMac accepts upper or lower case and - or : as the separator, and the button stays disabled until both are well formed. It goes through the same browser confirmation and metal.confirmMac call. As with the CLI's metal confirm-mac, the call is decided as compute:nodes:provision on the node and audited as node.pxe-mac.confirmed; the buttons are not gated by IAM checks in advance, so a refusal (for example CONFLICT for a MAC another machine of the site has, or INVALID_STATE for a node that already has MACs) is shown in place.

Destructive batches

The Batches page (createBatchesView in console-kit, b24d043) shows the tenant's destructive batches: fleet-wide reimages, wipes and power actions held until enough approvers sign their scope. It lists pending batches by default, with a filter for approved, expired, cancelled or all, and counts down each pending batch's window from notAfter. Each row shows its reason and who created it; a batch an agent proposed shows a warning line instead, "Proposed by agent X for Y (delegation D): the reason is the agent's own text" (bd80209, from createdByAgent and delegationId).

Details loads the batch (commandBatches.get) and its whole scope (commandBatches.scope, paged 5,000 at a time and capped at 20,000 entries, with a warning when cut short), grouped by command kind. Subjects read as a person would: a node id, BMC 10.0.0.7 (via node_site) for a BMC reached through a site agent, or HTTP to node_…. The panel also shows the scope root, the approvals so far (key id, approver and time) and the member commands by state. Cancel asks for confirmation and calls commandBatches.cancel, so none of the batch's commands run.

The page cannot approve. A browser never holds an approver key, so for a pending batch it shows the command for the approver's terminal, better-compute batch approve --batch <id> --key <approver key>, where the CLI lists the scope, recomputes the root from it and signs. Reading needs compute:batches:read and cancelling compute:batches:approve, both decided when called.

Break-glass SSH

A node's page carries the Break-glass SSH panel for SSH certificates, for platform administrators. It takes your public key (a pasted private key, several lines or an unsupported key type is refused before anything is sent), the account (default root) and a lifetime of 5, 15, 30 or 60 minutes, and issues a certificate with nodes.issueSshCert; the certificate is shown once, with a copy button and the commands to save it as <key>-cert.pub and connect. A session without MFA is told to sign in again with a second factor.

Below it, Who holds access lists the node's certificates from nodes.listSshCerts: holder, principal, issue and expiry times, and state (active, expired or revoked, with the revocation reason), with Include expired to show those too. Each active certificate has a Revoke button, which asks for confirmation and calls nodes.revokeSshCert with the reason revoked from the console.

console-kit

@better-compute/console-kit depends only on the typed client and core. Every store has subscribe(listener) and get(), the contract of React's useSyncExternalStore, so a framework adapter is a few lines.

import { createComputeClient } from '@better-compute/client';
import { createPermissions, describeAction, startLiveConsole } from '@better-compute/console-kit';

const client = createComputeClient({ baseURL, token: () => session.token });

const live = await startLiveConsole(client, {
  tenantId,
  watchUrl: `${baseURL}/watch`, // wherever your host mounts handleWatch
  headers: () => ({ authorization: `Bearer ${session.token}` }),
  permissions: createPermissions({ authorize: (checks) => iamAuthorizeMany(checks) }),
});

live.reservations.subscribe(() => render(live.reservations.get()));
live.connection.subscribe(() => showBadge(live.connection.get().status));
AreaExportsDoes
StorescreateConsoleResources, createListResource, createValueResource, createStorenodes, pools, reservations, instances and capacity for one tenant; lists page with loadMore(), a stale response never overwrites a newer one, and an error keeps the last good items
LivewatchEvents, startLiveConsole, matchesType, parseSseBlockServer-Sent Events over fetch, resumed with Last-Event-ID; each event re-reads the item it names
ActionsdescribeAction, actionsFor, createPermissionsmap a button to its IAM action and resource from core's API surface; batch checks (50 per call) in one tick and cache answers for 10 s
Detail pagescreateDetail, applicableone reservation, node, instance or pool with each action gated by state and by IAM; run(path) answers done or confirmation-required
FormscreateReservationForm, validateReservation, createInstanceForm, validateInstancevalidation that mirrors the server, a debounced capacity.preview, one idempotency key per attempt kept across retries and confirmations. Since e833433 the reservation form also takes vcpu (0 to 4096) and memoryGiB (0 to 65,536), sent as resources (left out: the pool's per-GPU share), and kubernetes for binding: 'kubernetes', and the preview books them too. The console app's reservation page shows them since 4533265, with the Kubernetes checkbox only for a pool the tenant owns that has policy.kubernetes
ViewscapacityChart, quotaView, createQuotaView, timelineView, earliestFit, usageView, usageCsvstacked capacity bars, quota meters (warning from 80 %), the booking calendar and earliest free window, usage and estimated spend
AdmincreateRolloutView, rolloutProgress, createFleetDriftView, nodeTablerollout waves, fleet drift by kind, a filterable, stably sorted node table
SandboxescreateSandboxesView, timeLeft, sandboxExecCommand, withTemplateVolume, maxTemplateVolumesa tenant's sandboxes (scope mine or all) and templates, detail, create, extend, snapshot and remove, each refreshing the list; the remaining lifetime as 12m 03s or 1h 05m, urgent in the last 5 minutes; the CLI line that opens a shell. Since dd5555b a SandboxTemplateRow carries its volumes (volumeId, mountPath, readOnly), TemplateFields.volumes replaces a template's whole list, volumes() lists the tenant's volumes a template can attach, and withTemplateVolume(current, { add } or { remove }) returns the new list, refusing a second volume at the same mount path or more than maxTemplateVolumes (8) before anything is sent
BatchescreateBatchesView, scopeByKind, describeSubject, batchApproveCommanda tenant's destructive batches over commandBatches (filter(state or 'all'), default pending), detail, scope(batchId, max?) (every leaf, paged, at most 20,000 by default, stopping on a page that does not advance; truncated when capped) and cancel; the scope grouped by kind with sorted subjects; a subject as text; the approver's CLI line
PXE discoverycreatePxeDiscoveryView, pxeCandidates, seenAgo, normalizeMacone site's metal.discovered list ({ siteId, machines }, each a DiscoveredMachineRow), site(siteId) to switch sites, and confirmMac(nodeId, mac), which normalizes the MAC (throwing before any call for one that is not a MAC), calls metal.confirmMac and reloads the list; the rows as { kind, row } with kind candidate, unknown or enrolled, in that order, newest first; a last-seen time as text; a MAC as the API takes it (lower case, colons) or undefined (15c11ee)
SSH accesscreateSshAccessView, checkSshPublicKey, sshUsageone node's break-glass certificates: issue (held in view.issued until dismissed), revoke, showExpired; a public key check that refuses private keys; the commands to use a certificate
WebhookscreateWebhooksView, deliverySummarya tenant's webhooks over the webhooks group (list, create, pause, rotate, delete, ping, deliveries, redeliver, event types); the secret from create or rotate sits in view.secret until dismissSecret() and is never stored or re-read; delivery counts by state and the latest failure
Break-glass SSHcreateSshAccessView, checkSshPublicKey, sshUsageone node's certificates over nodes.listSshCerts (expired ones with showExpired(true)), issue({ publicKey, account?, minutes? }) (1 to 60 minutes, 15 by default) and revoke(serial, reason?); a pasted private key, a multi-line paste or a key type other than Ed25519 or ECDSA P-256 is refused before anything is sent; the issued certificate sits in issued until dismissIssued(), and sshUsage gives the commands to use it. The console's node page uses it
TerminalcreateTerminalSession, createScreen, keyInputtunnels.open, then the WebSocket (an instance with op exec, ssh or console for its serial console, or a node's console); a VT100/xterm screen model with scrollback, no DOM
ReactbindReact, and @better-compute/console-kit/reactuseStore, useList, usePermission, useDetail, useAction

Live updates

watchEvents({ url, types?, lastEventId?, headers?, onEvent, onStatus? }) reads the stream with fetch rather than EventSource, so it can send an Authorization header (or, in the console, X-Better-Compute: 1). It remembers the id: of the last event and sends it as Last-Event-ID when it reconnects, so it resumes exactly after that event. It reconnects after the server's retry: delay (2 s by default) when the server closes the stream, and with doubling backoff, capped at maxBackoffMs (60 s by default), after a failure. onStatus reports connecting, open, retrying and stopped.

applyEvent routes each event by its subject: reservation/…, node/… and instance/… re-read that one item (and drop it on NOT_FOUND or ACCESS_DENIED), and pool/… refreshes the pools. Reservation, pool and node events (except node.status) also refresh the capacity summary, debounced by 250 ms. startLiveConsole does all of this in one call and clears cached permission answers after events whose type starts with iam., binding., delegation., agent. or project.members. See watch for the server side.

Permission-aware actions

const release = describeAction('reservations', 'release', reservation.id);
// { path: 'reservations/release', action: 'compute:reservations:release',
//   resource: { type: 'compute-reservation', id: 'rsv_…' }, kind: 'mutation' }
const allowed = await permissions.can(release);

describeAction throws for a method outside the API table, and actionsFor(kind, id) lists every mutation on a resource kind, optionally limited to the phases and modules your deployment runs. createPermissions takes your authorize(checks), usually Better IAM's authorizeMany for the caller's tenant behind a server endpoint. A failed batch rejects its checks and leaves them unanswered; it never answers "denied", so a button stays disabled rather than lying. The server still decides every call, so a stale "allowed" only ever ends in a clean ACCESS_DENIED.

React

import type { LiveConsole } from '@better-compute/console-kit';
import { useList } from '@better-compute/console-kit/react';

function Reservations({ live }: { live: LiveConsole }) {
  const { items, status } = useList(live.reservations);
  if (status === 'loading') return <p>Loading</p>;
  return <ul>{items.map((item) => <li key={item.id}>{item.id}: {String(item.state)}</li>)}</ul>;
}

The /react entry binds the hooks to the app's react, an optional peer (React 19). Apps that bring their own React build, or preact/compat, call bindReact(React) from the main entry instead. useList and useDetail load on first use; usePermission returns undefined while IAM answers (render the button disabled), then true or false; useAction(detail, path) returns run, pending, and the outcome or error. The hooks use useSyncExternalStore with the same snapshot on the server, so they render during SSR.

Related: TypeScript client, tunnels, delegated confirmation.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page