Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions plugins/team-setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,53 @@ the page when the server is back. A bb that is not behind bb-gate gets a
on bb's own host, so the request carries the tailnet assertion the gateway
adds, and the plugin's server side knows nothing about it.

## Data profiles

A bb-gate server's one data volume can hold several data sets, switchable
from the "Advanced" disclosure at the foot of this plugin's settings page.
It is for testing or filming onboarding: create a `demo` profile, switch to
it for an empty server, and switch back to `default` for the real one. Most
people never need it.

The bb-gate entrypoint owns the layout. The volume root is the `default`
profile and every other profile is a directory under `profiles/<name>/`; a
one-line `active-profile` file at the root names the active one. The
entrypoint exports `BB_GATE_PROFILES_DIR` and `BB_GATE_ACTIVE_PROFILE_FILE`
into this plugin's server process. The plugin registers four RPCs only when
both are set:

| RPC | What it does |
|---|---|
| `profiles.list` | `{ active, profiles: [{ name, booted, bytes }] }`; `booted` is "has a `bb.db`", sizes are computed on request and `profiles/` is left out of `default`'s. |
| `profiles.create { name }` | Validates the name, refuses an existing one, creates the empty directory. |
| `profiles.delete { name }` | Refuses `default`, the active profile and the profile the server is actually running; removes the directory, never following a symlink. |
| `profiles.activate { name }` | Refuses an unknown name, writes `active-profile`, returns `{ booted }`. |

A profile name is a lowercase DNS label of 1 to 32 characters, checked before
it becomes a path, and the resolved path is refused if it escapes
`profiles/`. The server records the profile it booted on; `list` reports that
as active and `delete` refuses it as well as `active-profile`, so a switch
whose restart failed cannot leave the live data dir deletable. `switchProfile`
also puts `active-profile` back when the restart is refused or unreachable,
so a later unrelated restart does not land the developer on the new data set.

Switching calls `profiles.activate` and then `POST`s the router's
`/_bb-gate/restart`, with `?first_visit=1` for a profile that has never
booted so the first browser visit lands on Team setup, then follows the
restart through `/_bb-gate/update` exactly as the Server update section does.
The router rolls only the caller's own Deployment.

What does not follow a profile: machines are enrolled per profile, so a
devbox stays attached to the profile that made it and gets 401 from any
other until switched back; Linear, Slack, hooks leases and every steward
thread live in the profile's database, so a fresh profile has no subscribers
until it connects. Disk is one shared 20Gi volume with no cap on the number
of profiles.

A server not behind bb-gate, or on an image older than this layout, has no
`BB_GATE_PROFILES_DIR`, so the RPCs are absent and the Advanced section
renders nothing.

## Development

