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,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>
);
}