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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user