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:
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
Reference in New Issue
Block a user