```sh
Expand Down
8 changes: 7 additions & 1 deletion plugins/team-setup/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ describe("registration", () => {
it("registers the checklist homepage section and the settings sections", () => {
const { homepageSections, settingsSections } = capture();
expect(homepageSections.map((section) => section.id)).toEqual(["checklist"]);
expect(settingsSections.map((section) => section.id)).toEqual(["server-update", "checklist"]);
expect(settingsSections.map((section) => section.id)).toEqual(["server-update", "checklist", "advanced"]);
// The Advanced section draws its own heading and description in the
// disclosure, and renders nothing off-gate; the host must not draw a
// heading for it.
const advanced = settingsSections.find((section) => section.id === "advanced");
expect(advanced?.title).toBeUndefined();
expect(advanced?.description).toBeUndefined();
});

// BB-174's Get started nav panel and redirect overlay were removed in
Expand Down
267 changes: 267 additions & 0 deletions plugins/team-setup/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { definePluginApp, useBbNavigate, useRealtime, useRpc } from "@get-bb/plugin-sdk/app";
import type { rpcContract } from "./server";
import type { profilesRpcContract } from "./profiles-contract.js";
import { validProfileName, type ProfileRow } from "./profiles-name.js";
import { switchProfile } from "./profile-switch.js";
import { browserDeps, createUpdateStore, type UpdateStore } from "./server-update";
import { CreateProjectEntry } from "./create-project-entry.js";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Disclosure } from "@/components/ui/disclosure";
import { Icon } from "@/components/ui/icon";
import { Input } from "@/components/ui/input";

type Status = Awaited<ReturnType<ReturnType<typeof useRpc<typeof rpcContract>>["call"]>> extends infer R
? Extract<R, { steps: unknown }>
Expand Down Expand Up @@ -778,6 +785,262 @@ function ServerUpdateSection() {
);
}

// ---- Data profiles (Advanced) ---------------------------------------------

interface ProfilesList {
active: string;
profiles: ProfileRow[];
}

type ProfilesRpc = ReturnType<typeof useRpc<typeof profilesRpcContract>>;

function sortProfiles(profiles: ProfileRow[], active: string): ProfileRow[] {
return [...profiles].sort((a, b) => (a.name === active ? -1 : b.name === active ? 1 : a.name.localeCompare(b.name)));
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
}

function ProfilesPanel({ rpc, list, setList }: { rpc: ProfilesRpc; list: ProfilesList; setList: (list: ProfilesList) => void }) {
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ProfileRow | null>(null);
const [deleting, setDeleting] = useState<ProfileRow | null>(null);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);

const trimmed = name.trim();
const nameValid = validProfileName(trimmed);
const nameTaken = list.profiles.some((profile) => profile.name === trimmed);
const canCreate = nameValid && !nameTaken && !creating;

const create = async () => {
if (!canCreate) return;
setCreating(true);
setCreateError(null);
try {
const row = await rpc.call("profiles.create", { name: trimmed });
setList({ active: list.active, profiles: sortProfiles([...list.profiles, row], list.active) });
setName("");
} catch (error) {
setCreateError(message(error));
} finally {
setCreating(false);
}
};

const confirmSwitch = async (target: ProfileRow) => {
setBusy(true);
setActionError(null);
try {
await switchProfile(
{ ...browserDeps(), activate: (value) => rpc.call("profiles.activate", { name: value }) },
target.name,
list.active,
);
} catch (error) {
setActionError(message(error));
setBusy(false);
setSwitchTarget(null);
}
};

const confirmDelete = async (target: ProfileRow) => {
setBusy(true);
setActionError(null);
try {
await rpc.call("profiles.delete", { name: target.name });
setList({ active: list.active, profiles: list.profiles.filter((profile) => profile.name !== target.name) });
setDeleting(null);
} catch (error) {
setActionError(message(error));
setDeleting(null);
} finally {
setBusy(false);
}
};

return (
<div className="flex flex-col gap-3">
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
{list.profiles.map((profile) => (
<li key={profile.name} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="font-medium">{profile.name}</span>
{profile.name === list.active ? <Badge>Active</Badge> : !profile.booted ? <Badge>Never started</Badge> : null}
</span>
<span className="flex items-center gap-3 text-muted-foreground">
<span className="text-xs">{formatBytes(profile.bytes)}</span>
<span className="flex gap-2">
{profile.name !== list.active ? (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => {
setActionError(null);
setSwitchTarget(profile);
}}
>
Switch
</Button>
) : null}
{profile.name !== list.active && profile.name !== "default" ? (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => {
setActionError(null);
setDeleting(profile);
}}
>
Delete
</Button>
) : null}
</span>
</span>
</li>
))}
</ul>

<form
className="flex flex-col gap-1"
onSubmit={(event) => {
event.preventDefault();
void create();
}}
>
<div className="flex items-center gap-2">
<Input
aria-label="New profile name"
placeholder="new profile name (lowercase letters, digits, hyphens)"
value={name}
onChange={(event) => {
setName(event.target.value);
setCreateError(null);
}}
/>
<Button size="sm" type="submit" disabled={!canCreate}>
Create
</Button>
</div>
{trimmed !== "" && !nameValid ? (
<p className="text-xs text-destructive">Use a lowercase DNS label: letters, digits and hyphens, 1–32 characters.</p>
) : nameTaken ? (
<p className="text-xs text-destructive">“{trimmed}” already exists.</p>
) : null}
{createError !== null ? <p className="text-xs text-destructive">{createError}</p> : null}
</form>

{actionError !== null ? <p className="text-xs text-destructive">{actionError}</p> : null}

<Dialog
open={switchTarget !== null}
title={switchTarget !== null ? `Switch to ${switchTarget.name}?` : ""}
onClose={() => {
if (!busy) setSwitchTarget(null);
}}
footer={
<>
<Button size="sm" variant="outline" disabled={busy} onClick={() => setSwitchTarget(null)}>
Cancel
</Button>
<Button size="sm" disabled={busy} onClick={() => switchTarget !== null && void confirmSwitch(switchTarget)}>
{busy ? "Restarting…" : "Switch"}
</Button>
</>
}
>
{switchTarget !== null ? (
<>
<p>The server restarts in under a minute and any running turn is interrupted.</p>
<p>Machines and connections stay with the profile that made them.</p>
{!switchTarget.booted ? <p>This profile has never started, so it opens on Team setup.</p> : null}
</>
) : null}
</Dialog>

<Dialog
open={deleting !== null}
title={deleting !== null ? `Delete ${deleting.name}?` : ""}
onClose={() => {
if (!busy) setDeleting(null);
}}
footer={
<>
<Button size="sm" variant="outline" disabled={busy} onClick={() => setDeleting(null)}>
Cancel
</Button>
<Button size="sm" variant="destructive" disabled={busy} onClick={() => deleting !== null && void confirmDelete(deleting)}>
{busy ? "Deleting…" : "Delete"}
</Button>
</>
}
>
{deleting !== null ? (
<p>
{deleting.name} is {formatBytes(deleting.bytes)}. Deleting it cannot be undone.
</p>
) : null}
</Dialog>
</div>
);
}

/** The settings section: switch this server between data sets. It renders
* nothing on a server whose entrypoint does not export the profile layout
* (not behind bb-gate, or an older image), which it learns from the first
* profiles.list. */
function AdvancedSection() {
const rpc = useRpc<typeof profilesRpcContract>();
const [open, setOpen] = useState(false);
const [list, setList] = useState<ProfilesList | null>(null);
const [absent, setAbsent] = useState(false);

useEffect(() => {
let cancelled = false;
rpc.call("profiles.list").then(
(result) => {
if (!cancelled) setList(result);
},
() => {
// No profile layout: not behind bb-gate, or an older image.
if (!cancelled) setAbsent(true);
},
);
return () => {
cancelled = true;
};
}, [rpc]);

if (absent) return null;
return (
<Disclosure
title="Advanced"
description="Switch this server between data sets. For testing onboarding; most people never need it."
open={open}
onOpenChange={setOpen}
>
{list === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<ProfilesPanel rpc={rpc} list={list} setList={setList} />
)}
</Disclosure>
);
}

const CHANGED_CHANNEL = "changed";

export default definePluginApp((app) => {
Expand All @@ -803,4 +1066,8 @@ export default definePluginApp((app) => {
description: "Everything a new server needs before agents can work here.",
component: () => <Checklist mode="settings" />,
});
app.slots.settingsSection({
id: "advanced",
component: AdvancedSection,
});
});
15 changes: 15 additions & 0 deletions plugins/team-setup/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from "react";

import { cn } from "../../lib/utils";

export function Badge({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground",
className,
)}
{...props}
/>
);
}
Loading
Loading