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,13 @@
import Span from "@moduletrace/twui/layout/Span";
import { Monitor } from "lucide-react";
/**
* A computer's avatar in lists: the monitor icon on a small tile.
*/
export default function ComputerIconTile() {
return (
<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>
);
}
+23 -5
View File
@@ -1,3 +1,6 @@
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import userInitials from "@/src/utils/user-initials";
import Img from "@moduletrace/twui/layout/Img";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
@@ -12,28 +15,43 @@ const UserAvatarSizeClasses = {
} as const;
type Props = {
/**
* Their photo and initials come from this user (`grabUsers`' row, with
* `profile_media_id`), unless `src` or `initials` are given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
/**
* A photo to show instead, e.g. one just picked and not saved yet.
*/
src?: string;
/**
* Tried when `src` fails to load, e.g. a full image behind a thumbnail.
*/
img_err_src?: string;
initials: string;
initials?: string;
alt?: string;
size?: keyof typeof UserAvatarSizeClasses;
className?: string;
};
/**
* A user's round photo, or their initials on sunken paper when there is none.
* A user's round photo (its thumbnail, the full image if that fails), or
* their initials on sunken paper when there is none.
*/
export default function UserAvatar({
src,
img_err_src,
initials,
person,
src: passed_src,
img_err_src: passed_err_src,
initials: passed_initials,
alt = "",
size = "md",
className,
}: Props) {
const photo = person?.profile_media_id ? grabMediaURL({ user: person }) : {};
const src = passed_src || photo.media_thumbnail_url || photo.media_url;
const img_err_src = passed_src ? passed_err_src : photo.media_url;
const initials = passed_initials || userInitials(person || {});
const base_class = twMerge(
"shrink-0 rounded-full bg-sunken",
UserAvatarSizeClasses[size],
+10 -24
View File
@@ -1,23 +1,21 @@
import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import pushComputerUpdate from "@/src/functions/backend/db/computers/push-computer-update";
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status"> &
Pick<BUN_SQLITE_COBBLE_COMPUTERS, "user_id"> & { computer_id: number };
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address"> &
{ computer_id: number };
/**
* Reads every computer container's state from docker and records it
* (`containers.status`, `last_seen_at`), telling owners about changes. A
* container docker no longer has shows as `dead`. Containers still being
* set up, or that failed, are left alone.
* (`refreshComputerContainer`), telling owners about changes. Containers
* still being set up, or that failed, are left alone.
*/
export default async function syncComputers() {
const rows =
(
await BunSQLite.sql<Row>({
sql: `SELECT co.id, co.docker_id, co.status, c.user_id, c.id AS computer_id
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, c.id AS computer_id
FROM containers co JOIN computers c ON c.container_id = co.id
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
})
@@ -25,22 +23,10 @@ export default async function syncComputers() {
let changed = 0;
for (const row of rows) {
const state = await inspectDockerContainer({ container: row.docker_id! }).catch(() => undefined);
for (const { computer_id, ...container } of rows) {
const refreshed = await refreshComputerContainer({ computer_id, container });
if (state === undefined) continue;
const status = state?.status || "dead";
await BunSQLite.sql({
sql: `UPDATE containers SET status = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [status, Date.now(), Date.now(), row.id!],
});
if (status != row.status) {
changed++;
await pushComputerUpdate({ computer_id: row.computer_id }).catch(() => {});
}
if (refreshed?.changed) changed++;
}
bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`);
+38 -5
View File
@@ -27,9 +27,9 @@ export const AppData = {
PasswordMaxLength: 128,
PasswordAttemptLimit: 10,
/**
* Docker network shared by the app and every computer. The app creates
* it when it sets up its first computer and joins it when it runs in
* docker.
* Docker network shared by the app and every computer. The app makes
* sure it exists on startup and before it creates a container, and
* joins it when it runs in docker.
*/
DockerNetworkName: SiteData["SiteSlug"],
/**
@@ -51,11 +51,44 @@ export const AppData = {
ComputerHomeDir: "/home/gem",
ComputerUserUid: 1000,
ComputerUserGid: 1000,
/**
* Resources a new computer's container gets, saved on the computer
* (`cpus`, `memory_mb`) when it is created.
*/
ComputerDefaultCpus: 1,
ComputerDefaultMemoryMb: 2048,
ComputerMinCpus: 0.25,
ComputerMaxCpus: 64,
ComputerMinMemoryMb: 512,
/**
* Docker's health check on every computer container: the sandbox's API
* on `ComputerPort` and its browser answer. A new computer stays
* `creating` until it passes, for at most `ComputerHealthyTimeoutMs`,
* checked every `ComputerHealthPollMs`.
*/
ComputerHealthcheck: {
IntervalSeconds: 5,
TimeoutSeconds: 5,
StartPeriodSeconds: 120,
Retries: 3,
},
ComputerHealthyTimeoutMs: 5 * 60 * 1000,
ComputerHealthPollMs: 2000,
/**
* How long docker gives a computer's container to stop before it is
* killed, when it is removed or recreated.
*/
ComputerStopTimeoutSeconds: 10,
/**
* A computer is reached at its hostname, else its IP address. Each
* address gets this long to answer, and the one that did is reused
* for `ComputerAddressCacheMs`.
*/
ComputerReachTimeoutMs: 2000,
ComputerAddressCacheMs: 60 * 1000,
/**
* Per computer container.
*/
ComputerMemoryBytes: 4 * 1024 * 1024 * 1024,
ComputerCpus: 2,
ComputerShmBytes: 2 * 1024 * 1024 * 1024,
/**
* The docker engine API, reached over its unix socket.
+13 -2
View File
@@ -20,8 +20,11 @@ export const MainComputerName = "Main computer";
/**
* Docker container state as reported by `docker inspect` (`State.Status`),
* plus `creating` while the app sets it up, `failed` when that didn't
* work, and `unknown` for a record that hasn't been inspected yet.
* with `running` narrowed by its health check: `starting` until it first
* passes, `unhealthy` once it keeps failing. Plus `creating` while the app
* sets it up (until the health check passes), `failed` when that didn't
* work, and `unknown` for a record that hasn't been inspected yet. Only a
* `running` computer is reached.
*/
export const ContainerStatuses = [
{
@@ -36,10 +39,18 @@ export const ContainerStatuses = [
title: "Created",
value: "created",
},
{
title: "Starting",
value: "starting",
},
{
title: "Running",
value: "running",
},
{
title: "Unhealthy",
value: "unhealthy",
},
{
title: "Paused",
value: "paused",
+1
View File
@@ -70,6 +70,7 @@ export const ProcessOnlyEnvKeys = [
"ENCRYPTION_SALT",
"PORT",
"DATA_DIR",
"DB_DIR",
"MAIN_SERVER",
] as const;
+21
View File
@@ -1,11 +1,17 @@
import {
Bot,
CpuAi,
KeyRound,
Layers,
Monitor,
Users,
SlidersHorizontal,
UserRound,
Variable,
type LucideIcon,
} from "lucide-react";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import canListUsers from "@/src/utils/rbac/can-list-users";
export type SettingsPage = {
title: string;
@@ -15,8 +21,14 @@ export type SettingsPage = {
* Shown and served to super admins only.
*/
super_admin_only?: boolean;
/**
* Who sees it, when that's an RBAC rule rather than super admins only.
*/
can_view?: (params: { user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null }) => boolean;
};
export const SettingsComputersPath = "/admin/settings/computers";
/**
* The `/admin/settings/*` sub pages, listed in the rail on every settings
* page. The first one is where `/admin/settings` lands.
@@ -33,6 +45,15 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/change-password",
Icon: KeyRound,
},
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor },
{
title: "Users",
url: "/admin/settings/users",
Icon: Users,
can_view: canListUsers,
},
{
title: "Models",
url: "/admin/settings/models",
+13 -22
View File
@@ -19,6 +19,7 @@ describe("UserTypes dict", () => {
const required = [
"super_admin",
"admin",
"member",
"revoked",
"read_only",
] as const;
@@ -48,28 +49,18 @@ describe("Assignment matrices", () => {
).toBeUndefined();
});
test("elevated types are pass-through only", () => {
const elevated = [
"admin",
"president",
"vice_president",
"secretary",
] as const;
for (const type of elevated) {
const entry = UserTypeAssignmentMatrix[type];
expect(Array.isArray(entry.allowed_actors)).toBe(true);
expect((entry.allowed_actors as string[]).length).toBe(0);
test("admin is granted by pass-through actors only", () => {
const entry = UserTypeAssignmentMatrix["admin"];
expect(Array.isArray(entry.allowed_actors)).toBe(true);
expect((entry.allowed_actors as string[]).length).toBe(0);
});
test("admin manages member types, never on admins or the super admin", () => {
for (const type of ["member", "read_only", "revoked"] as const) {
for (const matrix of [UserTypeAssignmentMatrix, UserTypeDeletionMatrix]) {
const rules = (matrix[type].allowed_actors as Record<string, { excluded_targets?: string[] }>).admin;
expect(rules?.excluded_targets).toEqual(["super_admin", "admin"]);
}
}
});
test("admin can manage auditor but not another admin", () => {
expect(
UserTypeAssignmentMatrix["auditor"].allowed_actors,
).toContain("admin");
expect(
(UserTypeAssignmentMatrix["admin"].allowed_actors as string[])
.length,
).toBe(0);
});
});
+33 -53
View File
@@ -1,29 +1,33 @@
import type { UserType, UserTypeAssignmentMatrixType } from "../types";
/**
* Every user type on this install. A user needs at least one to sign in.
*/
export const UserTypes = [
{
value: "super_admin",
title: "Super admin",
description: "Runs the install: models, environment, computers and users. The first account.",
},
{
value: "admin",
title: "Admin",
description: "Adds and manages members.",
},
{
value: "auditor",
},
{
value: "president",
},
{
value: "vice_president",
},
{
value: "secretary",
},
{
value: "revoked",
value: "member",
title: "Member",
description: "Has their own crew, workspaces and main computer.",
},
{
value: "read_only",
title: "Read only",
description: "Can look around, but can't change anything.",
},
{
value: "revoked",
title: "Revoked",
description: "Can't sign in. Their crew and files are kept.",
},
] as const;
@@ -58,36 +62,24 @@ export const UserTypeAssignmentMatrix: UserTypeAssignmentMatrixType = {
admin: {
allowed_actors: [],
},
president: {
allowed_actors: [],
},
vice_president: {
allowed_actors: [],
},
secretary: {
allowed_actors: [],
},
auditor: {
allowed_actors: ["admin"],
member: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin", "admin"],
},
},
},
read_only: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin"],
excluded_targets: ["super_admin", "admin"],
},
},
},
revoked: {
allowed_actors: {
admin: {
allowed_targets: ["read_only"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
excluded_targets: ["super_admin", "admin"],
},
},
},
@@ -98,36 +90,24 @@ export const UserTypeDeletionMatrix: UserTypeAssignmentMatrixType = {
admin: {
allowed_actors: [],
},
president: {
allowed_actors: [],
},
vice_president: {
allowed_actors: [],
},
secretary: {
allowed_actors: [],
},
auditor: {
allowed_actors: ["admin"],
member: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin", "admin"],
},
},
},
read_only: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin"],
excluded_targets: ["super_admin", "admin"],
},
},
},
revoked: {
allowed_actors: {
admin: {
allowed_targets: ["read_only"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
excluded_targets: ["super_admin", "admin"],
},
},
},
@@ -60,8 +60,8 @@ describe("checkUserAccess", () => {
test("includes match with OR semantics", () => {
expect(
checkUserAccess({
user_types: makeUserTypes("president"),
includes: ["admin", "president"],
user_types: makeUserTypes("member"),
includes: ["admin", "member"],
}).success,
).toBe(true);
});
@@ -0,0 +1,38 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
};
/**
* Deletes one of the user's worker bots with its conversations, tasks,
* jobs, models and workspace places. A job it's running stops: it loses
* its row. The coordinator can't be deleted.
*/
export default async function deleteBot({ user_id, bot_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
if (bot.kind == "coordinator") {
throw new AppError(`The coordinator can't be deleted.`);
}
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
if (!res.success) {
throw new Error(`Couldn't delete the bot: ${res.error}`);
}
if (bot.image_media_id) {
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
return bot;
}
@@ -17,6 +17,15 @@ type Params = {
* `main` only for the owner's one main computer (`ensureMainComputer`).
*/
kind?: ComputerKind;
/**
* CPU cores its container may use. Defaults to `AppData.ComputerDefaultCpus`.
*/
cpus?: number;
/**
* Its container's memory limit in MB. Defaults to
* `AppData.ComputerDefaultMemoryMb`.
*/
memory_mb?: number;
};
/**
@@ -24,7 +33,8 @@ type Params = {
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
* in the background (`provisionComputer`). Returns the computer at once,
* still `creating`, and the setup to await when the caller must (a CLI
* script). The main computer of a server that reaches it at
* script). Its resources (`cpus`, `memory_mb`) are saved on the computer
* and applied to its container whenever it is set up. The main computer of a server that reaches it at
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
*/
export default async function createComputer({
@@ -32,6 +42,8 @@ export default async function createComputer({
name,
description,
kind = "sandbox",
cpus = AppData.ComputerDefaultCpus,
memory_mb = AppData.ComputerDefaultMemoryMb,
}: Params) {
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
@@ -70,6 +82,8 @@ export default async function createComputer({
name,
description: description || undefined,
kind,
cpus,
memory_mb,
},
],
});
@@ -3,30 +3,39 @@ import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_CONTAINERS,
} from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import { ComputerMcpPath } from "@/src/dict/computer-dict";
import { ComputerMcpPath, type ContainerStatus } from "@/src/dict/computer-dict";
import grabEnv from "../envs/grab-env";
import pickReachableAddress from "./pick-reachable-address";
import refreshComputerContainer from "./refresh-computer-container";
type Params = {
computer_id: number | string;
};
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> &
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">;
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "kind"> &
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "hostname" | "ip_address" | "port">;
export type ComputerAddress =
| { ready: true; url: string; base_url: string; name: string }
| { ready: false; status: ContainerStatus; name: string };
/**
* Where a computer's MCP server answers. In order: the container's
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app
* setting, `grabEnv`: for a server outside docker, e.g.
* `http://localhost:8170`), else its docker hostname and port. `base_url`
* is the same address without `/mcp`, for the sandbox's REST API. Null
* when the computer has no container.
* Where a computer's MCP server answers: `MAIN_COMPUTER_URL` for the
* main computer (an app setting, `grabEnv`: for a server outside docker,
* e.g. `http://localhost:8170`), else its docker hostname and port, or
* its IP address on the network when the hostname can't be reached (a
* dev server outside docker, `pickReachableAddress`). `base_url` is the
* same address without `/mcp`, for the sandbox's REST API. Only a
* computer whose health check passed (`running`) is reached; any other
* state is read again from docker first, since it may have come back
* since the last sync, and returned as `ready: false`. Null when the
* computer has no container.
*/
export default async function grabComputerMcpUrl({ computer_id }: Params) {
export default async function grabComputerMcpUrl({ computer_id }: Params): Promise<ComputerAddress | null> {
const row = (
await BunSQLite.sql<ComputerRow>({
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata
sql: `SELECT co.name, co.kind, c.id, c.docker_id, c.status, c.hostname, c.ip_address, c.port
FROM computers co JOIN containers c ON c.id = co.container_id
WHERE co.id = ? LIMIT 1`,
values: [computer_id],
@@ -35,23 +44,37 @@ export default async function grabComputerMcpUrl({ computer_id }: Params) {
if (!row?.hostname) return null;
const metadata = parseJobJson({ text: row.metadata }) || {};
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
return {
url: metadata.mcp_url,
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
name: row.name || "",
};
}
const name = row.name || "";
const main_override = row.kind == "main"
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
: undefined;
const base = main_override
? main_override.replace(/\/+$/, "")
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`;
if (main_override) {
const base_url = main_override.replace(/\/+$/, "");
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
}
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" };
let status = (row.status || "unknown") as ContainerStatus;
let ip_address: string | null | undefined = row.ip_address;
if (status != "running" && status != "creating" && status != "failed") {
const refreshed = await refreshComputerContainer({ computer_id, container: row });
if (refreshed) ({ status, ip_address } = refreshed);
}
if (status != "running") return { ready: false, status, name };
const port = row.port || AppData.ComputerPort;
const base_url = await pickReachableAddress({
key: `computer:${computer_id}`,
candidates: [
`http://${row.hostname}:${port}`,
...(ip_address ? [`http://${ip_address}:${port}`] : []),
],
});
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
}
@@ -0,0 +1,26 @@
import { afterAll, describe, expect, test } from "bun:test";
import pickReachableAddress from "./pick-reachable-address";
const server = Bun.serve({ port: 0, fetch: () => new Response("", { status: 404 }) });
const reachable = `http://127.0.0.1:${server.port}`;
const unreachable = "http://cobble-computer-test.invalid:8080";
afterAll(() => server.stop(true));
describe("pickReachableAddress", () => {
test("falls back to the next address when the first can't be reached", async () => {
expect(await pickReachableAddress({ key: "a", candidates: [unreachable, reachable] })).toBe(reachable);
});
test("keeps the first address when it answers, whatever the status", async () => {
expect(await pickReachableAddress({ key: "b", candidates: [reachable, unreachable] })).toBe(reachable);
});
test("returns the first address when none answers", async () => {
expect(await pickReachableAddress({ key: "c", candidates: [unreachable, "http://127.0.0.1:1"] })).toBe(unreachable);
});
test("returns a lone address without trying it", async () => {
expect(await pickReachableAddress({ key: "d", candidates: [unreachable] })).toBe(unreachable);
});
});
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
type Params = {
/**
* Caches the pick, e.g. `computer:<id>`.
*/
key: string;
/**
* Base URLs in the order to try, e.g. the hostname, then the IP address.
*/
candidates: [string, ...string[]];
};
const picked = new Map<string, { base_url: string; at: number }>();
/**
* The first of `candidates` that answers over HTTP (any status counts),
* each given `AppData.ComputerReachTimeoutMs`. The pick is reused for
* `AppData.ComputerAddressCacheMs` while it is still a candidate. When
* none answers, the first, so the caller's own error names it.
*/
export default async function pickReachableAddress({ key, candidates }: Params) {
if (candidates.length < 2) return candidates[0];
const cached = picked.get(key);
if (
cached &&
Date.now() - cached.at < AppData.ComputerAddressCacheMs &&
candidates.includes(cached.base_url)
) {
return cached.base_url;
}
for (const base_url of candidates) {
const reached = await fetch(base_url, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(AppData.ComputerReachTimeoutMs),
})
.then(() => true)
.catch(() => false);
if (reached) {
picked.set(key, { base_url, at: Date.now() });
return base_url;
}
}
picked.delete(key);
return candidates[0];
}
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
@@ -17,15 +17,18 @@ type Params = {
/**
* Sets up a computer's container: prepares its home directory, pulls the
* sandbox image and starts the container, then records it (`containers`:
* docker id, state, and where to reach it outside docker). A failure is
* sandbox image and starts the container with the computer's resources
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
* docker id, state, and its IP address on the network). A failure is
* recorded as `failed` with its reason, which the computer lists show. The
* owner's tabs hear about it either way. Never throws.
*/
export default async function provisionComputer({ computer_id, fresh }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
await BunSQLite.sql<
BUN_SQLITE_COBBLE_CONTAINERS & Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">
>({
sql: `SELECT co.*, c.cpus, c.memory_mb FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
values: [computer_id],
})
).payload?.[0];
@@ -41,26 +44,28 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
});
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
const created = await createComputerContainer({ computer_id, name: row.name, data_dir });
const created = await createComputerContainer({
computer_id,
name: row.name,
data_dir,
cpus: Number(row.cpus) || AppData.ComputerDefaultCpus,
memory_mb: Number(row.memory_mb) || AppData.ComputerDefaultMemoryMb,
});
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
await BunSQLite.sql({
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?,
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, ip_address = ?,
port = ?, status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [
created.docker_id,
AppData.ComputerImage,
created.network,
row.name,
created.ip_address,
AppData.ComputerPort,
created.status,
JSON.stringify({
...kept,
data_dir,
host_dir: created.host_dir,
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
}),
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
Date.now(),
Date.now(),
row.id,
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import type { ContainerStatus } from "@/src/dict/computer-dict";
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
import pushComputerUpdate from "./push-computer-update";
type Params = {
computer_id: number | string;
container: Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address">;
};
/**
* Reads a computer container's state from docker and records it
* (`status`, `ip_address`, which can change when it restarts,
* `last_seen_at`), telling the owner when its state changed. A container
* docker no longer has is `dead`. Returns the state recorded, or null
* when docker couldn't be asked.
*/
export default async function refreshComputerContainer({ computer_id, container }: Params) {
if (!container.id || !container.docker_id) return null;
const state = await inspectDockerContainer({ container: container.docker_id }).catch(() => undefined);
if (state === undefined) return null;
const status: ContainerStatus = state?.status || "dead";
const ip_address = state?.ip_address || container.ip_address || null;
await BunSQLite.sql({
sql: `UPDATE containers SET status = ?, ip_address = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [status, ip_address, Date.now(), Date.now(), container.id],
});
const changed = status != container.status;
if (changed) await pushComputerUpdate({ computer_id }).catch(() => {});
return { status, ip_address, changed };
}
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BunSQLiteTables,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import _ from "lodash";
import grabEnv from "../envs/grab-env";
import grabUserComputers from "./grab-user-computers";
import grabUserComputer from "./grab-user-computer";
import provisionComputer from "./provision-computer";
type Params = {
user_id: number | string;
computer_id: number | string;
/**
* Only the fields given are changed.
*/
data: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "description" | "cpus" | "memory_mb">;
};
/**
* Updates one of the user's computers. When its container's settings
* change (`cpus`, `memory_mb`), the container is stopped, removed and
* run again with them in the background (`provisionComputer`); its home
* directory is bind-mounted, so its files stay. A name or description
* alone leaves the container running. The main computer of a server that
* reaches it at `MAIN_COMPUTER_URL` has no container to recreate.
*/
export default async function updateComputer({ user_id, computer_id, data }: Params) {
const computer = await grabUserComputer({ computer_id, user_id });
if (!computer?.id) {
throw new AppError(`Computer not found.`);
}
const recreate =
(data.cpus !== undefined && Number(data.cpus) != Number(computer.cpus)) ||
(data.memory_mb !== undefined && Number(data.memory_mb) != Number(computer.memory_mb));
if (recreate && (await grabUserComputers({ computer_id: computer.id }))[0]?.status == "creating") {
throw new AppError(`${computer.name || "The computer"} is still being set up. Try again once it's ready.`);
}
const res = await BunSQLite.update<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
targetId: computer.id,
/**
* `BunSQLite.update` writes every key it gets, so a field left out
* here would be set to null.
*/
data: _.omitBy(
{
name: data.name,
description: data.description,
cpus: data.cpus,
memory_mb: data.memory_mb,
},
_.isUndefined,
),
});
if (!res.success) {
throw new Error(`Couldn't update the computer: ${res.msg}`);
}
const external =
computer.kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
const recreating = recreate && !external && Boolean(computer.container_id);
const provisioning = recreating
? provisionComputer({ computer_id: Number(computer.id) })
: Promise.resolve();
return { recreating, provisioning };
}
@@ -0,0 +1,98 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
import AppError from "@/src/utils/app-error";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import grabUsers from "./grab-users";
type Params = {
/**
* Who is deleting: never themselves.
*/
actor_user_id: number | string;
/**
* Whoever may revoke a user may delete them (`canRevokeUser`).
*/
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
user_id: number | string;
};
/**
* Deletes a user with everything that's theirs (bots, workspaces,
* conversations, computers, user types, password, media and its files):
* their computers' containers are removed first, their home directories
* stay on disk. Their audit log entries stay too. The super admin can't be
* deleted, and nobody deletes themselves.
*/
export default async function deleteUser({ actor_user_id, actor_user_types, user_id }: Params) {
if (String(actor_user_id) == String(user_id)) {
throw new AppError(`You can't delete yourself.`);
}
const user = (await grabUsers({ user_id })).singleRes;
if (!user?.id) {
throw new AppError(`User not found.`);
}
if (parseUserTypes({ user_types: user.user_types }).includes("super_admin")) {
throw new AppError(`The super admin can't be deleted.`);
}
const target_user_types = parseUserTypes({ user_types: user.user_types }).map((user_type) => ({ user_type }));
if (!canRevokeUser({ user_types: actor_user_types, target_user_types })) {
throw new AppError(`You can't delete ${user.first_name || "this user"}.`);
}
const containers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.user_id = ?`,
values: [user.id],
})
).payload || [];
for (const container of containers) {
if (container.docker_id || container.name) {
await removeDockerContainer({ container: container.docker_id || container.name! });
}
}
const media_ids =
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM media WHERE user_id = ?`,
values: [user.id],
})
).payload?.map((media) => media.id) || [];
if (media_ids.length) {
await deleteMedia({ ids: media_ids, user_id: user.id });
}
const res = await BunSQLite.sql({ sql: `DELETE FROM users WHERE id = ?`, values: [user.id] });
if (!res.success) {
throw new Error(`Couldn't delete the user: ${res.error}`);
}
/**
* These point at the user without a foreign key, so nothing cascades.
*/
for (const table of ["user_types", "user_passwords", "sso_login_codes"]) {
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}
if (containers.length) {
await BunSQLite.sql({
sql: `DELETE FROM containers WHERE id IN (${containers.map(() => "?").join(", ")})`,
values: containers.map((container) => container.id!),
});
}
return user;
}
+10 -9
View File
@@ -29,21 +29,22 @@ export default async function grabUsers(
sql += `SELECT\n`;
sql += ` u.*,\n`;
sql += ` ut_agg.user_types,\n`;
sql += ` m.id AS profile_media_id\n`;
/**
* Their profile photo only: other primary media (a bot's picture)
* must not stand in for it.
*/
sql += ` (\n`;
sql += ` SELECT m.id FROM media m\n`;
sql += ` JOIN media_paradigms mp ON mp.media_id = m.id AND mp.media_paradigm = 'user-profile-image'\n`;
sql += ` WHERE m.user_id = u.id AND m.is_primary = 1\n`;
sql += ` ORDER BY m.id DESC LIMIT 1\n`;
sql += ` ) AS profile_media_id\n`;
sql += `FROM users u\n`;
sql += `LEFT JOIN (\n`;
sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`;
sql += ` FROM user_types\n`;
sql += ` GROUP BY user_id\n`;
sql += `) ut_agg ON u.id = ut_agg.user_id\n`;
sql += `LEFT JOIN media m ON (\n`;
sql += ` u.id = m.user_id\n`;
sql += ` AND m.is_primary = 1\n`;
sql += `)\n`;
sql += `LEFT JOIN media_paradigms mp ON (\n`;
sql += ` m.id = mp.media_id\n`;
sql += ` AND mp.media_paradigm = 'user-profile-image'\n`;
sql += `)\n`;
sql += `WHERE COALESCE(u.archived, 0) <> 1\n`;
const target_user_id = params?.user_id || query?.user_id;
+140
View File
@@ -0,0 +1,140 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_USERS,
BUN_SQLITE_COBBLE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
import type { SaveUserRequest } from "@/src/schemas/user-schema";
import AppError from "@/src/utils/app-error";
import parseUserTypes from "@/src/utils/parse-user-types";
import userTypeAssignment from "@/src/utils/rbac/user-type-assignment";
import validatePassword from "@/src/utils/validate-password";
import grabUsers from "./grab-users";
type Params = SaveUserRequest & {
/**
* The user types of whoever is saving: what they may grant or remove
* is the assignment matrix's call (`userTypeAssignment`).
*/
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
};
/**
* Creates a user (a password is required: login is password only) or
* updates one, then sets their user types to `user_types`: each one
* granted or removed must pass the assignment matrix, and uneditable ones
* (`super_admin`) stay as they are. The super admin's types are never
* changed here, and they can't be revoked. A user needs at least one type to sign
* in. Email and username stay unique. Returns the user's id and whether
* they're new.
*/
export default async function saveUser({
actor_user_types,
user_id,
first_name,
last_name,
email,
username,
password,
user_types,
}: Params) {
email = email.trim().toLowerCase();
username = username.trim().toLowerCase();
if (!AppData["UsernameRegex"].test(username)) {
throw new AppError(`Usernames must be 3-32 characters: lowercase letters, numbers, dots, dashes or underscores`);
}
if (password || !user_id) {
const password_error = validatePassword({ password });
if (password_error) throw new AppError(password_error);
}
const existing = user_id ? (await grabUsers({ user_id })).singleRes : null;
if (user_id && !existing?.id) {
throw new AppError(`User not found.`);
}
const current = parseUserTypes({ user_types: existing?.user_types });
const is_super_admin = current.includes("super_admin");
if (is_super_admin && user_types.includes("revoked")) {
throw new AppError(`The super admin can't be revoked.`);
}
const kept = is_super_admin ? current : current.filter((type) => UneditableUserTypes.includes(type));
const wanted = is_super_admin
? []
: [...new Set(user_types)].filter((type) => !UneditableUserTypes.includes(type));
const added = wanted.filter((type) => !current.includes(type));
const removed = current.filter((type) => !kept.includes(type) && !wanted.includes(type));
if (!kept.length && !wanted.length) {
throw new AppError(`Give them at least one user type, or they can't sign in.`);
}
for (const [types, action] of [[added, "assign"], [removed, "delete"]] as const) {
for (const target_user_type of types) {
if (!userTypeAssignment({ actor_user_types, target_user_type, target_user_types: current, action })) {
const title = UserTypes.find((type) => type.value == target_user_type)?.title || target_user_type;
throw new AppError(`You can't ${action == "assign" ? "grant" : "remove"} ${title}.`);
}
}
}
const taken = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_USERS>({
sql: `SELECT id, email, username FROM users WHERE (email = ? OR username = ?) AND id != ? LIMIT 1`,
values: [email, username, user_id || 0],
})
).payload?.[0];
if (taken) {
throw new AppError(taken.email == email ? `That email is already in use.` : `That username is taken.`);
}
let target_id = Number(user_id || 0);
if (existing?.id) {
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
table: "users",
targetId: existing.id,
data: { first_name, last_name, email, username },
});
if (!res.success) throw new Error(`Couldn't update the user: ${res.msg}`);
} else {
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
table: "users",
data: [{ first_name, last_name, email, username }],
});
target_id = Number(res.singleRes?.id || res.postInsertReturn?.insertId || 0);
if (!res.success || !target_id) throw new Error(`Couldn't create the user: ${res.msg}`);
}
if (password) {
await setUserPassword({ user_id: target_id, password });
}
if (removed.length) {
await BunSQLite.sql({
sql: `DELETE FROM user_types WHERE user_id = ? AND user_type IN (${removed.map(() => "?").join(", ")})`,
values: [target_id, ...removed],
});
}
if (added.length) {
await BunSQLite.insert<BUN_SQLITE_COBBLE_USER_TYPES, (typeof BunSQLiteTables)[number]>({
table: "user_types",
data: added.map((user_type) => ({ user_id: target_id, user_type })),
});
}
return { user_id: target_id, created: !existing?.id };
}
@@ -1,13 +1,16 @@
import { AppData } from "@/src/data/app-data";
import { ComputerMcpPath } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request";
import ensureDockerNetwork from "./ensure-docker-network";
import grabAppContainer from "./grab-app-container";
import grabFreePort from "./grab-free-port";
import inspectDockerContainer from "./inspect-docker-container";
import pullDockerImage from "./pull-docker-image";
import removeDockerContainer from "./remove-docker-container";
import resolveHostPath from "./resolve-host-path";
import waitForHealthyContainer from "./wait-for-healthy-container";
/**
* Docker takes health check durations in nanoseconds.
*/
const seconds = (value: number) => value * 1e9;
type Params = {
computer_id: number;
@@ -19,22 +22,36 @@ type Params = {
* The computer's home directory as this app sees it.
*/
data_dir: string;
/**
* CPU cores the container may use.
*/
cpus: number;
/**
* The container's memory limit in MB.
*/
memory_mb: number;
};
/**
* Creates and starts an AIO sandbox container for a computer
* (`AppData.ComputerImage`) on the computers' network, with its home
* directory bind-mounted from `data_dir` and restarted with docker. A
* container left by an earlier try is replaced. When the app runs outside
* docker, the sandbox is also published on a localhost port, returned as
* `mcp_url`'s host.
* Creates and starts (the API's `docker run`) an AIO sandbox container
* for a computer (`AppData.ComputerImage`), attached to the computers'
* network (created first when missing), with its home directory
* bind-mounted from `data_dir`, limited to `cpus` and `memory_mb`, with a
* health check (`AppData.ComputerHealthcheck`), and restarted with
* docker. A container left by an earlier try is stopped and replaced.
* Resolves once the health check passes, with its IP address on the network, which a dev
* server outside docker reaches it at, since it can't resolve the
* hostname.
*/
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
export default async function createComputerContainer({
computer_id,
name,
data_dir,
cpus,
memory_mb,
}: Params) {
const network = await ensureDockerNetwork();
const in_docker = Boolean(await grabAppContainer());
const host_dir = await resolveHostPath({ local_path: data_dir });
const port_key = `${AppData.ComputerPort}/tcp`;
const host_port = in_docker ? undefined : grabFreePort();
await pullDockerImage({ image: AppData.ComputerImage });
await removeDockerContainer({ container: name });
@@ -49,18 +66,25 @@ export default async function createComputerContainer({ computer_id, name, data_
Labels: {
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
},
ExposedPorts: { [port_key]: {} },
ExposedPorts: { [`${AppData.ComputerPort}/tcp`]: {} },
Healthcheck: {
Test: [
"CMD-SHELL",
`curl -fs -o /dev/null http://localhost:${AppData.ComputerPort}/v1/sandbox && ([ "$DISABLE_BROWSER" = "true" ] || nc -z localhost "$BROWSER_REMOTE_DEBUGGING_PORT") || exit 1`,
],
Interval: seconds(AppData.ComputerHealthcheck.IntervalSeconds),
Timeout: seconds(AppData.ComputerHealthcheck.TimeoutSeconds),
StartPeriod: seconds(AppData.ComputerHealthcheck.StartPeriodSeconds),
Retries: AppData.ComputerHealthcheck.Retries,
},
HostConfig: {
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
NetworkMode: network,
RestartPolicy: { Name: "unless-stopped" },
SecurityOpt: ["seccomp=unconfined"],
ShmSize: AppData.ComputerShmBytes,
Memory: AppData.ComputerMemoryBytes,
NanoCpus: AppData.ComputerCpus * 1e9,
PortBindings: host_port
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
: undefined,
Memory: memory_mb * 1024 * 1024,
NanoCpus: Math.round(cpus * 1e9),
},
NetworkingConfig: {
EndpointsConfig: { [network]: { Aliases: [name] } },
@@ -81,13 +105,13 @@ export default async function createComputerContainer({ computer_id, name, data_
throw new Error(`Couldn't start the container: ${started.message}`);
}
const state = await inspectDockerContainer({ container: created.data.Id });
const state = await waitForHealthyContainer({ container: created.data.Id });
return {
docker_id: created.data.Id,
network,
status: state?.status || "unknown",
status: state.status,
host_dir,
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
ip_address: state.ip_address,
};
}
@@ -1,16 +0,0 @@
/**
* A port nothing on this host listens on right now, for publishing a
* computer when the app runs outside docker and can't reach it by name.
*/
export default function grabFreePort() {
const listener = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: { data() {} },
});
const port = listener.port;
listener.stop(true);
return port;
}
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import type { ContainerStatus } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request";
@@ -10,11 +11,19 @@ type Params = {
export type DockerContainerState = {
id: string;
/**
* `State.Status`, with `running` narrowed by the health check
* (`starting`, `unhealthy`). A container without one is `running`.
*/
status: ContainerStatus;
/**
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
*/
ports: Record<string, string>;
/**
* Its IP address on `AppData.DockerNetworkName`, when it's on it and running.
*/
ip_address: string | null;
};
/**
@@ -25,8 +34,14 @@ export default async function inspectDockerContainer({
}: Params): Promise<DockerContainerState | null> {
const res = await dockerRequest<{
Id: string;
State?: { Status?: ContainerStatus };
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
State?: {
Status?: ContainerStatus;
Health?: { Status?: "starting" | "healthy" | "unhealthy" | "none" };
};
NetworkSettings?: {
Ports?: Record<string, { HostPort?: string }[] | null>;
Networks?: Record<string, { IPAddress?: string }>;
};
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
if (res.status == 404) return null;
@@ -35,13 +50,20 @@ export default async function inspectDockerContainer({
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
}
const status = res.data.State?.Status || "unknown";
const health = res.data.State?.Health?.Status;
return {
id: res.data.Id,
status: res.data.State?.Status || "unknown",
status:
status == "running" && (health == "starting" || health == "unhealthy")
? health
: status,
ports: Object.fromEntries(
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
),
),
ip_address: res.data.NetworkSettings?.Networks?.[AppData.DockerNetworkName]?.IPAddress || null,
};
}
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import dockerRequest from "./docker-request";
type Params = {
@@ -8,10 +9,17 @@ type Params = {
};
/**
* Stops and removes a container. Its bind-mounted files stay on the host.
* A container docker doesn't have is already gone.
* Stops a container (killed after `AppData.ComputerStopTimeoutSeconds`),
* then removes it. Its bind-mounted files stay on the host. A container
* docker doesn't have is already gone.
*/
export default async function removeDockerContainer({ container }: Params) {
await dockerRequest({
method: "POST",
path: `/containers/${encodeURIComponent(container)}/stop`,
query: { t: AppData.ComputerStopTimeoutSeconds },
});
const res = await dockerRequest({
method: "DELETE",
path: `/containers/${encodeURIComponent(container)}`,
@@ -0,0 +1,37 @@
import { AppData } from "@/src/data/app-data";
import inspectDockerContainer from "./inspect-docker-container";
type Params = {
/**
* The container's id or name.
*/
container: string;
};
/**
* Waits until a container's health check passes (`running`), checking
* every `AppData.ComputerHealthPollMs` for at most
* `AppData.ComputerHealthyTimeoutMs`, and returns its state. Throws when
* it turns unhealthy, stops, or takes too long.
*/
export default async function waitForHealthyContainer({ container }: Params) {
const deadline = Date.now() + AppData.ComputerHealthyTimeoutMs;
while (true) {
const state = await inspectDockerContainer({ container });
if (state?.status == "running") return state;
if (!state || !["starting", "created", "restarting"].includes(state.status)) {
throw new Error(`The container is ${state?.status || "gone"} instead of passing its health check.`);
}
if (Date.now() > deadline) {
throw new Error(
`The container didn't pass its health check within ${Math.round(AppData.ComputerHealthyTimeoutMs / 60000)} minutes.`,
);
}
await Bun.sleep(AppData.ComputerHealthPollMs);
}
}
@@ -1,6 +1,7 @@
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer";
import userAuth from "@/src/functions/backend/auth/user-auth";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import parseComputerProxyPath from "@/src/utils/parse-computer-proxy-path";
type Params = {
@@ -10,8 +11,8 @@ type Params = {
/**
* Where a `/computers/<id>/...` request goes: the computer's own address
* for that path, only for the computer's owner. An error response when
* the user isn't logged in, the computer isn't theirs, or it has no
* container.
* the user isn't logged in, the computer isn't theirs, it has no
* container, or it isn't ready.
*/
export default async function computerProxyTarget({ req }: Params) {
const url = new URL(req.url);
@@ -36,6 +37,10 @@ export default async function computerProxyTarget({ req }: Params) {
return { error: new Response("This computer has no container.", { status: 502 }) };
}
if (!address.ready) {
return { error: new Response(computerNotReadyMessage(address), { status: 503 }) };
}
return {
session,
url,
@@ -1,6 +1,7 @@
import { ComputerMcpExcludedTools } from "@/src/dict/computer-dict";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import redactSecrets from "@/src/utils/redact-secrets";
import connectMcpServer from "../../mcp/connect-mcp-server";
import grabMcpTools from "../../mcp/grab-mcp-tools";
@@ -58,6 +59,10 @@ export default async function openComputerTools({
throw new Error(`Computer #${computer_id} has no container to connect to.`);
}
if (!target.ready) {
throw new Error(computerNotReadyMessage(target));
}
const envs = await grabComputerEnvs({ computer_id, workspace_id });
const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal });
const secrets = envs.filter((env) => env.secret);
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerRef from "@/src/utils/computer-ref";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import redactSecrets from "@/src/utils/redact-secrets";
import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command";
@@ -49,6 +50,8 @@ export default function searchComputersTool({
if (!target) return `${label}\nNot reachable: it has no container.`;
if (!target.ready) return `${label}\nNot reachable: ${computerNotReadyMessage(target)}`;
const [result, envs] = await Promise.all([
runComputerCommand({
base_url: target.base_url,
@@ -4,19 +4,30 @@ import type { ComputerWithStatus } from "@/src/types";
type Params = {
computers: ComputerWithStatus[];
/**
* Also add computers the list doesn't have yet (one just created), for
* a list of every computer the user has.
*/
include_new?: boolean;
};
/**
* A list of computers kept current from `server:computer-updated` (setup
* finishing or failing, a container stopping).
*/
export default function useLiveComputers({ computers: initial }: Params) {
export default function useLiveComputers({ computers: initial, include_new }: 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))),
setComputers((prev) =>
prev.some((c) => c.id == computer.id)
? prev.map((c) => (c.id == computer.id ? { ...c, ...computer } : c))
: include_new
? [...prev, computer]
: prev,
),
});
return { computers, setComputers };
@@ -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}
@@ -23,7 +23,8 @@ type Props = {
*/
export default function ComputerViewerModal({ bot }: Props) {
const { pageProps } = useContext(AppContext);
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
const { computers, viewing, closeComputer } =
useContext(BotComputersContext);
const [handing_back, setHandingBack] = useState(false);
const [error, setError] = useState<string>();
@@ -57,7 +58,7 @@ export default function ComputerViewerModal({ bot }: Props) {
setOpen={(open) => {
if (!open) closeComputer();
}}
className="max-w-[1280px] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
className="max-w-[95vw]! h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
>
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 gap-0! items-start">
@@ -82,7 +83,10 @@ export default function ComputerViewerModal({ bot }: Props) {
{`Hand back to ${bot_name}`}
</Button>
{error ? (
<Span role="alert" className="text-[13px] leading-[18px] text-ink-secondary">
<Span
role="alert"
className="text-[13px] leading-[18px] text-ink-secondary"
>
{error}
</Span>
) : null}
@@ -13,6 +13,7 @@ import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react";
import dayjs from "dayjs";
import UserAvatar from "@/src/components/general/user-avatar";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
@@ -54,6 +55,7 @@ export default function PeopleListCard({ person }: Props) {
>
<Row className="w-full justify-between">
<Row>
<UserAvatar person={person} size="md" />
<Img
alt={`${person_name}`}
src={
@@ -77,10 +79,7 @@ export default function PeopleListCard({ person }: Props) {
size={14}
className="opacity-50"
/>
<Span
variant="faded"
className="text-xs"
>
<Span variant="faded" className="text-xs">
{date_created.toString()}
</Span>
</Row>
@@ -0,0 +1,39 @@
import { useState } from "react";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
type Status = { error?: boolean; success?: boolean; msg?: string };
type Params<T extends { id?: number | string | "" }> = {
items: T[];
};
/**
* A settings list's rows with its busy state and one status line, and
* `remove`: confirm, post to an admin API route, drop the row.
*/
export default function useSettingsList<T extends { id?: number | string | "" }>({ items: initial }: Params<T>) {
const [items, setItems] = useState(initial);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const remove = async ({ id, path, body, confirm }: { id: T["id"]; path: string; body: Record<string, unknown>; confirm: string }) => {
if (busy || !window.confirm(confirm)) return;
setBusy(true);
setStatus(undefined);
const res = await postAdminApi({ path, body });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
setItems((prev) => prev.filter((item) => item.id != id));
setStatus({ success: true, msg: res.msg || "Deleted." });
};
return { items, setItems, busy, status, setStatus, remove };
}
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
avatar: ReactNode;
title: string;
/**
* A word beside the title, e.g. "Default" or "You".
*/
badge?: string;
subtitle?: string;
/**
* Its actions, at the end of the row.
*/
children?: ReactNode;
};
/**
* One row of a settings list: an avatar, a title and one line about it,
* then what can be done with it.
*/
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
{avatar}
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
) : null}
</Stack>
{children ? <Row className="shrink-0 gap-1! flex-nowrap">{children}</Row> : null}
</Row>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
children: ReactNode;
/**
* Shown instead when the list has no rows.
*/
empty?: string;
is_empty?: boolean;
};
/**
* A framed list of rows inside a settings group.
*/
export default function SettingsList({ children, empty, is_empty }: Props) {
return (
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
{children}
{is_empty && empty ? (
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">{empty}</Span>
) : null}
</Stack>
);
}
@@ -3,14 +3,18 @@ import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
label: string;
/**
* A link instead of a button.
*/
href?: string;
disabled?: boolean;
onClick: () => void;
onClick?: () => void;
};
/**
* One small text action on a workspace computer row.
* One small text action on a list row (Edit, Open, Delete).
*/
export default function WorkspaceComputerAction({ title, label, disabled, onClick }: Props) {
export default function SettingsRowAction({ title, label, href, disabled, onClick }: Props) {
return (
<Button
title={title}
@@ -18,6 +22,7 @@ export default function WorkspaceComputerAction({ title, label, disabled, onClic
variant="ghost"
color="gray"
size="small"
href={href}
disabled={disabled}
onClick={onClick}
className="px-2! text-[13px]"
@@ -0,0 +1,32 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
/**
* Open while set.
*/
bot?: BotWithLastMessage;
onClose: () => void;
};
/**
* Edits a bot in a modal, with the same form as adding one (and as the
* chat header's avatar).
*/
export default function BotEditModal({ bot, onClose }: Props) {
return (
<AddModalShell
open={Boolean(bot)}
setOpen={(open) => !open && onClose()}
title={`Edit ${bot?.name || "the bot"}`}
description={
bot?.kind == "coordinator"
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
: "Its core instructions apply everywhere. What it does in one workspace is set on that workspace's page."
}
>
{bot ? <BotForm key={bot.id} bot={bot} onCancel={onClose} /> : null}
</AddModalShell>
);
}
@@ -0,0 +1,44 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
bot: BotWithLastMessage;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One bot: its stone or picture, name and job, then Chat, Edit, and for a
* worker its Models and Delete.
*/
export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
const name = bot.name || `Bot ${bot.id}`;
const is_coordinator = bot.kind == "coordinator";
return (
<SettingsListRow
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
}
title={name}
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
>
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy} onClick={onEdit} />
{is_coordinator ? null : (
<>
<SettingsRowAction title={`${name}'s models`} label="Models" href={`/admin/bots/${bot.id}/models`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</>
)}
</SettingsListRow>
);
}
@@ -0,0 +1,63 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import type { BotWithLastMessage } from "@/src/types";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import BotEditModal from "../(partials)/bot-edit-modal";
import BotSettingsRow from "../(partials)/bot-settings-row";
/**
* The user's crew, coordinator first: chat with a bot, edit it, set its
* models, add one or delete a worker.
*/
export default function BotsSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
const [editing, setEditing] = useState<BotWithLastMessage>();
return (
<SettingsGroup
title="Your crew"
description="The coordinator stays. Deleting a bot removes its conversations, tasks and models."
>
<SettingsList is_empty={!items.length} empty="No bots yet.">
{items.map((bot) => (
<BotSettingsRow
key={bot.id}
bot={bot}
busy={busy}
onEdit={() => setEditing(bot)}
onDelete={() =>
remove({
id: bot.id,
path: "bots/delete-bot",
body: { bot_id: bot.id },
confirm: `Delete ${bot.name || "this bot"}? Its conversations, tasks and models go with it.`,
})
}
/>
))}
</SettingsList>
<Button
title="Add a bot"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "bot" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New bot
</Button>
<SettingsStatus status={status} />
<BotEditModal bot={editing} onClose={() => setEditing(undefined)} />
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
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,
bots: await grabBots({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import BotsSettingsSection from "./(sections)/bots-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsBotsPage() {
return (
<SettingsShell title="Bots" description="Your crew: the coordinator and the bots it hands work to.">
<BotsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Bots" });
@@ -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" });
@@ -32,12 +32,7 @@ export default function ProfilePhotoField({
return (
<Row className="w-full gap-4! flex-nowrap items-center">
<UserAvatar
src={preview_src}
initials={initials}
alt="Your photo"
size="lg"
/>
<UserAvatar src={preview_src} initials={initials} alt="Your photo" size="lg" />
<Stack className="gap-2! min-w-0">
<Row className="gap-2!">
<Button
@@ -0,0 +1,33 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "./user-form";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
open: boolean;
onClose: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Adds or edits a user in a modal.
*/
export default function UserEditModal({ person, open, onClose, onSaved }: Props) {
return (
<AddModalShell
open={open}
setOpen={(next) => !next && onClose()}
title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"}
description={
person
? "Leave the password empty to keep theirs."
: "They get their own coordinator and main computer, and sign in with this password."
}
>
{open ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -0,0 +1,141 @@
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Row from "@moduletrace/twui/layout/Row";
import { useContext, useState } from "react";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import { AppContext } from "@/src/pages/__root";
import type { UserType } from "@/src/types";
import parseUserTypes from "@/src/utils/parse-user-types";
import UserPasswordFields from "./user-password-fields";
import UserTypesSelect from "./user-types-select";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
onCancel: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* A user's name, email, username, password (twice, to confirm) and user
* types. Uneditable types (`super_admin`) aren't offered, and the super
* admin's types aren't shown at all: they never change here.
*/
export default function UserForm({ person, onCancel, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const current_types = parseUserTypes({ user_types: person?.user_types });
const is_super_admin = current_types.includes("super_admin");
const [first_name, setFirstName] = useState(person?.first_name || "");
const [last_name, setLastName] = useState(person?.last_name || "");
const [email, setEmail] = useState(person?.email || "");
const [username, setUsername] = useState(person?.username || "");
const [password, setPassword] = useState("");
const [confirm_password, setConfirmPassword] = useState("");
const [user_types, setUserTypes] = useState<UserType[]>(
person ? current_types.filter((type) => !UneditableUserTypes.includes(type)) : ["member"],
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
if (password != confirm_password) {
setError("The passwords don't match.");
return;
}
setLoading(true);
setError(undefined);
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
path: "users/save-user",
body: {
user_id: person?.id || null,
first_name,
last_name,
email,
username,
password,
confirm_password,
user_types: is_super_admin ? [] : user_types,
},
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't save the user.");
return;
}
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Row className="w-full gap-4! flex-nowrap items-start">
<Input
id="user-form-first-name"
title="First name"
label="First name"
showLabel
placeholder="Ada"
autoComplete="off"
defaultValue={first_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setFirstName(e.target.value)}
/>
<Input
id="user-form-last-name"
title="Last name"
label="Last name"
showLabel
placeholder="Lovelace"
autoComplete="off"
defaultValue={last_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setLastName(e.target.value)}
/>
</Row>
<Input
id="user-form-email"
title="Email"
label="Email"
showLabel
type="email"
placeholder="[email protected]"
autoComplete="off"
defaultValue={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="user-form-username"
title="Username"
label="Username"
showLabel
placeholder="ada"
autoComplete="off"
defaultValue={username}
info="Lowercase letters, numbers, dots, dashes or underscores."
onChange={(e) => setUsername(e.target.value)}
/>
{is_super_admin ? null : (
<UserTypesSelect actor_user_types={pageProps?.user_types} value={user_types} onChange={setUserTypes} />
)}
<UserPasswordFields optional={Boolean(person)} onPassword={setPassword} onConfirm={setConfirmPassword} />
<AddFormActions
submit_label={person ? "Save" : "Add user"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
);
}
@@ -0,0 +1,50 @@
import Input from "@moduletrace/twui/form/Input";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
type Props = {
/**
* Editing someone: both may stay empty to keep their password.
*/
optional?: boolean;
onPassword: (password: string) => void;
onConfirm: (password: string) => void;
};
/**
* The password section of the user form: the password, then the same
* again to confirm it.
*/
export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) {
return (
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
<Stack className="gap-0.5!">
<Span className="text-sm font-bold leading-5 text-ink">{optional ? "New password" : "Password"}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`At least ${AppData.PasswordMinLength} characters.${optional ? " Leave both empty to keep theirs." : " They sign in with it."}`}
</Span>
</Stack>
<Input
id="user-form-password"
title="Password"
label="Password"
showLabel
type="password"
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
autoComplete="new-password"
onChange={(e) => onPassword(e.target.value)}
/>
<Input
id="user-form-confirm-password"
title="Confirm password"
label="Confirm password"
showLabel
type="password"
placeholder="The same password again"
autoComplete="new-password"
onChange={(e) => onConfirm(e.target.value)}
/>
</Stack>
);
}
@@ -0,0 +1,71 @@
import UserAvatar from "@/src/components/general/user-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One user: their photo, name, user types and email, then Edit and
* Delete where the actor may (`canUpdateUser`, `canRevokeUser`; never
* deleting yourself).
*/
export default function UserSettingsRow({
person,
actor_user_id,
actor_user_types,
busy,
onEdit,
onDelete,
}: Props) {
const name =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
const user_types = parseUserTypes({ user_types: person.user_types });
const types_title = UserTypes.filter((type) => user_types.includes(type.value))
.map((type) => type.title)
.join(", ");
const is_self = person.id == actor_user_id;
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
return (
<SettingsListRow
avatar={<UserAvatar person={person} size="md" />}
title={name}
badge={is_self ? "You" : undefined}
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
>
{can_edit ? (
<SettingsRowAction
title={`Edit ${name}`}
label="Edit"
disabled={busy}
onClick={onEdit}
/>
) : null}
{can_delete ? (
<SettingsRowAction
title={`Delete ${name}`}
label="Delete"
disabled={busy}
onClick={onDelete}
/>
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,45 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import type { UserType } from "@/src/types";
import filterAssignableUserTypes from "@/src/utils/rbac/filter-assignable-user-types";
type Props = {
/**
* The session user's types: only what they may grant is offered.
*/
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
value: UserType[];
onChange: (user_types: UserType[]) => void;
};
/**
* Picks a user's types from `UserTypes`, leaving out the uneditable ones
* (`super_admin`) and any the assignment matrix keeps from the actor.
*/
export default function UserTypesSelect({ actor_user_types, value, onChange }: Props) {
const assignable = filterAssignableUserTypes({
actor_user_types,
user_types: UserTypes.map((type) => type.value).filter((type) => !UneditableUserTypes.includes(type)),
});
return (
<SearchSelect
id="user-form-user-types"
title="User types"
label="User types"
showLabel
multiple
placeholder="Pick their user types"
options={UserTypes.filter((type) => assignable.includes(type.value)).map((type) => ({
value: type.value,
title: type.title,
}))}
defaultValues={value}
info="At least one, or they can't sign in."
changeHandler={(next) =>
onChange((Array.isArray(next) ? next : next ? [next] : []) as UserType[])
}
/>
);
}
@@ -0,0 +1,84 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import canCreateUser from "@/src/utils/rbac/can-create-user";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import UserEditModal from "../(partials)/user-edit-modal";
import UserSettingsRow from "../(partials)/user-settings-row";
/**
* Everyone who can sign in (for super admins and admins): add a user
* with a password, edit one (name, email, username, a new password, user
* types), or delete one with everything that's theirs. What each person
* may do here is the RBAC helpers' call.
*/
export default function UsersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({ items: pageProps?.persons || [] });
/**
* The user being edited, or `true` while adding one.
*/
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
return (
<SettingsGroup
title="People"
description="Sign in is by email or username and password, so set one for each new user."
>
<SettingsList is_empty={!items.length} empty="No users yet.">
{items.map((person) => (
<UserSettingsRow
key={person.id}
person={person}
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
busy={busy}
onEdit={() => setEditing(person)}
onDelete={() =>
remove({
id: person.id,
path: "users/delete-user",
body: { user_id: person.id },
confirm: `Delete ${person.first_name || "this user"} with their bots, workspaces and computers? Their computers' files stay on disk.`,
})
}
/>
))}
</SettingsList>
{canCreateUser({ user_types: pageProps?.user_types }) ? (
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setEditing(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
) : null}
<SettingsStatus status={status} />
<UserEditModal
person={editing === true ? undefined : editing}
open={Boolean(editing)}
onClose={() => setEditing(undefined)}
onSaved={({ person, msg }) => {
setItems((prev) =>
prev.some((p) => p.id == person.id)
? prev.map((p) => (p.id == person.id ? person : p))
: [...prev, person],
);
setEditing(undefined);
setStatus({ success: true, msg });
}}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,30 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import canListUsers from "@/src/utils/rbac/can-list-users";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* For those who may list users (`canListUsers`: super admins, admins);
* everyone else lands on the general settings.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props) return settings;
const { user_types } = await userAuth({ req: params.req });
if (!canListUsers({ user_types })) {
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
}
return {
props: {
...settings.props,
persons: (await grabUsers()).payload || [],
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import UsersSettingsSection from "./(sections)/users-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsUsersPage() {
return (
<SettingsShell title="Users" description="Who can sign in to this install. Each has their own crew and computers.">
<UsersSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Users" });
@@ -0,0 +1,29 @@
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { WorkspaceWithActivity } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
workspace: WorkspaceWithActivity;
busy?: boolean;
onDelete: () => void;
};
/**
* One workspace: its picture, name and what it's about, then Open and
* Delete.
*/
export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Props) {
const name = workspace.name || `Workspace ${workspace.id}`;
return (
<SettingsListRow
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
title={name}
subtitle={workspace.description || undefined}
>
<SettingsRowAction title={`Open ${name}`} label="Open" href={`/admin/workspaces/${workspace.id}`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</SettingsListRow>
);
}
@@ -0,0 +1,58 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import WorkspaceSettingsRow from "../(partials)/workspace-settings-row";
/**
* Every workspace the user has: open one to set it up (its page has its
* context, bots, computers and models), create one, or delete one.
*/
export default function WorkspacesSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.workspaces || [] });
return (
<SettingsGroup
title="Your workspaces"
description="Bots in a workspace share its context and computers. Deleting one keeps its bots and computers."
>
<SettingsList is_empty={!items.length} empty="No workspaces yet.">
{items.map((workspace) => (
<WorkspaceSettingsRow
key={workspace.id}
workspace={workspace}
busy={busy}
onDelete={() =>
remove({
id: workspace.id,
path: "workspaces/delete-workspace",
body: { workspace_id: workspace.id },
confirm: `Delete ${workspace.name || "this workspace"} and its conversations? Its bots and computers stay.`,
})
}
/>
))}
</SettingsList>
<Button
title="Create a workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "workspace" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New workspace
</Button>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
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,
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
},
};
};
@@ -0,0 +1,13 @@
import WorkspacesSettingsSection from "./(sections)/workspaces-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsWorkspacesPage() {
return (
<SettingsShell title="Workspaces" description="Projects with their own context, bots, computers and models.">
<WorkspacesSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Workspaces" });
@@ -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"
+51
View File
@@ -0,0 +1,51 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteBot from "@/src/functions/backend/db/bots/delete-bot";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { BotIdRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Deletes one of the session user's worker bots and everything it had
* (conversations, tasks, models). The coordinator stays.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
const bot = await deleteBot({ user_id: user.id, bot_id });
await logEvent({
title: "Bot deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted the bot ${bot.name}.`,
metadata: { bot_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: `${bot.name || "The bot"} is deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -42,13 +42,19 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus
throw new AppError(`Only a super admin can do that.`);
}
const { name, description, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
const { name, description, cpus, memory_mb, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
throw new AppError(`Workspace not found.`);
}
const { computer_id } = await createComputer({ user_id: user.id, name, description });
const { computer_id } = await createComputer({
user_id: user.id,
name,
description,
cpus,
memory_mb,
});
if (workspace_id) {
await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id });
@@ -0,0 +1,67 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import updateComputer from "@/src/functions/backend/db/computers/update-computer";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { ComputerWithStatus } from "@/src/types";
import { UpdateComputerRequestSchema } from "@/src/schemas/computer-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Updates a computer of the session user (super admins only: each one is
* a container on this host). New resources (`cpus`, `memory_mb`) recreate
* its container in the background, its files kept; its state arrives as
* `server:computer-updated`. Returns the computer.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
if (!isPassThroughUser({ user_types })) {
throw new AppError(`Only a super admin can do that.`);
}
const { computer_id, ...data } = parseWithSchema({ schema: UpdateComputerRequestSchema, data: body });
const { recreating } = await updateComputer({ user_id: user.id, computer_id, data });
const computer = (await grabUserComputers({ computer_id }))[0];
const name = computer?.name || "The computer";
await logEvent({
title: "Computer updated",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} updated the computer ${name}.`,
metadata: { computer_id, ...data, recreating },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: recreating ? `${name} is restarting with its new settings.` : `${name} saved.`,
singleRes: recreating ? { ...computer, status: "creating", error: null } : computer,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+50
View File
@@ -0,0 +1,50 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteUser from "@/src/functions/backend/db/users/delete-user";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { UserIdRequestSchema } from "@/src/schemas/user-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Deletes a user and everything that's theirs, when the actor could
* revoke them (`canRevokeUser`), never themselves.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { user_id } = parseWithSchema({ schema: UserIdRequestSchema, data: body });
const deleted = await deleteUser({ actor_user_id: user.id, actor_user_types: user_types || [], user_id });
await logEvent({
title: "User deleted",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} deleted user #${user_id} (${deleted.email}).`,
metadata: { user_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: `${deleted.first_name || "The user"} is deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+96
View File
@@ -0,0 +1,96 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import saveUser from "@/src/functions/backend/db/users/save-user";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import canCreateUser from "@/src/utils/rbac/can-create-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import parseUserTypes from "@/src/utils/parse-user-types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
* name, email, username, password and user types, each type checked
* against the assignment matrix. A new user gets their coordinator and main computer
* straight away, as at signup. Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const data = parseWithSchema({ schema: SaveUserRequestSchema, data: body });
const target = data.user_id ? (await grabUsers({ user_id: data.user_id })).singleRes : null;
const allowed = data.user_id
? canUpdateUser({
actor_user_types: user_types,
actor_user_id: user.id,
target_user_id: data.user_id,
target_user_types: parseUserTypes({ user_types: target?.user_types }).map((user_type) => ({ user_type })),
update_fields: target && target.email != data.email.trim().toLowerCase() ? ["email"] : [],
})
: canCreateUser({ user_types });
if (!allowed) {
throw new AppError(`You can't do that.`);
}
const { user_id, created } = await saveUser({ ...data, actor_user_types: user_types || [] });
if (created) {
/**
* The account exists now; `/admin/bots` creates both on their
* first visit if this fails.
*/
await ensureCoordinatorBot({ user_id })
.then((coordinator_id) => ensureMainComputer({ user_id, coordinator_id }))
.catch((error) =>
logError({
title: "Coordinator or main computer not created with the user",
error,
user_id,
ip_address: grabRequestIP(req),
}).catch(() => {}),
);
}
await logEvent({
title: created ? "User created" : "User updated",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} ${created ? "created" : "updated"} user #${user_id}.`,
metadata: { user_id, user_types: data.user_types, password_changed: Boolean(data.password) },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: created ? `${data.first_name} can sign in now.` : `Saved.`,
singleRes: (await grabUsers({ user_id })).singleRes,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+30 -2
View File
@@ -1,8 +1,27 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
const ComputerCpusSchema = z.coerce
.number()
.min(AppData.ComputerMinCpus, `Give it at least ${AppData.ComputerMinCpus} CPU`)
.max(AppData.ComputerMaxCpus, `Keep it under ${AppData.ComputerMaxCpus} CPUs`);
const ComputerMemoryMbSchema = z.coerce
.number()
.int()
.min(AppData.ComputerMinMemoryMb, `Give it at least ${AppData.ComputerMinMemoryMb} MB of memory`);
const ComputerNameSchema = z.string().trim().min(1, "Give the computer a name").max(60, "Keep the name under 60 characters");
const ComputerDescriptionSchema = z.string().trim().max(240, "Keep the description short");
export const CreateComputerRequestSchema = z.object({
name: z.string().trim().min(1, "Give the computer a name").max(60, "Keep the name under 60 characters"),
description: z.string().trim().max(240, "Keep the description short").default(""),
name: ComputerNameSchema,
description: ComputerDescriptionSchema.default(""),
/**
* Defaults to `AppData.ComputerDefaultCpus` / `ComputerDefaultMemoryMb`.
*/
cpus: ComputerCpusSchema.optional(),
memory_mb: ComputerMemoryMbSchema.optional(),
/**
* Also link it to this workspace.
*/
@@ -16,3 +35,12 @@ export const ComputerIdRequestSchema = z.object({
});
export type ComputerIdRequest = z.infer<typeof ComputerIdRequestSchema>;
export const UpdateComputerRequestSchema = ComputerIdRequestSchema.extend({
name: ComputerNameSchema.optional(),
description: ComputerDescriptionSchema.optional(),
cpus: ComputerCpusSchema.optional(),
memory_mb: ComputerMemoryMbSchema.optional(),
});
export type UpdateComputerRequest = z.infer<typeof UpdateComputerRequestSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { UserTypes } from "@/src/dict/user-types-dict";
export const SaveUserRequestSchema = z
.object({
/**
* Updates this user; creates one when not given.
*/
user_id: z.coerce.number().int().positive().nullish(),
first_name: z.string().trim().min(1, "Enter their first name").max(100),
last_name: z.string().trim().max(100).default(""),
email: z.email("Enter a valid email address"),
username: z.string().trim().min(3, "Usernames must be at least 3 characters").max(32, "Usernames must be at most 32 characters"),
/**
* Required for a new user; on an existing one, empty keeps theirs.
*/
password: z.string().max(128).default(""),
confirm_password: z.string().max(128).default(""),
/**
* Every user type they should hold (`UserTypes`). Types the actor may
* not grant or remove (`super_admin`, the assignment matrix) are left
* as they are.
*/
user_types: z.array(z.enum(UserTypes.map((type) => type.value))).default([]),
})
.refine((data) => data.password == data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
});
export type SaveUserRequest = z.infer<typeof SaveUserRequestSchema>;
export const UserIdRequestSchema = z.object({
user_id: z.coerce.number().int().positive("Pick a user"),
});
export type UserIdRequest = z.infer<typeof UserIdRequestSchema>;
+13
View File
@@ -10,6 +10,7 @@ import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import { ComputerProxyPrefix } from "./dict/computer-dict";
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
declare global {}
@@ -21,6 +22,18 @@ const port = process.env.PORT || 7227;
await enableForeignKeys();
await bunext.bunextInit();
/**
* Computers are docker containers on this network, so the app doesn't
* start without docker.
*/
try {
await ensureDockerNetwork();
} catch (error) {
await logError({ title: "Docker isn't available", error }).catch(() => {});
console.error(`Can't start without docker: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const server = Bun.serve({
async fetch(req, server) {
try {
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import computerNotReadyMessage from "./computer-not-ready-message";
describe("computerNotReadyMessage", () => {
test("names the computer and its state", () => {
expect(computerNotReadyMessage({ name: "Main computer", status: "starting" })).toBe(
"Main computer isn't ready yet: it's starting.",
);
});
test("falls back to a generic name", () => {
expect(computerNotReadyMessage({ status: "creating" })).toBe("The computer isn't ready yet: it's creating.");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { ContainerStatuses, type ContainerStatus } from "@/src/dict/computer-dict";
type Params = {
name?: string | null;
status: ContainerStatus;
};
/**
* Why a computer can't be reached yet, e.g. "Main computer isn't ready
* yet: it's starting."
*/
export default function computerNotReadyMessage({ name, status }: Params) {
const title = ContainerStatuses.find((s) => s.value == status)?.title || "Unknown";
return `${name || "The computer"} isn't ready yet: it's ${title.toLowerCase()}.`;
}
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import computerResourcesLabel from "./computer-resources-label";
describe("computerResourcesLabel", () => {
test("shows what the computer has", () => {
expect(computerResourcesLabel({ computer: { cpus: 2, memory_mb: 4096 } })).toBe("2 CPUs · 4 GB");
expect(computerResourcesLabel({ computer: { cpus: 0.5, memory_mb: 1536 } })).toBe("0.5 CPUs · 1.5 GB");
});
test("falls back to the defaults", () => {
expect(computerResourcesLabel({ computer: { cpus: null as never, memory_mb: undefined } })).toBe("1 CPU · 2 GB");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { AppData } from "@/src/data/app-data";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
type Params = {
computer: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">;
};
/**
* A computer's resources in a few words, e.g. "1 CPU · 2 GB". One saved
* without them gets the defaults when it's next set up.
*/
export default function computerResourcesLabel({ computer }: Params) {
const cpus = Number(computer.cpus) || AppData.ComputerDefaultCpus;
const gb = (Number(computer.memory_mb) || AppData.ComputerDefaultMemoryMb) / 1024;
return `${cpus} CPU${cpus == 1 ? "" : "s"} · ${Number(gb.toFixed(2))} GB`;
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import parseUserTypes from "./parse-user-types";
describe("parseUserTypes", () => {
test("splits the joined types, in dict order", () => {
expect(parseUserTypes({ user_types: "read_only, admin" })).toEqual(["admin", "read_only"]);
});
test("drops unknown types and handles none", () => {
expect(parseUserTypes({ user_types: "auditor,admin" })).toEqual(["admin"]);
expect(parseUserTypes({ user_types: null })).toEqual([]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { UserTypes } from "@/src/dict/user-types-dict";
import type { UserType } from "@/src/types";
type Params = {
/**
* The user's types, comma separated (`grabUsers`' `user_types`).
*/
user_types?: string | null;
};
/**
* A user's types as a list, only the ones `UserTypes` defines.
*/
export default function parseUserTypes({ user_types }: Params): UserType[] {
const values = (user_types || "").split(",").map((type) => type.trim());
return UserTypes.map((type) => type.value).filter((type) => values.includes(type));
}
+12 -17
View File
@@ -8,10 +8,10 @@ import {
filterAssignableUserTypes,
isPassThroughUser,
} from "./";
import {
ALL_USER_TYPES,
makeUserTypes,
} from "./user-type-assignment.test-fixtures";
import { UserTypes } from "@/src/dict/user-types-dict";
import { makeUserTypes } from "./user-type-assignment.test-fixtures";
const ALL_USER_TYPES = UserTypes.map((type) => type.value);
describe("isPassThroughUser", () => {
test("super_admin is pass-through", () => {
@@ -19,7 +19,7 @@ describe("isPassThroughUser", () => {
});
test("regular types are not pass-through", () => {
for (const type of ["admin", "president", "secretary"]) {
for (const type of ["admin", "member", "read_only"]) {
expect(
isPassThroughUser({ user_types: makeUserTypes(type) }),
).toBe(false);
@@ -45,7 +45,7 @@ describe("canListUsers / canCreateUser / canManageUserTypes", () => {
});
test("non-admin types fail", () => {
for (const type of ["auditor", "president", "secretary", "read_only"]) {
for (const type of ["member", "read_only", "revoked"]) {
expect(
canListUsers({ user_types: makeUserTypes(type) }),
).toBe(false);
@@ -63,7 +63,7 @@ describe("canUpdateUser", () => {
test("actors may update their own record", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("auditor"),
actor_user_types: makeUserTypes("member"),
actor_user_id: 5,
target_user_id: 5,
}),
@@ -108,7 +108,7 @@ describe("canUpdateUser", () => {
test("non-admin actors cannot update others", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("auditor"),
actor_user_types: makeUserTypes("member"),
actor_user_id: 1,
target_user_id: 2,
update_fields: ["first_name"],
@@ -155,7 +155,7 @@ describe("canRevokeUser", () => {
expect(
canRevokeUser({
user_types: makeUserTypes("admin"),
target_user_types: makeUserTypes("president"),
target_user_types: makeUserTypes("super_admin"),
}),
).toBe(false);
});
@@ -175,17 +175,12 @@ describe("filterAssignableUserTypes", () => {
actor_user_types: makeUserTypes("admin"),
user_types: ALL_USER_TYPES,
});
expect(assignable).toContain("auditor");
expect(assignable).toContain("read_only");
expect(assignable).not.toContain("admin");
expect(assignable).not.toContain("super_admin");
expect(assignable).not.toContain("president");
expect(assignable).not.toContain("secretary");
expect(assignable).toEqual(["member", "read_only", "revoked"]);
});
test("secretary can assign nothing", () => {
test("a member can assign nothing", () => {
const assignable = filterAssignableUserTypes({
actor_user_types: makeUserTypes("secretary"),
actor_user_types: makeUserTypes("member"),
user_types: ALL_USER_TYPES,
});
expect(assignable).toHaveLength(0);
+25
View File
@@ -0,0 +1,25 @@
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import type { User } from "../types";
type Params = {
first_name?: string | null;
last_name?: string | null;
};
/**
* A user's initials for their avatar, e.g. "AB".
*/
export default function userInitials(
params?: User | BUN_SQLITE_COBBLE_USERS | null,
) {
if (!params?.first_name) {
return "NA";
}
const { first_name, last_name } = params;
return [first_name, last_name]
.filter(Boolean)
.map((part) => part!.charAt(0).toUpperCase())
.join("");
}