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