From f24c0d7e829f632d84396a50dfddbb9ca339a91c Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 10:26:44 +0100 Subject: [PATCH] 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 --- AGENTS.md | 13 +- Dockerfile | 4 + bun-sqlite.config.ts | 12 +- db/schema.ts | 15 ++ db/types/db.ts | 12 ++ deploy/deploy-prod.sh | 17 +-- deploy/run-app-container.sh | 59 ++++++++ entrypoint.sh | 8 +- src/components/general/computer-icon-tile.tsx | 13 ++ src/components/general/user-avatar.tsx | 28 +++- src/cron/sync-computers/index.ts | 34 ++--- src/data/app-data.ts | 43 +++++- src/dict/computer-dict.ts | 15 +- src/dict/env-dict.ts | 1 + src/dict/settings-pages-dict.ts | 21 +++ src/dict/user-types-dict.test.ts | 35 ++--- src/dict/user-types-dict.ts | 86 ++++------- .../backend/auth/check-user-access.test.ts | 4 +- src/functions/backend/db/bots/delete-bot.ts | 38 +++++ .../backend/db/computers/create-computer.ts | 16 +- .../db/computers/grab-computer-mcp-url.ts | 73 +++++---- .../computers/pick-reachable-address.test.ts | 26 ++++ .../db/computers/pick-reachable-address.ts | 53 +++++++ .../db/computers/provision-computer.ts | 33 ++-- .../computers/refresh-computer-container.ts | 39 +++++ .../backend/db/computers/update-computer.ts | 80 ++++++++++ src/functions/backend/db/users/delete-user.ts | 98 ++++++++++++ src/functions/backend/db/users/grab-users.ts | 19 +-- src/functions/backend/db/users/save-user.ts | 140 +++++++++++++++++ .../docker/create-computer-container.ts | 70 ++++++--- .../backend/docker/grab-free-port.ts | 16 -- .../docker/inspect-docker-container.ts | 28 +++- .../backend/docker/remove-docker-container.ts | 12 +- .../docker/wait-for-healthy-container.ts | 37 +++++ .../server/computers/computer-proxy-target.ts | 9 +- .../tools/computer/open-computer-tools.ts | 5 + .../tools/computer/search-computers-tool.ts | 3 + .../(hooks) => hooks}/use-live-computers.ts | 15 +- .../bots/(partials)/add-computer-form.tsx | 93 ++++++++++-- src/layouts/bots/(partials)/add-flow.tsx | 8 +- .../bots/(partials)/rail-back-link.tsx | 10 +- .../bots/(partials)/rail-settings-list.tsx | 9 +- .../(partials)/computer-viewer-modal.tsx | 10 +- .../people/(partials)/people-list-card.tsx | 7 +- .../settings/(hooks)/use-settings-list.ts | 39 +++++ .../settings/(partials)/settings-list-row.tsx | 40 +++++ .../settings/(partials)/settings-list.tsx | 26 ++++ .../(partials)/settings-row-action.tsx} | 11 +- .../bots/(partials)/bot-edit-modal.tsx | 32 ++++ .../bots/(partials)/bot-settings-row.tsx | 44 ++++++ .../bots/(sections)/bots-settings-section.tsx | 63 ++++++++ src/pages/admin/settings/bots/index.server.ts | 17 +++ src/pages/admin/settings/bots/index.tsx | 13 ++ .../(partials)/computer-edit-modal.tsx | 30 ++++ .../(partials)/computer-settings-row.tsx | 45 ++++++ .../(sections)/computers-settings-section.tsx | 103 +++++++++++++ .../admin/settings/computers/index.server.ts | 17 +++ src/pages/admin/settings/computers/index.tsx | 13 ++ .../(partials)/profile-photo-field.tsx | 7 +- .../users/(partials)/user-edit-modal.tsx | 33 ++++ .../settings/users/(partials)/user-form.tsx | 141 ++++++++++++++++++ .../users/(partials)/user-password-fields.tsx | 50 +++++++ .../users/(partials)/user-settings-row.tsx | 71 +++++++++ .../users/(partials)/user-types-select.tsx | 45 ++++++ .../(sections)/users-settings-section.tsx | 84 +++++++++++ .../admin/settings/users/index.server.ts | 30 ++++ src/pages/admin/settings/users/index.tsx | 13 ++ .../(partials)/workspace-settings-row.tsx | 29 ++++ .../workspaces-settings-section.tsx | 58 +++++++ .../admin/settings/workspaces/index.server.ts | 17 +++ src/pages/admin/settings/workspaces/index.tsx | 13 ++ .../(partials)/workspace-computer-row.tsx | 54 +++---- .../(partials)/workspace-computers-group.tsx | 17 +-- src/pages/api/admin/bots/delete-bot.ts | 51 +++++++ .../api/admin/computers/create-computer.ts | 10 +- .../api/admin/computers/update-computer.ts | 67 +++++++++ src/pages/api/admin/users/delete-user.ts | 50 +++++++ src/pages/api/admin/users/save-user.ts | 96 ++++++++++++ src/schemas/computer-schema.ts | 32 +++- src/schemas/user-schema.ts | 37 +++++ src/server.ts | 13 ++ src/utils/computer-not-ready-message.test.ts | 14 ++ src/utils/computer-not-ready-message.ts | 16 ++ src/utils/computer-resources-label.test.ts | 13 ++ src/utils/computer-resources-label.ts | 17 +++ .../computer-status-label.test.ts | 0 .../computer-status-label.ts | 0 src/utils/parse-user-types.test.ts | 13 ++ src/utils/parse-user-types.ts | 18 +++ src/utils/rbac/index.test.ts | 29 ++-- src/utils/user-initials.ts | 25 ++++ 91 files changed, 2692 insertions(+), 331 deletions(-) create mode 100755 deploy/run-app-container.sh create mode 100644 src/components/general/computer-icon-tile.tsx create mode 100644 src/functions/backend/db/bots/delete-bot.ts create mode 100644 src/functions/backend/db/computers/pick-reachable-address.test.ts create mode 100644 src/functions/backend/db/computers/pick-reachable-address.ts create mode 100644 src/functions/backend/db/computers/refresh-computer-container.ts create mode 100644 src/functions/backend/db/computers/update-computer.ts create mode 100644 src/functions/backend/db/users/delete-user.ts create mode 100644 src/functions/backend/db/users/save-user.ts delete mode 100644 src/functions/backend/docker/grab-free-port.ts create mode 100644 src/functions/backend/docker/wait-for-healthy-container.ts rename src/{pages/admin/workspaces/[workspace_id]/(hooks) => hooks}/use-live-computers.ts (50%) create mode 100644 src/pages/admin/settings/(hooks)/use-settings-list.ts create mode 100644 src/pages/admin/settings/(partials)/settings-list-row.tsx create mode 100644 src/pages/admin/settings/(partials)/settings-list.tsx rename src/pages/admin/{workspaces/[workspace_id]/(partials)/workspace-computer-action.tsx => settings/(partials)/settings-row-action.tsx} (66%) create mode 100644 src/pages/admin/settings/bots/(partials)/bot-edit-modal.tsx create mode 100644 src/pages/admin/settings/bots/(partials)/bot-settings-row.tsx create mode 100644 src/pages/admin/settings/bots/(sections)/bots-settings-section.tsx create mode 100644 src/pages/admin/settings/bots/index.server.ts create mode 100644 src/pages/admin/settings/bots/index.tsx create mode 100644 src/pages/admin/settings/computers/(partials)/computer-edit-modal.tsx create mode 100644 src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx create mode 100644 src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx create mode 100644 src/pages/admin/settings/computers/index.server.ts create mode 100644 src/pages/admin/settings/computers/index.tsx create mode 100644 src/pages/admin/settings/users/(partials)/user-edit-modal.tsx create mode 100644 src/pages/admin/settings/users/(partials)/user-form.tsx create mode 100644 src/pages/admin/settings/users/(partials)/user-password-fields.tsx create mode 100644 src/pages/admin/settings/users/(partials)/user-settings-row.tsx create mode 100644 src/pages/admin/settings/users/(partials)/user-types-select.tsx create mode 100644 src/pages/admin/settings/users/(sections)/users-settings-section.tsx create mode 100644 src/pages/admin/settings/users/index.server.ts create mode 100644 src/pages/admin/settings/users/index.tsx create mode 100644 src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx create mode 100644 src/pages/admin/settings/workspaces/(sections)/workspaces-settings-section.tsx create mode 100644 src/pages/admin/settings/workspaces/index.server.ts create mode 100644 src/pages/admin/settings/workspaces/index.tsx create mode 100644 src/pages/api/admin/bots/delete-bot.ts create mode 100644 src/pages/api/admin/computers/update-computer.ts create mode 100644 src/pages/api/admin/users/delete-user.ts create mode 100644 src/pages/api/admin/users/save-user.ts create mode 100644 src/schemas/user-schema.ts create mode 100644 src/utils/computer-not-ready-message.test.ts create mode 100644 src/utils/computer-not-ready-message.ts create mode 100644 src/utils/computer-resources-label.test.ts create mode 100644 src/utils/computer-resources-label.ts rename src/{pages/admin/workspaces/[workspace_id]/(utils) => utils}/computer-status-label.test.ts (100%) rename src/{pages/admin/workspaces/[workspace_id]/(utils) => utils}/computer-status-label.ts (100%) create mode 100644 src/utils/parse-user-types.test.ts create mode 100644 src/utils/parse-user-types.ts create mode 100644 src/utils/user-initials.ts diff --git a/AGENTS.md b/AGENTS.md index c3eeebb..26f157a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w - `/media/*` short-circuits to the media server handler - `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/` - everything else goes to `bunext.bunextRequestHandler` +- `ensureDockerNetwork()` makes sure the computers' docker network (`AppData.DockerNetworkName`) exists before the server starts; without docker the process exits (computers are containers, so nothing works without it). Creating a computer's container checks it again first - `cron()` and the bots queue handler (`botsQueue()`, `src/bots-queue/`) start after the server is up, then `liveRelay()` (`src/live-relay/`, every process, ungated); in production cron and the queue are gated to the `MAIN_SERVER` container ## Commands @@ -63,6 +64,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Signup:** first-run only. `api/auth/signup.tsx` refuses once any user exists, creates the super admin (`create-super-admin-user.ts`, so user #1) with a required password, and logs them in. No Google signup. `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) is the headless alternative. - **Passwords:** bcrypt hashes live in the separate `user_passwords` table (never in `users`; blocked from the CRUD API via `src/dict/private-tables.ts`). Set/change at `/admin/settings/change-password`. - **Auth layout:** `/auth/*` (except `/auth/logout`) renders in `src/layouts/auth/` with no site header/footer. +- **User types** (`UserTypes` in `src/dict/user-types-dict.ts`, the only place they're defined): `super_admin` (the first account, runs the install), `admin` (manages members; only the super admin grants it), `member` (a regular user: new users start with it), `read_only`, `revoked` (can't sign in). A user needs at least one to sign in. Admins grant and remove `member`, `read_only` and `revoked` on anyone but admins and the super admin. - **RBAC:** `src/utils/check-user-access.ts` (includes/excludes primitive, `super_admin` bypass) plus the declarative engine in `src/utils/rbac/` - `userTypeAssignment` interprets `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix` in `src/dict/user-types-dict.ts`. `read_only` users are blocked from non-GET CRUD in the router. - **Audit trail:** `src/functions/backend/logger.ts` - `logEvent`/`logError` write to the `logs` table. Signup, login, Google login, logout, cron runs, and WebSocket connects are all logged. @@ -98,11 +100,16 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`. - **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`). - **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace page, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`), all ownership-checked against the session user; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies). -- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `-`. A computer's home (`/home/gem`) is `DATA_DIR/computers/` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker the container is published on a free localhost port. Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. Each user has one main computer, created with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`. +- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `-` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. Each user has one main computer, created with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`. - **Opening a computer:** the app serves each computer to its owner under `/computers//` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, ownership via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`). - **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to ", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. +- **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`). - **Not built yet:** routines, help, task steps (nothing writes `bot_task_steps` yet). +### Production container + +`deploy/run-app-container.sh` builds the image and (re)runs the app's container (`deploy/deploy-prod.sh` calls it on the server); rerun it to update. The app container and every computer container share the `cobble` network. Mounts: `.env` at `/app/.env` (read only; bun loads it), the host data dir (`COBBLE_DATA_DIR`, default `/data/cobble`) at `/data`, and the docker socket. Everything that must survive an update lives on `/data`: media and computers' home dirs (`DATA_DIR=/data`), the database with its backups and exports (`DB_DIR=/data/db`, read by `bun-sqlite.config.ts`; the schema always comes from `db/schema.ts`), and bun-sqlite's live schema record (`entrypoint.sh` links `/app/.bun-sqlite` there). `MAIN_SERVER=1` is set on that one container. + ### Envs Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). @@ -122,7 +129,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T - **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`). - **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. -- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (container `metadata.mcp_url`, else the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://:/mcp`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). +- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://:/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`. - **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data. @@ -134,7 +141,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/` - `/admin/logs` - audit log (super admin; the "Activity" item in the bots popover is disabled while this is closed) - `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import ` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` -- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry +- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). diff --git a/Dockerfile b/Dockerfile index 081da55..541a358 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,10 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* ENV NODE_ENV=production +# Everything that must survive an update lives on the /data volume: +# the database, media, computers' home directories +ENV DATA_DIR=/data +ENV DB_DIR=/data/db ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1 WORKDIR /app diff --git a/bun-sqlite.config.ts b/bun-sqlite.config.ts index 52c33e0..6c89038 100644 --- a/bun-sqlite.config.ts +++ b/bun-sqlite.config.ts @@ -1,10 +1,18 @@ +import path from "path"; import type { BunSQLiteConfig } from "@moduletrace/bun-sqlite/types"; +/** + * The database lives in `DB_DIR` when it's set (the app's container puts + * it on the data volume, so it survives updates), else in `./db`. The + * schema always comes from the code, `db/schema.ts`. + */ +const db_dir = process.env.DB_DIR || "./db"; + const config: BunSQLiteConfig = { db_name: "cobble", - db_dir: "./db", + db_dir, typedef_file_path: "./db/types/db.ts", - db_schema_file_name: "schema.ts", + db_schema_file_name: path.relative(path.resolve(db_dir), path.resolve("db/schema.ts")), }; export default config; diff --git a/db/schema.ts b/db/schema.ts index 36e6ba1..43846e0 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -893,6 +893,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { fieldDescription: `Address the main container reaches it on`, dataType: "TEXT", }, + { + fieldName: "ip_address", + fieldDescription: `Its IP address on the network, tried when the hostname can't be reached (a dev server outside docker)`, + dataType: "TEXT", + }, { fieldName: "port", fieldDescription: `Port the service listens on inside the container`, @@ -960,6 +965,16 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { fieldName: "description", dataType: "TEXT", }, + { + fieldName: "cpus", + fieldDescription: `CPU cores the container may use (docker NanoCpus), set when the computer is created`, + dataType: "REAL", + }, + { + fieldName: "memory_mb", + fieldDescription: `Memory limit of the container in MB, set when the computer is created`, + dataType: "INTEGER", + }, ], indexes: [ { diff --git a/db/types/db.ts b/db/types/db.ts index 6c6f121..96d8a2a 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -576,6 +576,10 @@ export type BUN_SQLITE_COBBLE_CONTAINERS = { * Address the main container reaches it on */ hostname?: string; + /** + * Its IP address on the network, tried when the hostname can't be reached (a dev server outside docker) + */ + ip_address?: string; /** * Port the service listens on inside the container */ @@ -621,6 +625,14 @@ export type BUN_SQLITE_COBBLE_COMPUTERS = { */ kind?: "main" | "sandbox" | ""; description?: string; + /** + * CPU cores the container may use (docker NanoCpus), set when the computer is created + */ + cpus?: number | ""; + /** + * Memory limit of the container in MB, set when the computer is created + */ + memory_mb?: number | ""; } export type BUN_SQLITE_COBBLE_BOT_COMPUTERS = { diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh index f2d374b..1d57423 100755 --- a/deploy/deploy-prod.sh +++ b/deploy/deploy-prod.sh @@ -20,7 +20,7 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi -DEPLOY_NAME="bunext-sqlite" +DEPLOY_NAME="cobble" SOURCE_PATH="${SOURCE_DIRECTORY:-$(read_env_var SOURCE_DIRECTORY)}" SERVER_IP="${DEPLOY_SERVER_IP:-$(read_env_var DEPLOY_SERVER_IP)}" SSH_USER="${SSH_USER:-$(read_env_var SSH_USER)}" @@ -60,17 +60,6 @@ set -euo pipefail DEPLOY_NAME="$1" cd "/docker/$DEPLOY_NAME" -docker build -t "$DEPLOY_NAME" . - -docker rm -f "$DEPLOY_NAME" 2>/dev/null || true - -docker run --name "$DEPLOY_NAME" -d \ - --network cloudflare \ - -v "/data/$DEPLOY_NAME/.data:/data" \ - --cpus 1 --memory 1g \ - --env-file "/docker/$DEPLOY_NAME/.env" \ - --restart unless-stopped \ - "$DEPLOY_NAME:latest" - -docker image prune -f +COBBLE_DATA_DIR="/data/$DEPLOY_NAME/.data" COBBLE_EXTRA_NETWORK=cloudflare \ + ./deploy/run-app-container.sh EOF \ No newline at end of file diff --git a/deploy/run-app-container.sh b/deploy/run-app-container.sh new file mode 100755 index 0000000..f2f310a --- /dev/null +++ b/deploy/run-app-container.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Builds the app's image and (re)starts its container. Rerun it to update: +# the database, media and computers' home directories (the data dir) and +# .env stay on the host. The container needs the docker socket, since it +# creates every computer as a container on the cobble network. +# +# COBBLE_DATA_DIR host data dir, mounted at /data (default /data/cobble) +# COBBLE_BIND host address the app is published on (default 127.0.0.1) +# COBBLE_EXTRA_NETWORK another docker network to join, e.g. a tunnel's +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$PROJECT_ROOT/.env" +NAME="cobble" +NETWORK="cobble" +DATA_PATH="${COBBLE_DATA_DIR:-/data/cobble}" +BIND="${COBBLE_BIND:-127.0.0.1}" +DOCKER_SOCKET="/var/run/docker.sock" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: $ENV_FILE not found. Copy .env.example and set ENCRYPTION_KEY and ENCRYPTION_SALT." >&2 + exit 1 +fi + +if [ ! -S "$DOCKER_SOCKET" ]; then + echo "Error: no docker socket at $DOCKER_SOCKET. Computers run in docker, so the app needs it." >&2 + exit 1 +fi + +PORT="$(grep -E '^[[:space:]]*PORT=' "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d "\"' \r")" +PORT="${PORT:-7227}" + +mkdir -p "$DATA_PATH" + +docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null + +docker build -t "$NAME" "$PROJECT_ROOT" + +docker stop -t 30 "$NAME" >/dev/null 2>&1 || true +docker rm -f "$NAME" >/dev/null 2>&1 || true + +docker run --name "$NAME" -d \ + --network "$NETWORK" \ + -p "$BIND:$PORT:$PORT" \ + -v "$ENV_FILE:/app/.env:ro" \ + -v "$DATA_PATH:/data" \ + -v "$DOCKER_SOCKET:$DOCKER_SOCKET" \ + -e MAIN_SERVER=1 \ + --cpus 1 --memory 1g \ + --restart unless-stopped \ + "$NAME:latest" + +if [ -n "${COBBLE_EXTRA_NETWORK:-}" ]; then + docker network connect "$COBBLE_EXTRA_NETWORK" "$NAME" +fi + +docker image prune -f >/dev/null + +echo "Cobble is starting on http://$BIND:$PORT (data in $DATA_PATH)" diff --git a/entrypoint.sh b/entrypoint.sh index a04ae8d..0f8a3a6 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,10 +1,16 @@ #!/bin/bash set -e -echo "=== Bunext SQLite Template ===" +echo "=== Cobble ===" cd /app +# bun-sqlite's record of the live schema sits beside the code; keep it on +# the data volume with the database it describes +mkdir -p "${DATA_DIR:-/data}/.bun-sqlite" +rm -rf /app/.bun-sqlite +ln -sfn "${DATA_DIR:-/data}/.bun-sqlite" /app/.bun-sqlite + bunx bun-sqlite schema -y bunx bunext build bunx bunext sitemap diff --git a/src/components/general/computer-icon-tile.tsx b/src/components/general/computer-icon-tile.tsx new file mode 100644 index 0000000..acd5a49 --- /dev/null +++ b/src/components/general/computer-icon-tile.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/general/user-avatar.tsx b/src/components/general/user-avatar.tsx index 009de87..4f9a82d 100644 --- a/src/components/general/user-avatar.tsx +++ b/src/components/general/user-avatar.tsx @@ -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], diff --git a/src/cron/sync-computers/index.ts b/src/cron/sync-computers/index.ts index 78c7595..acbc152 100644 --- a/src/cron/sync-computers/index.ts +++ b/src/cron/sync-computers/index.ts @@ -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 & - Pick & { computer_id: number }; +type Row = Pick & + { 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({ - 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`); diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 9b3517c..be4bf86 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -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. diff --git a/src/dict/computer-dict.ts b/src/dict/computer-dict.ts index ac9a511..46ab59c 100644 --- a/src/dict/computer-dict.ts +++ b/src/dict/computer-dict.ts @@ -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", diff --git a/src/dict/env-dict.ts b/src/dict/env-dict.ts index cc2892a..89e12ea 100644 --- a/src/dict/env-dict.ts +++ b/src/dict/env-dict.ts @@ -70,6 +70,7 @@ export const ProcessOnlyEnvKeys = [ "ENCRYPTION_SALT", "PORT", "DATA_DIR", + "DB_DIR", "MAIN_SERVER", ] as const; diff --git a/src/dict/settings-pages-dict.ts b/src/dict/settings-pages-dict.ts index b985193..524ceca 100644 --- a/src/dict/settings-pages-dict.ts +++ b/src/dict/settings-pages-dict.ts @@ -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", diff --git a/src/dict/user-types-dict.test.ts b/src/dict/user-types-dict.test.ts index d7fdcb9..eea09fc 100644 --- a/src/dict/user-types-dict.test.ts +++ b/src/dict/user-types-dict.test.ts @@ -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).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); - }); }); \ No newline at end of file diff --git a/src/dict/user-types-dict.ts b/src/dict/user-types-dict.ts index 33ed723..db186ed 100644 --- a/src/dict/user-types-dict.ts +++ b/src/dict/user-types-dict.ts @@ -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"], }, }, }, diff --git a/src/functions/backend/auth/check-user-access.test.ts b/src/functions/backend/auth/check-user-access.test.ts index 2fcafff..4ac5f32 100644 --- a/src/functions/backend/auth/check-user-access.test.ts +++ b/src/functions/backend/auth/check-user-access.test.ts @@ -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); }); diff --git a/src/functions/backend/db/bots/delete-bot.ts b/src/functions/backend/db/bots/delete-bot.ts new file mode 100644 index 0000000..0388742 --- /dev/null +++ b/src/functions/backend/db/bots/delete-bot.ts @@ -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; +} diff --git a/src/functions/backend/db/computers/create-computer.ts b/src/functions/backend/db/computers/create-computer.ts index 5900a2c..a1e4fdd 100644 --- a/src/functions/backend/db/computers/create-computer.ts +++ b/src/functions/backend/db/computers/create-computer.ts @@ -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 = { * (`-`), 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, }, ], }); diff --git a/src/functions/backend/db/computers/grab-computer-mcp-url.ts b/src/functions/backend/db/computers/grab-computer-mcp-url.ts index c4bdae6..f857d51 100644 --- a/src/functions/backend/db/computers/grab-computer-mcp-url.ts +++ b/src/functions/backend/db/computers/grab-computer-mcp-url.ts @@ -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 & - Pick; +type ComputerRow = Pick & + Pick; + +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 { const row = ( await BunSQLite.sql({ - 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 }; } diff --git a/src/functions/backend/db/computers/pick-reachable-address.test.ts b/src/functions/backend/db/computers/pick-reachable-address.test.ts new file mode 100644 index 0000000..ed9d795 --- /dev/null +++ b/src/functions/backend/db/computers/pick-reachable-address.test.ts @@ -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); + }); +}); diff --git a/src/functions/backend/db/computers/pick-reachable-address.ts b/src/functions/backend/db/computers/pick-reachable-address.ts new file mode 100644 index 0000000..36f4c0d --- /dev/null +++ b/src/functions/backend/db/computers/pick-reachable-address.ts @@ -0,0 +1,53 @@ +import { AppData } from "@/src/data/app-data"; + +type Params = { + /** + * Caches the pick, e.g. `computer:`. + */ + key: string; + /** + * Base URLs in the order to try, e.g. the hostname, then the IP address. + */ + candidates: [string, ...string[]]; +}; + +const picked = new Map(); + +/** + * 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]; +} diff --git a/src/functions/backend/db/computers/provision-computer.ts b/src/functions/backend/db/computers/provision-computer.ts index 3736be8..204559b 100644 --- a/src/functions/backend/db/computers/provision-computer.ts +++ b/src/functions/backend/db/computers/provision-computer.ts @@ -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({ - 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 + >({ + 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, diff --git a/src/functions/backend/db/computers/refresh-computer-container.ts b/src/functions/backend/db/computers/refresh-computer-container.ts new file mode 100644 index 0000000..2c7d320 --- /dev/null +++ b/src/functions/backend/db/computers/refresh-computer-container.ts @@ -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; +}; + +/** + * 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 }; +} diff --git a/src/functions/backend/db/computers/update-computer.ts b/src/functions/backend/db/computers/update-computer.ts new file mode 100644 index 0000000..48472b8 --- /dev/null +++ b/src/functions/backend/db/computers/update-computer.ts @@ -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; +}; + +/** + * 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 }; +} diff --git a/src/functions/backend/db/users/delete-user.ts b/src/functions/backend/db/users/delete-user.ts new file mode 100644 index 0000000..acd1b80 --- /dev/null +++ b/src/functions/backend/db/users/delete-user.ts @@ -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({ + 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; +} diff --git a/src/functions/backend/db/users/grab-users.ts b/src/functions/backend/db/users/grab-users.ts index 0260708..788737e 100644 --- a/src/functions/backend/db/users/grab-users.ts +++ b/src/functions/backend/db/users/grab-users.ts @@ -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; diff --git a/src/functions/backend/db/users/save-user.ts b/src/functions/backend/db/users/save-user.ts new file mode 100644 index 0000000..2e8159f --- /dev/null +++ b/src/functions/backend/db/users/save-user.ts @@ -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({ + 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({ + 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({ + 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({ + table: "user_types", + data: added.map((user_type) => ({ user_id: target_id, user_type })), + }); + } + + return { user_id: target_id, created: !existing?.id }; +} diff --git a/src/functions/backend/docker/create-computer-container.ts b/src/functions/backend/docker/create-computer-container.ts index 1ad00f6..5c0d557 100644 --- a/src/functions/backend/docker/create-computer-container.ts +++ b/src/functions/backend/docker/create-computer-container.ts @@ -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, }; } diff --git a/src/functions/backend/docker/grab-free-port.ts b/src/functions/backend/docker/grab-free-port.ts deleted file mode 100644 index 73c2c3a..0000000 --- a/src/functions/backend/docker/grab-free-port.ts +++ /dev/null @@ -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; -} diff --git a/src/functions/backend/docker/inspect-docker-container.ts b/src/functions/backend/docker/inspect-docker-container.ts index 537873f..5a09a1c 100644 --- a/src/functions/backend/docker/inspect-docker-container.ts +++ b/src/functions/backend/docker/inspect-docker-container.ts @@ -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; + /** + * 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 { const res = await dockerRequest<{ Id: string; - State?: { Status?: ContainerStatus }; - NetworkSettings?: { Ports?: Record }; + State?: { + Status?: ContainerStatus; + Health?: { Status?: "starting" | "healthy" | "unhealthy" | "none" }; + }; + NetworkSettings?: { + Ports?: Record; + Networks?: Record; + }; }>({ 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, }; } diff --git a/src/functions/backend/docker/remove-docker-container.ts b/src/functions/backend/docker/remove-docker-container.ts index 9eded6c..7dd8f7d 100644 --- a/src/functions/backend/docker/remove-docker-container.ts +++ b/src/functions/backend/docker/remove-docker-container.ts @@ -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)}`, diff --git a/src/functions/backend/docker/wait-for-healthy-container.ts b/src/functions/backend/docker/wait-for-healthy-container.ts new file mode 100644 index 0000000..55fa3c2 --- /dev/null +++ b/src/functions/backend/docker/wait-for-healthy-container.ts @@ -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); + } +} diff --git a/src/functions/server/computers/computer-proxy-target.ts b/src/functions/server/computers/computer-proxy-target.ts index 861dc27..9ae87b2 100644 --- a/src/functions/server/computers/computer-proxy-target.ts +++ b/src/functions/server/computers/computer-proxy-target.ts @@ -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//...` 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, diff --git a/src/harness/tools/computer/open-computer-tools.ts b/src/harness/tools/computer/open-computer-tools.ts index ba08382..33dfaa9 100644 --- a/src/harness/tools/computer/open-computer-tools.ts +++ b/src/harness/tools/computer/open-computer-tools.ts @@ -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); diff --git a/src/harness/tools/computer/search-computers-tool.ts b/src/harness/tools/computer/search-computers-tool.ts index 4e7fbb5..219e92b 100644 --- a/src/harness/tools/computer/search-computers-tool.ts +++ b/src/harness/tools/computer/search-computers-tool.ts @@ -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, diff --git a/src/pages/admin/workspaces/[workspace_id]/(hooks)/use-live-computers.ts b/src/hooks/use-live-computers.ts similarity index 50% rename from src/pages/admin/workspaces/[workspace_id]/(hooks)/use-live-computers.ts rename to src/hooks/use-live-computers.ts index cc0d2f1..a104730 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(hooks)/use-live-computers.ts +++ b/src/hooks/use-live-computers.ts @@ -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 }; diff --git a/src/layouts/bots/(partials)/add-computer-form.tsx b/src/layouts/bots/(partials)/add-computer-form.tsx index a52a289..acc9ca4 100644 --- a/src/layouts/bots/(partials)/add-computer-form.tsx +++ b/src/layouts/bots/(partials)/add-computer-form.tsx @@ -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(); @@ -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({ - path: "computers/create-computer", - body: { name, description, workspace_id }, - }); + const res = computer?.id + ? await postAdminApi({ + path: "computers/update-computer", + body: { computer_id: computer.id, name, description, ...resources }, + }) + : await postAdminApi({ + 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)} /> + + setCpus(e.target.value)} + /> + setMemoryGb(e.target.value)} + /> + {workspace?.id ? ( ) : null} - + ); } diff --git a/src/layouts/bots/(partials)/add-flow.tsx b/src/layouts/bots/(partials)/add-flow.tsx index 50ea423..8c2f53d 100644 --- a/src/layouts/bots/(partials)/add-flow.tsx +++ b/src/layouts/bots/(partials)/add-flow.tsx @@ -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) { { - 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; } diff --git a/src/layouts/bots/(partials)/rail-back-link.tsx b/src/layouts/bots/(partials)/rail-back-link.tsx index d63f9c0..ef5fa59 100644 --- a/src/layouts/bots/(partials)/rail-back-link.tsx +++ b/src/layouts/bots/(partials)/rail-back-link.tsx @@ -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 ( - {title} + + {title} + ); } diff --git a/src/layouts/bots/(partials)/rail-settings-list.tsx b/src/layouts/bots/(partials)/rail-settings-list.tsx index ede4ec9..3fb1df6 100644 --- a/src/layouts/bots/(partials)/rail-settings-list.tsx +++ b/src/layouts/bots/(partials)/rail-settings-list.tsx @@ -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) { <> - {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) => ( (); @@ -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" > @@ -82,7 +83,10 @@ export default function ComputerViewerModal({ bot }: Props) { {`Hand back to ${bot_name}`} {error ? ( - + {error} ) : null} diff --git a/src/pages/admin/people/(partials)/people-list-card.tsx b/src/pages/admin/people/(partials)/people-list-card.tsx index f626f80..d542855 100644 --- a/src/pages/admin/people/(partials)/people-list-card.tsx +++ b/src/pages/admin/people/(partials)/people-list-card.tsx @@ -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) { > + {`${person_name}`} - + {date_created.toString()} diff --git a/src/pages/admin/settings/(hooks)/use-settings-list.ts b/src/pages/admin/settings/(hooks)/use-settings-list.ts new file mode 100644 index 0000000..1f28dab --- /dev/null +++ b/src/pages/admin/settings/(hooks)/use-settings-list.ts @@ -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 = { + 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({ items: initial }: Params) { + const [items, setItems] = useState(initial); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(); + + const remove = async ({ id, path, body, confirm }: { id: T["id"]; path: string; body: Record; 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 }; +} diff --git a/src/pages/admin/settings/(partials)/settings-list-row.tsx b/src/pages/admin/settings/(partials)/settings-list-row.tsx new file mode 100644 index 0000000..84e4e6d --- /dev/null +++ b/src/pages/admin/settings/(partials)/settings-list-row.tsx @@ -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 ( + + {avatar} + + + {title} + {badge ? {badge} : null} + + {subtitle ? ( + {subtitle} + ) : null} + + {children ? {children} : null} + + ); +} diff --git a/src/pages/admin/settings/(partials)/settings-list.tsx b/src/pages/admin/settings/(partials)/settings-list.tsx new file mode 100644 index 0000000..6335411 --- /dev/null +++ b/src/pages/admin/settings/(partials)/settings-list.tsx @@ -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 ( + + {children} + {is_empty && empty ? ( + {empty} + ) : null} + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-action.tsx b/src/pages/admin/settings/(partials)/settings-row-action.tsx similarity index 66% rename from src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-action.tsx rename to src/pages/admin/settings/(partials)/settings-row-action.tsx index 893aa0e..10b5343 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-action.tsx +++ b/src/pages/admin/settings/(partials)/settings-row-action.tsx @@ -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 ( + + setEditing(undefined)} /> + + ); +} diff --git a/src/pages/admin/settings/bots/index.server.ts b/src/pages/admin/settings/bots/index.server.ts new file mode 100644 index 0000000..946a32a --- /dev/null +++ b/src/pages/admin/settings/bots/index.server.ts @@ -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 = 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 }), + }, + }; +}; diff --git a/src/pages/admin/settings/bots/index.tsx b/src/pages/admin/settings/bots/index.tsx new file mode 100644 index 0000000..5c876af --- /dev/null +++ b/src/pages/admin/settings/bots/index.tsx @@ -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 ( + + + + ); +} + +export const meta = settingsPageMeta({ label: "Bots" }); diff --git a/src/pages/admin/settings/computers/(partials)/computer-edit-modal.tsx b/src/pages/admin/settings/computers/(partials)/computer-edit-modal.tsx new file mode 100644 index 0000000..42c6902 --- /dev/null +++ b/src/pages/admin/settings/computers/(partials)/computer-edit-modal.tsx @@ -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 ( + !open && onClose()} + title={`Edit ${computer?.name || "the computer"}`} + description="New CPUs or memory restart it with them. Its files stay." + > + {computer ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx b/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx new file mode 100644 index 0000000..fae0f92 --- /dev/null +++ b/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx @@ -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 ( + } + title={name} + badge={computer.kind == "main" ? "Main" : undefined} + subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description] + .filter(Boolean) + .join(" · ")} + > + {is_super_admin ? ( + + ) : null} + {is_super_admin && computer.status == "failed" ? ( + + ) : null} + {is_super_admin && computer.kind != "main" ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx b/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx new file mode 100644 index 0000000..7fce403 --- /dev/null +++ b/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx @@ -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(); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(); + + 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({ 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 ( + + + {computers.map((computer) => ( + 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) }); + }} + /> + ))} + + {is_super_admin ? ( + + ) : null} + + setEditing(undefined)} + onUpdated={({ computer, msg }) => { + merge(computer); + setEditing(undefined); + setStatus({ success: true, msg }); + }} + /> + + ); +} diff --git a/src/pages/admin/settings/computers/index.server.ts b/src/pages/admin/settings/computers/index.server.ts new file mode 100644 index 0000000..c1952be --- /dev/null +++ b/src/pages/admin/settings/computers/index.server.ts @@ -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 = 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 }), + }, + }; +}; diff --git a/src/pages/admin/settings/computers/index.tsx b/src/pages/admin/settings/computers/index.tsx new file mode 100644 index 0000000..159c9b0 --- /dev/null +++ b/src/pages/admin/settings/computers/index.tsx @@ -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 ( + + + + ); +} + +export const meta = settingsPageMeta({ label: "Computers" }); diff --git a/src/pages/admin/settings/profile/(partials)/profile-photo-field.tsx b/src/pages/admin/settings/profile/(partials)/profile-photo-field.tsx index 8f33991..b9848cb 100644 --- a/src/pages/admin/settings/profile/(partials)/profile-photo-field.tsx +++ b/src/pages/admin/settings/profile/(partials)/profile-photo-field.tsx @@ -32,12 +32,7 @@ export default function ProfilePhotoField({ return ( - + + ) : null} + + 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 }); + }} + /> + + ); +} diff --git a/src/pages/admin/settings/users/index.server.ts b/src/pages/admin/settings/users/index.server.ts new file mode 100644 index 0000000..83f1cfc --- /dev/null +++ b/src/pages/admin/settings/users/index.server.ts @@ -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 = 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 || [], + }, + }; +}; diff --git a/src/pages/admin/settings/users/index.tsx b/src/pages/admin/settings/users/index.tsx new file mode 100644 index 0000000..23d1008 --- /dev/null +++ b/src/pages/admin/settings/users/index.tsx @@ -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 ( + + + + ); +} + +export const meta = settingsPageMeta({ label: "Users" }); diff --git a/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx b/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx new file mode 100644 index 0000000..752162a --- /dev/null +++ b/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx @@ -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 ( + } + title={name} + subtitle={workspace.description || undefined} + > + + + + ); +} diff --git a/src/pages/admin/settings/workspaces/(sections)/workspaces-settings-section.tsx b/src/pages/admin/settings/workspaces/(sections)/workspaces-settings-section.tsx new file mode 100644 index 0000000..03a1400 --- /dev/null +++ b/src/pages/admin/settings/workspaces/(sections)/workspaces-settings-section.tsx @@ -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 ( + + + {items.map((workspace) => ( + + 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.`, + }) + } + /> + ))} + + + + + ); +} diff --git a/src/pages/admin/settings/workspaces/index.server.ts b/src/pages/admin/settings/workspaces/index.server.ts new file mode 100644 index 0000000..34bbd39 --- /dev/null +++ b/src/pages/admin/settings/workspaces/index.server.ts @@ -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 = 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 }), + }, + }; +}; diff --git a/src/pages/admin/settings/workspaces/index.tsx b/src/pages/admin/settings/workspaces/index.tsx new file mode 100644 index 0000000..e14a066 --- /dev/null +++ b/src/pages/admin/settings/workspaces/index.tsx @@ -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 ( + + + + ); +} + +export const meta = settingsPageMeta({ label: "Workspaces" }); diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-row.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-row.tsx index 6b53ed7..c40fb1d 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-row.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computer-row.tsx @@ -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 ( - - - - - - - {name} - {is_default ? Default : null} - - - {computerStatusLabel({ computer })} - {computer.description ? ` · ${computer.description}` : ""} - - - - {is_default ? null : ( - - )} - {computer.status == "failed" && is_super_admin ? ( - - ) : null} - - {is_super_admin && computer.kind != "main" ? ( - - ) : null} - - + } + title={name} + badge={is_default ? "Default" : undefined} + subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`} + > + {is_default ? null : ( + + )} + {computer.status == "failed" && is_super_admin ? ( + + ) : null} + + {is_super_admin && computer.kind != "main" ? ( + + ) : null} + ); } diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-group.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-group.tsx index eedf363..79f9639 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-group.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-group.tsx @@ -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." > - + {computers.map((computer) => ( ))} - {computers.length ? null : ( - - No computers yet. Bots here can chat, but tasks that need a computer can't run. - - )} - + = 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 }) }; + } +}; diff --git a/src/pages/api/admin/computers/create-computer.ts b/src/pages/api/admin/computers/create-computer.ts index 493678b..d893c33 100644 --- a/src/pages/api/admin/computers/create-computer.ts +++ b/src/pages/api/admin/computers/create-computer.ts @@ -42,13 +42,19 @@ export const handler: BunextAPIRouteHandler> = 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 }) }; + } +}; diff --git a/src/pages/api/admin/users/delete-user.ts b/src/pages/api/admin/users/delete-user.ts new file mode 100644 index 0000000..f868f76 --- /dev/null +++ b/src/pages/api/admin/users/delete-user.ts @@ -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 = 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 }) }; + } +}; diff --git a/src/pages/api/admin/users/save-user.ts b/src/pages/api/admin/users/save-user.ts new file mode 100644 index 0000000..0a921dd --- /dev/null +++ b/src/pages/api/admin/users/save-user.ts @@ -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> = 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 }) }; + } +}; diff --git a/src/schemas/computer-schema.ts b/src/schemas/computer-schema.ts index 18cdb8d..b5e1b63 100644 --- a/src/schemas/computer-schema.ts +++ b/src/schemas/computer-schema.ts @@ -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; + +export const UpdateComputerRequestSchema = ComputerIdRequestSchema.extend({ + name: ComputerNameSchema.optional(), + description: ComputerDescriptionSchema.optional(), + cpus: ComputerCpusSchema.optional(), + memory_mb: ComputerMemoryMbSchema.optional(), +}); + +export type UpdateComputerRequest = z.infer; diff --git a/src/schemas/user-schema.ts b/src/schemas/user-schema.ts new file mode 100644 index 0000000..c7648b6 --- /dev/null +++ b/src/schemas/user-schema.ts @@ -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; + +export const UserIdRequestSchema = z.object({ + user_id: z.coerce.number().int().positive("Pick a user"), +}); + +export type UserIdRequest = z.infer; diff --git a/src/server.ts b/src/server.ts index cc013c2..12ac0a1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { diff --git a/src/utils/computer-not-ready-message.test.ts b/src/utils/computer-not-ready-message.test.ts new file mode 100644 index 0000000..4abedcb --- /dev/null +++ b/src/utils/computer-not-ready-message.test.ts @@ -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."); + }); +}); diff --git a/src/utils/computer-not-ready-message.ts b/src/utils/computer-not-ready-message.ts new file mode 100644 index 0000000..722af08 --- /dev/null +++ b/src/utils/computer-not-ready-message.ts @@ -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()}.`; +} diff --git a/src/utils/computer-resources-label.test.ts b/src/utils/computer-resources-label.test.ts new file mode 100644 index 0000000..b81365e --- /dev/null +++ b/src/utils/computer-resources-label.test.ts @@ -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"); + }); +}); diff --git a/src/utils/computer-resources-label.ts b/src/utils/computer-resources-label.ts new file mode 100644 index 0000000..ea63e58 --- /dev/null +++ b/src/utils/computer-resources-label.ts @@ -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; +}; + +/** + * 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`; +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(utils)/computer-status-label.test.ts b/src/utils/computer-status-label.test.ts similarity index 100% rename from src/pages/admin/workspaces/[workspace_id]/(utils)/computer-status-label.test.ts rename to src/utils/computer-status-label.test.ts diff --git a/src/pages/admin/workspaces/[workspace_id]/(utils)/computer-status-label.ts b/src/utils/computer-status-label.ts similarity index 100% rename from src/pages/admin/workspaces/[workspace_id]/(utils)/computer-status-label.ts rename to src/utils/computer-status-label.ts diff --git a/src/utils/parse-user-types.test.ts b/src/utils/parse-user-types.test.ts new file mode 100644 index 0000000..9ab64c5 --- /dev/null +++ b/src/utils/parse-user-types.test.ts @@ -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([]); + }); +}); diff --git a/src/utils/parse-user-types.ts b/src/utils/parse-user-types.ts new file mode 100644 index 0000000..79f8efa --- /dev/null +++ b/src/utils/parse-user-types.ts @@ -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)); +} diff --git a/src/utils/rbac/index.test.ts b/src/utils/rbac/index.test.ts index c0fd840..1ae4139 100644 --- a/src/utils/rbac/index.test.ts +++ b/src/utils/rbac/index.test.ts @@ -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); diff --git a/src/utils/user-initials.ts b/src/utils/user-initials.ts new file mode 100644 index 0000000..071f23e --- /dev/null +++ b/src/utils/user-initials.ts @@ -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(""); +}