Run computers with resources and health checks, add settings for bots, workspaces, computers and users

- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and
  editable; new resources stop, remove and recreate the container, its home
  dir kept. Every computer container has a docker health check and stays
  "creating" until it passes; only healthy computers are reached, by
  hostname, else by their IP on the network (dev outside docker).
- Docker is required: the app creates the cobble network on startup and
  exits without docker.
- Production container: deploy/run-app-container.sh mounts .env, the data
  dir and the docker socket; the database (DB_DIR), media, computers' homes
  and bun-sqlite's live schema now live on the data volume.
- Settings pages for bots, workspaces, computers and users, sharing list
  components; bots can be deleted; users are created with a password and
  confirmation, edited and deleted through RBAC-gated APIs.
- User types trimmed to super_admin, admin, member, read_only and revoked;
  admins manage members. UserAvatar shows profile photos from the user row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 10:26:44 +01:00
co-authored by Claude Opus 5.5
parent 0ce61cdb49
commit f24c0d7e82
91 changed files with 2692 additions and 331 deletions
@@ -2,30 +2,46 @@ import { useContext, useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Row from "@moduletrace/twui/layout/Row";
import { AppData } from "@/src/data/app-data";
import { AppContext } from "@/src/pages/__root";
import type { ComputerWithStatus } from "@/src/types";
import postAdminApi from "../(functions)/post-admin-api";
import AddFormActions from "./add-form-actions";
type Props = {
/**
* Edits this computer; creates one when not given.
*/
computer?: ComputerWithStatus;
onCancel: () => void;
/**
* Gets the new computer (still being set up) and the server's note.
*/
onCreated: (params: { computer: ComputerWithStatus; msg: string; linked: boolean }) => void;
onCreated?: (params: { computer: ComputerWithStatus; msg: string; linked: boolean }) => void;
/**
* Gets the saved computer and the server's note.
*/
onUpdated?: (params: { computer: ComputerWithStatus; msg: string }) => void;
};
/**
* Creates a computer: a new sandbox container on this host, its home
* directory kept on disk. On a workspace's pages it's linked to that
* workspace too (unless unticked).
* Creates a computer (a new sandbox container on this host, its home
* directory kept on disk) or edits one: its name, what it's for, and the
* CPUs and memory its container gets. On a workspace's pages a new one is
* linked to that workspace too (unless unticked). New resources restart
* the computer; its files stay.
*/
export default function AddComputerForm({ onCancel, onCreated }: Props) {
export default function AddComputerForm({ computer, onCancel, onCreated, onUpdated }: Props) {
const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace;
const workspace = computer ? null : pageProps?.workspace;
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [name, setName] = useState(computer?.name || "");
const [description, setDescription] = useState(computer?.description || "");
const [cpus, setCpus] = useState(String(Number(computer?.cpus) || AppData.ComputerDefaultCpus));
const [memory_gb, setMemoryGb] = useState(
String((Number(computer?.memory_mb) || AppData.ComputerDefaultMemoryMb) / 1024),
);
const [link, setLink] = useState(Boolean(workspace?.id));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
@@ -36,20 +52,35 @@ export default function AddComputerForm({ onCancel, onCreated }: Props) {
setLoading(true);
setError(undefined);
const resources = {
cpus: Number(cpus),
memory_mb: Math.round(Number(memory_gb) * 1024),
};
const workspace_id = link && workspace?.id ? workspace.id : null;
const res = await postAdminApi<ComputerWithStatus>({
path: "computers/create-computer",
body: { name, description, workspace_id },
});
const res = computer?.id
? await postAdminApi<ComputerWithStatus>({
path: "computers/update-computer",
body: { computer_id: computer.id, name, description, ...resources },
})
: await postAdminApi<ComputerWithStatus>({
path: "computers/create-computer",
body: { name, description, workspace_id, ...resources },
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't create the computer.");
setError(res.msg || "Couldn't save the computer.");
return;
}
onCreated({ computer: res.singleRes, msg: res.msg || "Setting it up.", linked: Boolean(workspace_id) });
if (computer?.id) {
onUpdated?.({ computer: res.singleRes, msg: res.msg || "Saved." });
return;
}
onCreated?.({ computer: res.singleRes, msg: res.msg || "Setting it up.", linked: Boolean(workspace_id) });
};
return (
@@ -75,6 +106,33 @@ export default function AddComputerForm({ onCancel, onCreated }: Props) {
info="Bots see this when they pick a computer."
onChange={(e) => setDescription(e.target.value)}
/>
<Row className="w-full gap-4! flex-nowrap items-start">
<Input
id="add-computer-cpus"
title="CPUs"
label="CPUs"
showLabel
type="number"
min={AppData.ComputerMinCpus}
max={AppData.ComputerMaxCpus}
step={0.25}
defaultValue={cpus}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setCpus(e.target.value)}
/>
<Input
id="add-computer-memory"
title="Memory (GB)"
label="Memory (GB)"
showLabel
type="number"
min={AppData.ComputerMinMemoryMb / 1024}
step={0.5}
defaultValue={memory_gb}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setMemoryGb(e.target.value)}
/>
</Row>
{workspace?.id ? (
<Checkbox
title={`Add it to ${workspace.name}`}
@@ -83,7 +141,12 @@ export default function AddComputerForm({ onCancel, onCreated }: Props) {
changeHandler={setLink}
/>
) : null}
<AddFormActions submit_label="Create computer" loading={loading} error={error} onCancel={onCancel} />
<AddFormActions
submit_label={computer?.id ? "Save" : "Create computer"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
);
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { useContext, useRef, useState, type ReactNode } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { SettingsComputersPath } from "@/src/dict/settings-pages-dict";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { AddFlowContext } from "../(data)/add-flow-context";
import { AddChoices, type AddKind } from "../(dict)/add-choices-dict";
@@ -118,7 +119,12 @@ export default function AddFlow({ children }: Props) {
<AddComputerForm
onCancel={close}
onCreated={({ msg, linked }) => {
if (linked && pageProps?.url?.pathname == `/admin/workspaces/${workspace?.id}`) {
const pathname = pageProps?.url?.pathname;
if (
(linked && pathname == `/admin/workspaces/${workspace?.id}`) ||
pathname == SettingsComputersPath
) {
window.location.reload();
return;
}
@@ -2,6 +2,7 @@ import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { ArrowLeft } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import { SiteData } from "@/src/data/site-data";
type Props = {
title?: string;
@@ -12,7 +13,10 @@ type Props = {
* Top of the rail on settings and workspace pages, returning to the
* conversations. Same 48px row as a bot.
*/
export default function RailBackLink({ title = "Back to bots", href = AdminHomePath }: Props) {
export default function RailBackLink({
title = `Back to ${SiteData["SiteSlug"]}`,
href = AdminHomePath,
}: Props) {
return (
<Link
href={href}
@@ -22,7 +26,9 @@ export default function RailBackLink({ title = "Back to bots", href = AdminHomeP
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<ArrowLeft size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">{title}</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
{title}
</Span>
</Link>
);
}
@@ -12,7 +12,8 @@ type Props = {
/**
* Settings mode of the rail: back to the bots, then the settings sub pages
* (super admin pages only for super admins).
* (super admin pages only for super admins, RBAC-gated pages by their
* own rule).
*/
export default function RailSettingsList({ pathname }: Props) {
const { pageProps } = useContext(AppContext);
@@ -25,8 +26,10 @@ export default function RailSettingsList({ pathname }: Props) {
<>
<RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{SettingsPages.filter(
(page) => is_super_admin || !page.super_admin_only,
{SettingsPages.filter((page) =>
page.can_view
? page.can_view({ user_types: pageProps?.user_types })
: is_super_admin || !page.super_admin_only,
).map((page) => (
<RailSettingsLink
key={page.url}