From c4dbbb4c27a181d348687b9759faa856f5dcae1f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 30 May 2026 12:04:28 -0400 Subject: [PATCH] feat(web): add server-managed Environments CRUD settings UI Add /settings/environments for the server-owned Environments REST API: list, create, edit, and delete environments. The page lives in the new Workflows settings nav section, before Variables. - Shared environment-form component covering provider, image (reference + inline Dockerfile), resources, network, lifecycle, labels, env vars, and volumes - Edit uses the environment revision as If-Match for optimistic concurrency and surfaces 409 conflicts; the default environment is protected from deletion - Adds environmentsApi client, environments query keys, and useEnvironments/useEnvironment hooks Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/environment-form.tsx | 630 ++++++++++++++++++ apps/fabro-web/app/lib/api-client.ts | 6 + apps/fabro-web/app/lib/queries.ts | 17 + apps/fabro-web/app/lib/query-keys.ts | 4 + apps/fabro-web/app/router.tsx | 6 + .../app/routes/settings-environments-edit.tsx | 129 ++++ .../app/routes/settings-environments-new.tsx | 99 +++ .../app/routes/settings-environments.tsx | 236 +++++++ apps/fabro-web/app/routes/settings.tsx | 8 + 9 files changed, 1135 insertions(+) create mode 100644 apps/fabro-web/app/components/environment-form.tsx create mode 100644 apps/fabro-web/app/routes/settings-environments-edit.tsx create mode 100644 apps/fabro-web/app/routes/settings-environments-new.tsx create mode 100644 apps/fabro-web/app/routes/settings-environments.tsx diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx new file mode 100644 index 000000000..5d5a9b5f2 --- /dev/null +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -0,0 +1,630 @@ +import type { ReactNode } from "react"; +import { Switch } from "@headlessui/react"; +import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid"; +import { + EnvironmentApiDockerfileSourceInlineTypeEnum, + EnvironmentNetworkMode, + EnvironmentProvider, +} from "@qltysh/fabro-api-client"; +import type { + CreateEnvironmentRequest, + Environment, + EnvironmentApiImageSettings, + EnvironmentLifecycleSettings, + EnvironmentNetworkSettings, + EnvironmentResourcesSettings, + EnvironmentVolumeSettings, + ReplaceEnvironmentRequest, +} from "@qltysh/fabro-api-client"; + +import { Panel, Row } from "./settings-panel"; +import { INPUT_CLASS } from "./ui"; + +// Environment ids are server-managed file names: lowercase, digits, hyphens. +const ENVIRONMENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/; + +interface KeyValueEntry { + key: string; + value: string; +} + +interface VolumeEntry { + id: string; + mountPath: string; + subpath: string; +} + +export interface EnvironmentFormValues { + id: string; + provider: EnvironmentProvider; + dockerRef: string; + dockerfile: string; + cpu: string; + memory: string; + disk: string; + networkMode: EnvironmentNetworkMode; + allow: string; + preserve: boolean; + stopOnTerminal: boolean; + autoStop: string; + labels: KeyValueEntry[]; + envVars: KeyValueEntry[]; + volumes: VolumeEntry[]; +} + +export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { + id: "", + provider: EnvironmentProvider.DOCKER, + dockerRef: "", + dockerfile: "", + cpu: "", + memory: "", + disk: "", + networkMode: EnvironmentNetworkMode.ALLOW_ALL, + allow: "", + preserve: false, + stopOnTerminal: true, + autoStop: "", + labels: [], + envVars: [], + volumes: [], +}; + +export function environmentToFormValues(environment: Environment): EnvironmentFormValues { + return { + id: environment.id, + provider: environment.provider, + dockerRef: environment.image.docker ?? "", + dockerfile: environment.image.dockerfile?.value ?? "", + cpu: environment.resources.cpu === null ? "" : String(environment.resources.cpu), + memory: environment.resources.memory ?? "", + disk: environment.resources.disk ?? "", + networkMode: environment.network.mode, + allow: environment.network.allow.join("\n"), + preserve: environment.lifecycle.preserve, + stopOnTerminal: environment.lifecycle.stop_on_terminal, + autoStop: environment.lifecycle.auto_stop ?? "", + labels: entriesFromMap(environment.labels), + envVars: entriesFromMap(environment.env), + volumes: environment.volumes.map((volume) => ({ + id: volume.id, + mountPath: volume.mount_path, + subpath: volume.subpath ?? "", + })), + }; +} + +export function isEnvironmentFormValid(values: EnvironmentFormValues): boolean { + if (!ENVIRONMENT_ID_PATTERN.test(values.id.trim())) return false; + if (values.cpu.trim() !== "" && !Number.isFinite(Number(values.cpu))) return false; + return true; +} + +export function createRequestFromForm(values: EnvironmentFormValues): CreateEnvironmentRequest { + return { id: values.id.trim(), ...settingsFromForm(values) }; +} + +export function replaceRequestFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest { + return settingsFromForm(values); +} + +function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest { + return { + provider: values.provider, + image: imageFromForm(values), + resources: resourcesFromForm(values), + network: networkFromForm(values), + lifecycle: lifecycleFromForm(values), + labels: mapFromEntries(values.labels), + volumes: volumesFromForm(values), + env: mapFromEntries(values.envVars), + }; +} + +function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings { + const dockerfile = values.dockerfile.trim(); + return { + docker: values.dockerRef.trim() || null, + dockerfile: dockerfile + ? { type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, value: values.dockerfile } + : null, + }; +} + +function resourcesFromForm(values: EnvironmentFormValues): EnvironmentResourcesSettings { + const cpu = values.cpu.trim(); + return { + cpu: cpu === "" ? null : Number(cpu), + memory: values.memory.trim() || null, + disk: values.disk.trim() || null, + }; +} + +function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings { + return { + mode: values.networkMode, + allow: values.allow + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""), + }; +} + +function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleSettings { + return { + preserve: values.preserve, + stop_on_terminal: values.stopOnTerminal, + auto_stop: values.autoStop.trim() || null, + }; +} + +function volumesFromForm(values: EnvironmentFormValues): EnvironmentVolumeSettings[] { + return values.volumes + .map((volume) => ({ + id: volume.id.trim(), + mount_path: volume.mountPath.trim(), + subpath: volume.subpath.trim() || null, + })) + .filter((volume) => volume.id !== "" && volume.mount_path !== ""); +} + +function entriesFromMap(map: { [key: string]: string }): KeyValueEntry[] { + return Object.entries(map).map(([key, value]) => ({ key, value })); +} + +function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } { + return Object.fromEntries( + entries + .map((entry): [string, string] => [entry.key.trim(), entry.value]) + .filter((entry) => entry[0] !== ""), + ); +} + +function parseProvider(value: string): EnvironmentProvider { + switch (value) { + case EnvironmentProvider.LOCAL: + return EnvironmentProvider.LOCAL; + case EnvironmentProvider.DAYTONA: + return EnvironmentProvider.DAYTONA; + default: + return EnvironmentProvider.DOCKER; + } +} + +function parseNetworkMode(value: string): EnvironmentNetworkMode { + switch (value) { + case EnvironmentNetworkMode.BLOCK: + return EnvironmentNetworkMode.BLOCK; + case EnvironmentNetworkMode.CIDR_ALLOW_LIST: + return EnvironmentNetworkMode.CIDR_ALLOW_LIST; + default: + return EnvironmentNetworkMode.ALLOW_ALL; + } +} + +interface EnvironmentFormFieldsProps { + values: EnvironmentFormValues; + onChange: (values: EnvironmentFormValues) => void; + lockId?: boolean; +} + +export function EnvironmentFormFields({ + values, + onChange, + lockId = false, +}: EnvironmentFormFieldsProps) { + function patch(partial: Partial) { + onChange({ ...values, ...partial }); + } + + const idValid = ENVIRONMENT_ID_PATTERN.test(values.id.trim()); + + return ( + <> + + ID} + help="Lowercase identifier (letters, digits, hyphens). Runs select this environment by id. Cannot be changed after creation." + > + {lockId ? ( +
{values.id}
+ ) : ( + patch({ id: e.target.value })} + placeholder="fabro-dev" + autoComplete="off" + spellCheck={false} + className={`${INPUT_CLASS} font-mono`} + /> + )} +
+ Provider} help="Where runs using this environment execute."> + + +
+ + + Image reference} + help="Docker image or Daytona snapshot name (e.g. fabro-v11)." + > + patch({ dockerRef: e.target.value })} + placeholder="ubuntu:24.04" + autoComplete="off" + spellCheck={false} + className={`${INPUT_CLASS} font-mono`} + /> + + Dockerfile} + help="Inline Dockerfile contents. The REST API accepts inline Dockerfiles only — local paths are rejected." + > +