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,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`;
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import computerStatusLabel from "./computer-status-label";
describe("computerStatusLabel", () => {
test("names the state", () => {
expect(computerStatusLabel({ computer: { id: 1, status: "running" } })).toBe("Running");
expect(computerStatusLabel({ computer: { id: 1, status: "creating" } })).toBe("Setting up…");
expect(computerStatusLabel({ computer: { id: 1 } })).toBe("Unknown");
});
test("says why setup failed", () => {
expect(computerStatusLabel({ computer: { id: 1, status: "failed", error: "no docker" } })).toBe(
"Setup failed: no docker",
);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { ContainerStatuses } from "@/src/dict/computer-dict";
import type { ComputerWithStatus } from "@/src/types";
type Params = {
computer: ComputerWithStatus;
};
/**
* A computer's state in a few words.
*/
export default function computerStatusLabel({ computer }: Params) {
if (computer.status == "creating") return "Setting up…";
if (computer.status == "failed") return computer.error ? `Setup failed: ${computer.error}` : "Setup failed";
return ContainerStatuses.find((s) => s.value == computer.status)?.title || "Unknown";
}
+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("");
}