mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
The web app's page held 145 lines that were not about being a page: read the registry, fold sessions and sandboxes into machines, mint a url per pane, poll on the plane's own clock. With three hosts for the workspace that is three answers to how often a machine goes stale, so it is a hook now and the page is 22 lines. What a host still owns is the token and where its cloud is. Each gets the token its own way — a browser mints it with PKCE, the desktop already holds the signed-in user's — and the hook has no business knowing which. TerminalHost had two definitions after the first draft of this, so it moved to the pure vocabulary both sides read. A lib module importing a component module would invert the layering, and a second copy of the type is two answers to what a machine is. One defect fell out of the move: the signed-out screen showed the REGISTRY's error, which is always null when there is no token — so a refused sign-in said nothing at all. The two failures have different remedies and now have different states. Co-authored-by: Hanzo Dev <[email protected]>
186 lines
6.7 KiB
TypeScript
186 lines
6.7 KiB
TypeScript
/**
|
|
* The machines you have, ready to hand to a workspace.
|
|
*
|
|
* This is the wiring between a control plane and the `Workspace` component, and
|
|
* it lived in the web app's page — which was fine while there was one host.
|
|
* There are three now (tabs.hanzo.ai, the desktop shell, hanzo.ai), and the
|
|
* wiring is the same React in each: read the registry, fold sessions and
|
|
* sandboxes into machines, mint a url per pane, poll on the plane's own clock.
|
|
* Copied three times it would be three sets of answers to how often a machine
|
|
* goes stale.
|
|
*
|
|
* WHAT A HOST STILL OWNS is the token and where its cloud is. Everything else
|
|
* is here. The token is passed in rather than fetched because each host gets it
|
|
* its own way — a browser mints it with PKCE, the desktop already holds the
|
|
* signed-in user's — and this module has no business knowing which.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
API,
|
|
type Class,
|
|
type Machine,
|
|
type SandboxMachine,
|
|
type Session,
|
|
createSandbox,
|
|
frameUrl,
|
|
grant,
|
|
machineName,
|
|
machines,
|
|
sandboxes,
|
|
sessions,
|
|
} from './api';
|
|
import { type Binding, type TerminalHost, shellUrl } from './panes';
|
|
|
|
/** Machines heartbeat every 30s and the plane calls one stale after 90s, so
|
|
* polling faster than the fact changes would only cost requests. */
|
|
const BEAT = 30_000;
|
|
|
|
export interface Fleet {
|
|
/** The machines, folded. Empty until the first read lands. */
|
|
hosts: TerminalHost[];
|
|
/** Whether a read has landed at all — which is a different fact from having
|
|
* no machines, and the one a caller needs to avoid claiming the second. */
|
|
read: boolean;
|
|
/** Why the registry could not be read, if it could not. */
|
|
error: string | null;
|
|
/** A fresh url for a pane. The workspace holds no token, so this does. */
|
|
mint: (host: TerminalHost, what: Binding) => Promise<string>;
|
|
/** Start a cloud box of one class and answer the name it goes by here. */
|
|
launch: (kind: Class) => Promise<string>;
|
|
/** Read the registry again now. */
|
|
refresh: () => void;
|
|
}
|
|
|
|
export function useFleet(token: string | null, origin: string = API): Fleet {
|
|
const [data, setData] = useState<{
|
|
m: Machine[];
|
|
s: Session[];
|
|
b: SandboxMachine[];
|
|
} | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const read = useCallback(
|
|
async (t: string) => {
|
|
try {
|
|
// The registry is where machines and terminals come from. Sandboxes are
|
|
// a SECOND read against the same plane: caught rather than reported,
|
|
// because their absence costs those panes and nothing else, and a
|
|
// banner about boxes you may not have is noise on a workspace that
|
|
// still works.
|
|
const [m, s, b] = await Promise.all([
|
|
machines(t, origin),
|
|
sessions(t, origin),
|
|
sandboxes(t, origin).catch(() => []),
|
|
]);
|
|
setData({ m, s, b });
|
|
setError(null);
|
|
} catch (e) {
|
|
// A registry that cannot be read is reported as such. Rendering an
|
|
// empty workspace would claim "no machines", which is a different and
|
|
// wrong thing.
|
|
setError(e instanceof Error ? e.message : 'could not reach the registry');
|
|
}
|
|
},
|
|
[origin],
|
|
);
|
|
|
|
const refresh = useCallback(() => {
|
|
if (token) void read(token);
|
|
}, [token, read]);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
void read(token);
|
|
const beat = setInterval(() => void read(token), BEAT);
|
|
return () => clearInterval(beat);
|
|
}, [token, read]);
|
|
|
|
/**
|
|
* Machines that can serve shells, and the tunnel each one's terminals live on.
|
|
*
|
|
* A terminal url is a fact about the MACHINE — one link, one ttyd, one tunnel
|
|
* — so it is read from whichever of its sessions published one, rather than
|
|
* treated as a property of that session. The workspace then names a shell per
|
|
* pane with `?arg=`, so one link serves many.
|
|
*/
|
|
const hosts = useMemo<TerminalHost[]>(() => {
|
|
if (!data) return [];
|
|
const base = new Map<string, string>();
|
|
for (const s of data.s) {
|
|
if (!s.terminal || !s.host) continue;
|
|
if (s.status !== 'running' && s.status !== 'paused') continue;
|
|
if (!base.has(s.host)) base.set(s.host, s.terminal);
|
|
}
|
|
const out = new Map<string, TerminalHost>();
|
|
for (const m of data.m) {
|
|
const key = m.host || m.label || m.id;
|
|
out.set(key, { machine: key, base: base.get(key), status: m.status, label: m.capacity });
|
|
}
|
|
for (const [host, url] of base) {
|
|
if (!out.has(host)) out.set(host, { machine: host, base: url, status: 'online' });
|
|
}
|
|
// A sandbox has no tunnel to publish — its terminal url is MINTED per open
|
|
// (a single-use ticket), so the host carries the sandbox id instead of a
|
|
// base and the workspace mints when a pane binds. One live sandbox per
|
|
// project is the server's rule, so the project name is a stable, unique
|
|
// machine name.
|
|
for (const s of data.b) {
|
|
const key = machineName(s);
|
|
if (!out.has(key)) {
|
|
out.set(key, {
|
|
machine: key,
|
|
sandbox: s.id,
|
|
screen: s.class === 'desktop',
|
|
status: 'online',
|
|
});
|
|
}
|
|
}
|
|
return [...out.values()];
|
|
}, [data]);
|
|
|
|
/**
|
|
* A fresh url for a pane, minted where the token lives so the workspace stays
|
|
* credential-free.
|
|
*
|
|
* ONE ACT, WHOEVER SERVES THE PAGE. A sandbox's terminal is hosted by cloud
|
|
* and opened by a single-use ticket; a linked machine's is served by the
|
|
* machine over its own tunnel, and what that tunnel wants is a session for
|
|
* the identity already held here. Both are "ask with the token, then frame
|
|
* the answer", which is why the workspace does not know which kind of machine
|
|
* a pane is bound to.
|
|
*/
|
|
const mint = useCallback(
|
|
async (host: TerminalHost, what: Binding) => {
|
|
if (!token) throw new Error('signed out');
|
|
if (host.sandbox) {
|
|
return what.kind === 'screen'
|
|
? frameUrl(token, host.sandbox, 'screen', undefined, origin)
|
|
: frameUrl(
|
|
token,
|
|
host.sandbox,
|
|
'terminal',
|
|
what.kind === 'shell' ? what.shell.name : undefined,
|
|
origin,
|
|
);
|
|
}
|
|
if (!host.base || what.kind !== 'shell') throw new Error('this machine serves no terminal');
|
|
await grant(token, host.base);
|
|
return shellUrl(host.base, what.shell.name);
|
|
},
|
|
[token, origin],
|
|
);
|
|
|
|
const launch = useCallback(
|
|
async (kind: Class) => {
|
|
if (!token) throw new Error('signed out');
|
|
const box = await createSandbox(token, kind, origin);
|
|
await read(token);
|
|
return machineName(box);
|
|
},
|
|
[token, origin, read],
|
|
);
|
|
|
|
return { hosts, read: data !== null, error, mint, launch, refresh };
|
|
}
|