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:
@@ -1,23 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
computers: ComputerWithStatus[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A list of computers kept current from `server:computer-updated` (setup
|
||||
* finishing or failing, a container stopping).
|
||||
*/
|
||||
export default function useLiveComputers({ computers: initial }: Params) {
|
||||
const [computers, setComputers] = useState(initial);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:computer-updated",
|
||||
onData: (computer: ComputerWithStatus) =>
|
||||
setComputers((prev) => prev.map((c) => (c.id == computer.id ? { ...c, ...computer } : c))),
|
||||
});
|
||||
|
||||
return { computers, setComputers };
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One small text action on a workspace computer row.
|
||||
*/
|
||||
export default function WorkspaceComputerAction({ title, label, disabled, onClick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="px-2! text-[13px]"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Monitor } from "lucide-react";
|
||||
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import computerStatusLabel from "../(utils)/computer-status-label";
|
||||
import WorkspaceComputerAction from "./workspace-computer-action";
|
||||
import computerStatusLabel from "@/src/utils/computer-status-label";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
@@ -34,32 +32,22 @@ export default function WorkspaceComputerRow({
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
|
||||
return (
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
|
||||
<Monitor size={16} aria-hidden />
|
||||
</Span>
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
{is_default ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">Default</Span> : null}
|
||||
</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{computerStatusLabel({ computer })}
|
||||
{computer.description ? ` · ${computer.description}` : ""}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
||||
{is_default ? null : (
|
||||
<WorkspaceComputerAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
|
||||
)}
|
||||
{computer.status == "failed" && is_super_admin ? (
|
||||
<WorkspaceComputerAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
<WorkspaceComputerAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<WorkspaceComputerAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</Row>
|
||||
</Row>
|
||||
<SettingsListRow
|
||||
avatar={<ComputerIconTile />}
|
||||
title={name}
|
||||
badge={is_default ? "Default" : undefined}
|
||||
subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`}
|
||||
>
|
||||
{is_default ? null : (
|
||||
<SettingsRowAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
|
||||
)}
|
||||
{computer.status == "failed" && is_super_admin ? (
|
||||
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
<SettingsRowAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
@@ -9,8 +7,9 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import useLiveComputers from "../(hooks)/use-live-computers";
|
||||
import useLiveComputers from "@/src/hooks/use-live-computers";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
import WorkspaceComputerRow from "./workspace-computer-row";
|
||||
|
||||
@@ -77,7 +76,10 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
|
||||
title="Computers"
|
||||
description="Bots here work on these. Their tasks run on the default one unless they pick another."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
<SettingsList
|
||||
is_empty={!computers.length}
|
||||
empty="No computers yet. Bots here can chat, but tasks that need a computer can't run."
|
||||
>
|
||||
{computers.map((computer) => (
|
||||
<WorkspaceComputerRow
|
||||
key={computer.id}
|
||||
@@ -95,12 +97,7 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{computers.length ? null : (
|
||||
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
|
||||
No computers yet. Bots here can chat, but tasks that need a computer can't run.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</SettingsList>
|
||||
<InlineAddSelect
|
||||
id="workspace-add-computer"
|
||||
noun="computer"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import computerStatusLabel from "./computer-status-label";
|
||||
|
||||
describe("computerStatusLabel", () => {
|
||||
test("names the state", () => {
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "running" } })).toBe("Running");
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "creating" } })).toBe("Setting up…");
|
||||
expect(computerStatusLabel({ computer: { id: 1 } })).toBe("Unknown");
|
||||
});
|
||||
|
||||
test("says why setup failed", () => {
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "failed", error: "no docker" } })).toBe(
|
||||
"Setup failed: no docker",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ContainerStatuses } from "@/src/dict/computer-dict";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
computer: ComputerWithStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer's state in a few words.
|
||||
*/
|
||||
export default function computerStatusLabel({ computer }: Params) {
|
||||
if (computer.status == "creating") return "Setting up…";
|
||||
if (computer.status == "failed") return computer.error ? `Setup failed: ${computer.error}` : "Setup failed";
|
||||
|
||||
return ContainerStatuses.find((s) => s.value == computer.status)?.title || "Unknown";
|
||||
}
|
||||
Reference in New Issue
Block a user