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
@@ -0,0 +1,30 @@
import AddComputerForm from "@/src/layouts/bots/(partials)/add-computer-form";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { ComputerWithStatus } from "@/src/types";
type Props = {
/**
* Open while set.
*/
computer?: ComputerWithStatus;
onClose: () => void;
onUpdated: (params: { computer: ComputerWithStatus; msg: string }) => void;
};
/**
* Edits a computer in a modal, with the same form as creating one.
*/
export default function ComputerEditModal({ computer, onClose, onUpdated }: Props) {
return (
<AddModalShell
open={Boolean(computer)}
setOpen={(open) => !open && onClose()}
title={`Edit ${computer?.name || "the computer"}`}
description="New CPUs or memory restart it with them. Its files stay."
>
{computer ? (
<AddComputerForm key={computer.id} computer={computer} onCancel={onClose} onUpdated={onUpdated} />
) : null}
</AddModalShell>
);
}
@@ -0,0 +1,45 @@
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import type { ComputerWithStatus } from "@/src/types";
import computerResourcesLabel from "@/src/utils/computer-resources-label";
import computerStatusLabel from "@/src/utils/computer-status-label";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
computer: ComputerWithStatus;
is_super_admin: boolean;
busy?: boolean;
onEdit: () => void;
onRetry: () => void;
onDelete: () => void;
};
/**
* One computer: its name, state and resources, then (super admins) Edit,
* Retry when its setup failed, and Delete (never the main one).
*/
export default function ComputerSettingsRow({ computer, is_super_admin, busy, onEdit, onRetry, onDelete }: Props) {
const name = computer.name || `Computer ${computer.id}`;
const busy_here = busy || computer.status == "creating";
return (
<SettingsListRow
avatar={<ComputerIconTile />}
title={name}
badge={computer.kind == "main" ? "Main" : undefined}
subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description]
.filter(Boolean)
.join(" · ")}
>
{is_super_admin ? (
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy_here} onClick={onEdit} />
) : null}
{is_super_admin && computer.status == "failed" ? (
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
) : null}
{is_super_admin && computer.kind != "main" ? (
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,103 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import useLiveComputers from "@/src/hooks/use-live-computers";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ComputerWithStatus } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import ComputerEditModal from "../(partials)/computer-edit-modal";
import ComputerSettingsRow from "../(partials)/computer-settings-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every computer the user has, main first, kept live: its state and
* resources. Super admins create, edit (new resources restart it, its
* files stay), retry and delete them.
*/
export default function ComputersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { computers, setComputers } = useLiveComputers({ computers: pageProps?.computers || [], include_new: true });
const [editing, setEditing] = useState<ComputerWithStatus>();
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
const merge = (fresh: ComputerWithStatus) =>
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
setBusy(true);
setStatus(undefined);
const res = await postAdminApi<ComputerWithStatus>({ path, body: { computer_id } });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
if (res.singleRes) merge(res.singleRes);
else setComputers((prev) => prev.filter((c) => c.id != computer_id));
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
};
return (
<SettingsGroup
title="Your computers"
description="Each one is a sandbox container on this machine. Bots reach them through workspaces or direct access."
>
<SettingsList is_empty={!computers.length} empty="No computers yet.">
{computers.map((computer) => (
<ComputerSettingsRow
key={computer.id}
computer={computer}
is_super_admin={is_super_admin}
busy={busy}
onEdit={() => setEditing(computer)}
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
onDelete={() => {
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
}}
/>
))}
</SettingsList>
{is_super_admin ? (
<Button
title="Create a new computer"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "computer" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New computer
</Button>
) : null}
<SettingsStatus status={status} />
<ComputerEditModal
computer={editing}
onClose={() => setEditing(undefined)}
onUpdated={({ computer, msg }) => {
merge(computer);
setEditing(undefined);
setStatus({ success: true, msg });
}}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
computers: await grabUserComputers({ user_id: settings.props.person.id }),
},
};
};
@@ -0,0 +1,13 @@
import ComputersSettingsSection from "./(sections)/computers-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsComputersPage() {
return (
<SettingsShell title="Computers" description="The machines your bots work on, and what each one gets.">
<ComputersSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Computers" });