Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e57cc467 | ||
|
|
e1faa2141e |
@@ -128,7 +128,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
|
|
||||||
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
||||||
|
|
||||||
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, `--cpus 1` and `--memory 2048m` (a light orchestration process: the heavy work happens in each computer's own container; overridable with `--cpus`/`--memory`), a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
||||||
|
|
||||||
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ The installer binds to `0.0.0.0` so the app is reachable from your phone on
|
|||||||
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
||||||
thing that should reach it.
|
thing that should reach it.
|
||||||
|
|
||||||
|
The app container itself is limited to 1 CPU and 2 GB of memory, since the
|
||||||
|
heavy work happens in the computers' own containers. Change that with
|
||||||
|
`--cpus` and `--memory`; each computer keeps its own limits.
|
||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy
|
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy
|
||||||
@@ -147,6 +151,8 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu
|
|||||||
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
|
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
|
||||||
| `--port PORT` | Host port, default `7227` |
|
| `--port PORT` | Host port, default `7227` |
|
||||||
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` |
|
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` |
|
||||||
|
| `--cpus N` | CPU cores for the app container, default `1` |
|
||||||
|
| `--memory MB` | Memory for the app container in MB, default `2048` |
|
||||||
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
||||||
| `--version TAG` | Image tag, default `latest` |
|
| `--version TAG` | Image tag, default `latest` |
|
||||||
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
||||||
|
|||||||
+34
@@ -17,6 +17,10 @@ CONTAINER="cobble"
|
|||||||
NETWORK="cobble"
|
NETWORK="cobble"
|
||||||
DEFAULT_PORT="7227"
|
DEFAULT_PORT="7227"
|
||||||
DEFAULT_BIND="0.0.0.0"
|
DEFAULT_BIND="0.0.0.0"
|
||||||
|
# The app container's own limits (the computers' sandboxes are limited
|
||||||
|
# separately, per computer).
|
||||||
|
DEFAULT_CPUS="1"
|
||||||
|
DEFAULT_MEMORY_MB="2048"
|
||||||
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
||||||
DEFAULT_VERSION="latest"
|
DEFAULT_VERSION="latest"
|
||||||
INSTALL_URL="https://url.moduletrace.com/cobble"
|
INSTALL_URL="https://url.moduletrace.com/cobble"
|
||||||
@@ -69,6 +73,8 @@ Options:
|
|||||||
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
||||||
--port PORT Host port (default 7227)
|
--port PORT Host port (default 7227)
|
||||||
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
||||||
|
--cpus N CPU cores the app container may use (default 1)
|
||||||
|
--memory MB Memory the app container may use (default 2048)
|
||||||
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
||||||
--version TAG Image tag (default latest)
|
--version TAG Image tag (default latest)
|
||||||
--no-pull Use the image already on this machine
|
--no-pull Use the image already on this machine
|
||||||
@@ -144,6 +150,8 @@ fi
|
|||||||
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
||||||
PORT="${COBBLE_PORT:-}"
|
PORT="${COBBLE_PORT:-}"
|
||||||
BIND="${COBBLE_BIND:-}"
|
BIND="${COBBLE_BIND:-}"
|
||||||
|
CPUS="${COBBLE_CPUS:-}"
|
||||||
|
MEMORY_MB="${COBBLE_MEMORY_MB:-}"
|
||||||
IMAGE="${COBBLE_IMAGE:-}"
|
IMAGE="${COBBLE_IMAGE:-}"
|
||||||
VERSION="${COBBLE_VERSION:-}"
|
VERSION="${COBBLE_VERSION:-}"
|
||||||
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
||||||
@@ -170,6 +178,8 @@ fi
|
|||||||
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
||||||
PORT="${PORT:-$DEFAULT_PORT}"
|
PORT="${PORT:-$DEFAULT_PORT}"
|
||||||
BIND="${BIND:-$DEFAULT_BIND}"
|
BIND="${BIND:-$DEFAULT_BIND}"
|
||||||
|
CPUS="${CPUS:-$DEFAULT_CPUS}"
|
||||||
|
MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}"
|
||||||
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
||||||
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
||||||
|
|
||||||
@@ -191,6 +201,14 @@ while [ $# -gt 0 ]; do
|
|||||||
BIND="${2:-}"
|
BIND="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--cpus)
|
||||||
|
CPUS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--memory)
|
||||||
|
MEMORY_MB="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--image)
|
--image)
|
||||||
IMAGE="${2:-}"
|
IMAGE="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -256,6 +274,8 @@ done
|
|||||||
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
||||||
[ -n "$PORT" ] || fail "--port needs a value"
|
[ -n "$PORT" ] || fail "--port needs a value"
|
||||||
[ -n "$BIND" ] || fail "--bind needs a value"
|
[ -n "$BIND" ] || fail "--bind needs a value"
|
||||||
|
[ -n "$CPUS" ] || fail "--cpus needs a value"
|
||||||
|
[ -n "$MEMORY_MB" ] || fail "--memory needs a value"
|
||||||
[ -n "$IMAGE" ] || fail "--image needs a value"
|
[ -n "$IMAGE" ] || fail "--image needs a value"
|
||||||
[ -n "$VERSION" ] || fail "--version needs a value"
|
[ -n "$VERSION" ] || fail "--version needs a value"
|
||||||
|
|
||||||
@@ -276,6 +296,14 @@ esac
|
|||||||
case "$BIND" in
|
case "$BIND" in
|
||||||
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
||||||
esac
|
esac
|
||||||
|
case "$CPUS" in
|
||||||
|
'' | *[!0-9.]*) fail "--cpus must be a number" ;;
|
||||||
|
esac
|
||||||
|
[ "$(awk -v value="$CPUS" 'BEGIN { print (value > 0) ? 1 : 0 }')" = 1 ] || fail "--cpus must be greater than 0"
|
||||||
|
case "$MEMORY_MB" in
|
||||||
|
'' | *[!0-9]*) fail "--memory must be a number of megabytes" ;;
|
||||||
|
esac
|
||||||
|
[ "$MEMORY_MB" -ge 512 ] || fail "--memory must be at least 512"
|
||||||
case "$IMAGE" in
|
case "$IMAGE" in
|
||||||
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
||||||
esac
|
esac
|
||||||
@@ -428,6 +456,8 @@ container_matches() {
|
|||||||
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
||||||
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
||||||
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
||||||
|
[ "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$CONTAINER")" = "$(awk -v cpus="$CPUS" 'BEGIN { printf "%d", cpus * 1000000000 }')" ] || return 1
|
||||||
|
[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((MEMORY_MB * 1024 * 1024))" ] || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
||||||
@@ -584,6 +614,8 @@ run_container() {
|
|||||||
--init \
|
--init \
|
||||||
--network "$NETWORK" \
|
--network "$NETWORK" \
|
||||||
-p "$BIND:$PORT:$PORT" \
|
-p "$BIND:$PORT:$PORT" \
|
||||||
|
--cpus "$CPUS" \
|
||||||
|
--memory "${MEMORY_MB}m" \
|
||||||
"${mounts[@]}" \
|
"${mounts[@]}" \
|
||||||
"${envs[@]}" \
|
"${envs[@]}" \
|
||||||
--log-driver json-file \
|
--log-driver json-file \
|
||||||
@@ -719,6 +751,8 @@ unset_env HTTP_REDIRECT_PORT
|
|||||||
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
||||||
printf 'COBBLE_PORT=%q\n' "$PORT"
|
printf 'COBBLE_PORT=%q\n' "$PORT"
|
||||||
printf 'COBBLE_BIND=%q\n' "$BIND"
|
printf 'COBBLE_BIND=%q\n' "$BIND"
|
||||||
|
printf 'COBBLE_CPUS=%q\n' "$CPUS"
|
||||||
|
printf 'COBBLE_MEMORY_MB=%q\n' "$MEMORY_MB"
|
||||||
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
||||||
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
||||||
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
||||||
|
import globalCache from "@/src/utils/global-cache";
|
||||||
|
|
||||||
let cache: Map<string, string> | null = null;
|
/**
|
||||||
|
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||||
|
* in a module variable: the page and API bundles each carry their own copy
|
||||||
|
* of this module, so a module-scoped cache would let a save clear its own
|
||||||
|
* copy while another page kept serving a stale one until the next restart.
|
||||||
|
*/
|
||||||
|
const cache = globalCache<Map<string, string>>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
||||||
* `saveAppSetting` and `deleteAppSetting` clear it.
|
* `saveAppSetting` and `deleteAppSetting` clear it.
|
||||||
*/
|
*/
|
||||||
export default async function grabAppSettings() {
|
export default async function grabAppSettings() {
|
||||||
if (cache) return cache;
|
const cached = cache.get();
|
||||||
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
||||||
sql: `SELECT key, value FROM app_settings`,
|
sql: `SELECT key, value FROM app_settings`,
|
||||||
@@ -21,7 +32,7 @@ export default async function grabAppSettings() {
|
|||||||
if (row.key) map.set(row.key, row.value || "");
|
if (row.key) map.set(row.key, row.value || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
cache = map;
|
cache.set(map);
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -30,5 +41,5 @@ export default async function grabAppSettings() {
|
|||||||
* Drops the cache, so the next read sees the database.
|
* Drops the cache, so the next read sees the database.
|
||||||
*/
|
*/
|
||||||
export function clearAppSettingsCache() {
|
export function clearAppSettingsCache() {
|
||||||
cache = null;
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||||
import type { EnvParadigm } from "@/src/dict/env-dict";
|
import type { EnvParadigm } from "@/src/dict/env-dict";
|
||||||
import { decrypt } from "@/src/utils/crypt";
|
import { decrypt } from "@/src/utils/crypt";
|
||||||
|
import globalCache from "@/src/utils/global-cache";
|
||||||
|
|
||||||
export type DecryptedEnv = {
|
export type DecryptedEnv = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,7 +17,16 @@ export type DecryptedEnv = {
|
|||||||
expose_to_computers: boolean;
|
expose_to_computers: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cache: Promise<DecryptedEnv[]> | undefined;
|
/**
|
||||||
|
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||||
|
* in a module variable: the page and API bundles each carry their own copy
|
||||||
|
* of this module, so a module-scoped cache would let `save-env` clear its
|
||||||
|
* own copy while the page kept serving a stale one until the next restart
|
||||||
|
* (a saved API key never reached the models shown in the app).
|
||||||
|
*/
|
||||||
|
const cache = globalCache<Promise<DecryptedEnv[]>>({
|
||||||
|
name: "cobble.grabAllEnvs.cache",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
||||||
@@ -24,16 +34,22 @@ let cache: Promise<DecryptedEnv[]> | undefined;
|
|||||||
* encryption key changed) reads as empty.
|
* encryption key changed) reads as empty.
|
||||||
*/
|
*/
|
||||||
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
||||||
cache ||= load().catch((error) => {
|
const cached = cache.get();
|
||||||
cache = undefined;
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const loading = load().catch((error: unknown) => {
|
||||||
|
cache.clear();
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
cache.set(loading);
|
||||||
|
|
||||||
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearEnvCache() {
|
export function clearEnvCache() {
|
||||||
cache = undefined;
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { useState } from "react";
|
|||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
|
import type {
|
||||||
|
EnvScope,
|
||||||
|
EnvsMutationResult,
|
||||||
|
KnownEnvStatus,
|
||||||
|
PublicEnv,
|
||||||
|
} from "@/src/types";
|
||||||
import postEnvApi from "../../(functions)/post-env-api";
|
import postEnvApi from "../../(functions)/post-env-api";
|
||||||
import EnvForm from "./env-form";
|
import EnvForm from "./env-form";
|
||||||
import EnvRowActions from "./env-row-actions";
|
import EnvRowActions from "./env-row-actions";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scope: EnvScope;
|
scope: EnvScope;
|
||||||
@@ -27,14 +33,26 @@ type Props = {
|
|||||||
* One env: its key, description and value (or "secret"), with Edit and
|
* One env: its key, description and value (or "secret"), with Edit and
|
||||||
* Remove. Edit opens the form in place.
|
* Remove. Edit opens the form in place.
|
||||||
*/
|
*/
|
||||||
export default function EnvRow({ scope, env, known, scoped, onChanged, onError }: Props) {
|
export default function EnvRow({
|
||||||
|
scope,
|
||||||
|
env,
|
||||||
|
known,
|
||||||
|
scoped,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: Props) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const key = env?.key || known?.key || "";
|
const key = env?.key || known?.key || "";
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!env || busy) return;
|
if (!env || busy) return;
|
||||||
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Remove ${key}? Computers drop it on their next run.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const res = await postEnvApi({
|
const res = await postEnvApi({
|
||||||
@@ -43,7 +61,8 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
|
|||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|
||||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
|
if (res.success && res.singleRes)
|
||||||
|
onChanged(res.singleRes, res.msg || "Removed.");
|
||||||
else onError(res.msg || "Couldn't remove the env.");
|
else onError(res.msg || "Couldn't remove the env.");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,12 +91,22 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
|
|||||||
? "From the server's environment"
|
? "From the server's environment"
|
||||||
: "Not set";
|
: "Not set";
|
||||||
|
|
||||||
const flags = env && !env.known && !env.expose_to_computers ? " · not on computers" : "";
|
const flags =
|
||||||
|
env && !env.known && !env.expose_to_computers
|
||||||
|
? " · not on computers"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
<Row
|
||||||
|
className={twMerge(
|
||||||
|
"w-full gap-3! flex-nowrap items-start justify-between py-3 px-4",
|
||||||
|
"border-0 border-b border-solid border-hairline last:border-b-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Stack className="gap-0.5! min-w-0">
|
<Stack className="gap-0.5! min-w-0">
|
||||||
<Span className="text-sm font-bold leading-5 text-ink truncate">{key}</Span>
|
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||||
|
{key}
|
||||||
|
</Span>
|
||||||
{env?.description || known?.description ? (
|
{env?.description || known?.description ? (
|
||||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
||||||
{known?.description || env?.description}
|
{known?.description || env?.description}
|
||||||
|
|||||||
@@ -34,20 +34,34 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Descriptions: Record<EnvScope["paradigm"], string> = {
|
const Descriptions: Record<EnvScope["paradigm"], string> = {
|
||||||
general: "Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
|
general:
|
||||||
workspace: "For every bot working here. Beats general, loses to the bot's own.",
|
"Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
|
||||||
computer: "For this computer only. Beats a workspace, loses to the bot's own.",
|
workspace:
|
||||||
|
"For every bot working here. Beats general, loses to the bot's own.",
|
||||||
|
computer:
|
||||||
|
"For this computer only. Beats a workspace, loses to the bot's own.",
|
||||||
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
|
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The envs bots and computers use, in one scope, with an add form.
|
* The envs bots and computers use, in one scope, with an add form.
|
||||||
*/
|
*/
|
||||||
export default function EnvsGroup({ scope, envs, scoped, title, status, onChanged, onError }: Props) {
|
export default function EnvsGroup({
|
||||||
|
scope,
|
||||||
|
envs,
|
||||||
|
scoped,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: Props) {
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup title={title || "Envs"} description={Descriptions[scope.paradigm]}>
|
<SettingsGroup
|
||||||
|
title={title || "Envs"}
|
||||||
|
description={Descriptions[scope.paradigm]}
|
||||||
|
>
|
||||||
<SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
|
<SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
|
||||||
{envs.map((env) => (
|
{envs.map((env) => (
|
||||||
<EnvRow
|
<EnvRow
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import globalCache from "./global-cache";
|
||||||
|
|
||||||
|
describe("globalCache", () => {
|
||||||
|
test("keeps a value and clears it", () => {
|
||||||
|
const cache = globalCache<number>({ name: "cobble.grabAllEnvs.cache" });
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.get()).toBeUndefined();
|
||||||
|
|
||||||
|
cache.set(7);
|
||||||
|
expect(cache.get()).toBe(7);
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.get()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two slots with one name share a value, as two bundles would", () => {
|
||||||
|
const page_bundle = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
const api_bundle = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
page_bundle.clear();
|
||||||
|
page_bundle.set("stale");
|
||||||
|
|
||||||
|
// The API sees what the page wrote, so a save there clears it too.
|
||||||
|
expect(api_bundle.get()).toBe("stale");
|
||||||
|
|
||||||
|
api_bundle.clear();
|
||||||
|
expect(page_bundle.get()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different names never share a slot", () => {
|
||||||
|
const envs = globalCache<string>({ name: "cobble.grabAllEnvs.cache" });
|
||||||
|
const settings = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
envs.clear();
|
||||||
|
settings.clear();
|
||||||
|
|
||||||
|
envs.set("a");
|
||||||
|
settings.set("b");
|
||||||
|
|
||||||
|
expect(envs.get()).toBe("a");
|
||||||
|
expect(settings.get()).toBe("b");
|
||||||
|
|
||||||
|
envs.clear();
|
||||||
|
settings.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* A per-process cache slot that survives module duplication.
|
||||||
|
*
|
||||||
|
* bunext bundles pages and API routes separately, so a module-scoped `let`
|
||||||
|
* cache exists once per bundle: a save that clears its own copy leaves the
|
||||||
|
* other one serving stale data until the process restarts (a saved API key
|
||||||
|
* never reached the model list). The key is taken from the global symbol
|
||||||
|
* registry, which resolves to the same symbol in every bundle, and the value
|
||||||
|
* is stored on `globalThis`, so every copy reads and clears one slot.
|
||||||
|
*/
|
||||||
|
type Slot<Value> = {
|
||||||
|
get(): Value | undefined;
|
||||||
|
set(value: Value): void;
|
||||||
|
clear(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named caches, so two modules never share a slot by accident.
|
||||||
|
*/
|
||||||
|
export type GlobalCacheName =
|
||||||
|
| "cobble.grabAllEnvs.cache"
|
||||||
|
| "cobble.grabAppSettings.cache";
|
||||||
|
|
||||||
|
export default function globalCache<Value>({
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
name: GlobalCacheName;
|
||||||
|
}): Slot<Value> {
|
||||||
|
const key = Symbol.for(name);
|
||||||
|
const holder = globalThis as Record<symbol, Value | undefined>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => holder[key],
|
||||||
|
set: (value: Value) => {
|
||||||
|
holder[key] = value;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
delete holder[key];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user