From bc0bda73a62f2f8beef82e9ac7d2cf4351ed9f69 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:44:38 -0400 Subject: [PATCH] feat(web): add server-managed Environments CRUD settings UI (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a CRUD interface for **server-managed Environments** at `/settings/environments`, driven by the `/api/v1/environments` REST API (list / create / retrieve / replace / delete), and reshapes how built-in environments are provisioned and protected. The page lives in the **Workflows** settings nav section (also introduced in this branch), positioned before Variables. ## Why The Environments REST API shipped (#453) but had no UI — environments could only be managed via the API/CLI. This gives operators a web UI alongside Variables and Secrets, and along the way tightens the model: environments are seeded at install time (not silently re-created on every boot), and the `default` fallback is an ordinary, deletable environment. ## Web UI **Pages & component** - `settings-environments.tsx` — list view: provider badge, image/resource summary, row actions (Edit/Delete). **"New environment" is a dropdown** of the enabled sandbox providers; the chosen provider is fixed for the environment's lifetime. - `settings-environments-new.tsx` / `settings-environments-edit.tsx` — create/edit flows; create reads the provider from a query param. - `environment-form.tsx` — shared form, reorganized: - **General** panel (merged identity + image): id, and an **image-source selector** (Image reference *vs* inline Dockerfile) that shows, requires, and sends only the selected, mutually-exclusive source. - **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8, memory 1–16 GB, disk 1–20 GB), each always writing a concrete value. - **Environment variables** key/value editor. - **Advanced** progressive-disclosure section holding **Network** (a single "Block all network access" toggle — allow-all vs block) and **Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by default when any advanced value is non-default. - The in-form **provider control and the Labels editor were removed** — labels remain API-managed and are round-tripped untouched so UI edits never clear them. **Data layer**: `environmentsApi` client, `queryKeys.environments`, `useEnvironments` / `useEnvironment` SWR hooks. **Nav & routing**: "Environments" item in the Workflows section before Variables; routes registered in `router.tsx`. ## Backend: seed at install, deletable `default` - **Seeding moved to install time.** The server no longer seeds built-ins on startup; `EnvironmentStore::load_or_seed` → `load` (load-only). A new public `seed_environments(dir)` (idempotent, preserves operator edits) is called by both the web installer and the CLI installer. An uninstalled instance therefore has no managed environments, and a run selecting an absent environment fails explicitly (`unknown environment: default`) rather than resurrecting a built-in. - **`default` is no longer protected.** The delete guard and the `Protected` error variant are gone; deleting `default` succeeds (204) and removes the run fallback on purpose — forcing an explicit choice. `local` is unchanged (reserved, in-memory). - **`volumes` removed** from environment settings across the OpenAPI spec, generated Rust + TS clients, config layers, sandbox/server/workflow plumbing, docs, and tests. ## API contract details honored - Edit sends the environment `revision` as `If-Match`; 409 conflicts surface a "changed since you opened it" message. - The REST API accepts inline Dockerfiles only — the form never sends a Dockerfile path. ## Verification - Rust: `cargo build` (touched crates) ✅, `cargo nextest -p fabro-environment` 21/21 ✅, server env unit + `tests/it` integration 2/2 + 15/15 ✅, `clippy` (nightly, touched crates, all targets) clean ✅, `fmt --check` clean ✅. Full `--workspace` suite not run here — worth a CI pass. - Web: `bun run typecheck` ✅, `bun run build` ✅, `environment-form.test.ts` 5/5 ✅. Web suite: 512 pass / 1 unrelated pre-existing `RunDetail` failure. - **Not visually verified in-browser** — the local app is login-gated and automated loads redirect to `/login`; rendering of the form, the New-environment dropdown, and `default` delete should be confirmed in a logged-in session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Co-authored-by: Fabro Co-authored-by: Release Repro --- Cargo.lock | 2 + .../app/components/environment-form.test.ts | 50 ++ .../app/components/environment-form.tsx | 600 ++++++++++++++++++ .../app/components/run-summary-panel.test.tsx | 13 +- .../app/components/run-summary-panel.tsx | 19 +- .../components/runs-list/run-table-row.tsx | 12 +- apps/fabro-web/app/data/runs.test.ts | 3 +- apps/fabro-web/app/data/runs.ts | 2 +- apps/fabro-web/app/lib/api-client.ts | 6 + apps/fabro-web/app/lib/principal-display.tsx | 6 - apps/fabro-web/app/lib/queries.ts | 17 + apps/fabro-web/app/lib/query-keys.ts | 4 + apps/fabro-web/app/lib/run-actions.test.ts | 3 +- apps/fabro-web/app/lib/test-fixtures.ts | 8 + apps/fabro-web/app/router.tsx | 6 + .../app/routes/automations-new.test.tsx | 3 +- apps/fabro-web/app/routes/run-detail.test.ts | 6 +- .../app/routes/run-files.render.test.tsx | 3 +- .../app/routes/runs.preferences.test.tsx | 3 +- apps/fabro-web/app/routes/runs.test.tsx | 3 +- .../app/routes/settings-environments-edit.tsx | 129 ++++ .../app/routes/settings-environments-new.tsx | 106 ++++ .../app/routes/settings-environments.tsx | 322 ++++++++++ apps/fabro-web/app/routes/settings.tsx | 8 + docs/internal/events-strategy.md | 5 +- docs/internal/events.md | 15 +- docs/internal/logging-strategy.md | 4 +- .../administration/server-configuration.mdx | 2 +- docs/public/api-reference/fabro-api.yaml | 64 +- docs/public/changelog/2026-05-02.mdx | 4 +- docs/public/execution/environments.mdx | 39 +- docs/public/execution/run-configuration.mdx | 3 +- lib/crates/fabro-api/Cargo.toml | 3 + .../fabro-api/tests/environment_round_trip.rs | 1 - .../fabro-api/tests/principal_round_trip.rs | 5 +- .../fabro-api/tests/run_event_round_trip.rs | 8 +- .../tests/run_projection_round_trip.rs | 5 +- .../fabro-api/tests/run_summary_round_trip.rs | 16 +- lib/crates/fabro-cli/Cargo.toml | 1 + lib/crates/fabro-cli/src/commands/install.rs | 19 + .../fabro-cli/src/commands/run/attach.rs | 3 +- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 3 +- lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 14 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 3 + lib/crates/fabro-cli/tests/it/support/mod.rs | 3 +- ...26050101_legacy_sandbox_to_environments.rs | 82 +-- ...1_settings_environments_to_server_files.rs | 55 +- lib/crates/fabro-config/src/layers/combine.rs | 8 +- .../fabro-config/src/layers/environment.rs | 14 - lib/crates/fabro-config/src/layers/mod.rs | 3 +- lib/crates/fabro-config/src/lib.rs | 30 +- .../fabro-config/src/resolve/environment.rs | 20 +- .../fabro-config/src/tests/resolve_run.rs | 9 - lib/crates/fabro-dump/src/lib.rs | 3 +- lib/crates/fabro-environment/src/error.rs | 6 +- lib/crates/fabro-environment/src/lib.rs | 4 +- lib/crates/fabro-environment/src/model.rs | 63 +- lib/crates/fabro-environment/src/store.rs | 329 +++++++--- lib/crates/fabro-install/src/lib.rs | 34 +- lib/crates/fabro-sandbox/src/config.rs | 9 - lib/crates/fabro-sandbox/src/daytona/mod.rs | 56 +- .../fabro-sandbox/src/from_environment.rs | 12 +- lib/crates/fabro-server/src/auth/cli_flow.rs | 18 +- lib/crates/fabro-server/src/demo/mod.rs | 17 +- lib/crates/fabro-server/src/install.rs | 28 + .../fabro-server/src/principal_middleware.rs | 72 +-- lib/crates/fabro-server/src/run_files.rs | 4 +- lib/crates/fabro-server/src/run_manifest.rs | 34 +- lib/crates/fabro-server/src/server.rs | 26 +- .../src/server/handler/environments.rs | 10 +- .../fabro-server/src/server/handler/events.rs | 4 +- .../src/server/handler/lifecycle.rs | 2 +- .../fabro-server/src/server/handler/pair.rs | 4 +- .../fabro-server/src/server/handler/runs.rs | 5 +- .../src/server/handler/sandbox.rs | 3 +- .../src/server/handler/sessions.rs | 3 +- lib/crates/fabro-server/src/server/tests.rs | 47 +- lib/crates/fabro-server/src/test_support.rs | 9 + lib/crates/fabro-server/src/web_auth.rs | 2 +- .../fabro-server/tests/it/api/environments.rs | 95 ++- .../fabro-server/tests/it/api/install.rs | 46 +- .../fabro-server/tests/it/api/run_files.rs | 4 +- lib/crates/fabro-server/tests/it/api/runs.rs | 30 +- lib/crates/fabro-store/Cargo.toml | 1 + lib/crates/fabro-store/src/run_state.rs | 45 +- lib/crates/fabro-store/src/slate/mod.rs | 6 +- lib/crates/fabro-store/src/slate/run_store.rs | 3 +- .../tests/serializable_projection.rs | 4 +- lib/crates/fabro-test/Cargo.toml | 1 + lib/crates/fabro-test/src/lib.rs | 14 + lib/crates/fabro-tool/Cargo.toml | 1 + lib/crates/fabro-tool/src/common.rs | 6 +- lib/crates/fabro-tool/src/create.rs | 4 +- lib/crates/fabro-tool/src/interact.rs | 4 +- lib/crates/fabro-tool/src/search.rs | 6 +- lib/crates/fabro-types/Cargo.toml | 1 + lib/crates/fabro-types/src/lib.rs | 2 + lib/crates/fabro-types/src/principal.rs | 20 +- lib/crates/fabro-types/src/run.rs | 8 +- lib/crates/fabro-types/src/run_event/mod.rs | 6 +- lib/crates/fabro-types/src/run_event/run.rs | 3 +- lib/crates/fabro-types/src/run_projection.rs | 8 +- lib/crates/fabro-types/src/run_summary.rs | 3 +- lib/crates/fabro-types/src/settings/mod.rs | 14 +- lib/crates/fabro-types/src/settings/run.rs | 31 +- lib/crates/fabro-types/src/test_support.rs | 19 + .../fabro-types/tests/run_event_serde.rs | 8 +- .../fabro-types/tests/run_spec_methods.rs | 3 +- .../fabro-types/tests/run_spec_serde.rs | 6 +- .../fabro-workflow/src/billing_rollup.rs | 4 +- .../fabro-workflow/src/event/convert.rs | 38 +- lib/crates/fabro-workflow/src/event/events.rs | 3 +- lib/crates/fabro-workflow/src/event/sink.rs | 3 +- .../fabro-workflow/src/event/stored_fields.rs | 2 +- lib/crates/fabro-workflow/src/git.rs | 4 +- .../fabro-workflow/src/handler/agent.rs | 4 +- .../fabro-workflow/src/handler/command.rs | 6 +- .../fabro-workflow/src/handler/llm/api.rs | 3 +- .../fabro-workflow/src/handler/parallel.rs | 4 +- .../fabro-workflow/src/handler/prompt.rs | 4 +- .../fabro-workflow/src/lifecycle/git.rs | 4 +- .../fabro-workflow/src/operations/archive.rs | 6 +- .../fabro-workflow/src/operations/create.rs | 28 +- .../fabro-workflow/src/operations/fork.rs | 4 +- .../fabro-workflow/src/operations/retry.rs | 36 +- .../fabro-workflow/src/operations/start.rs | 35 +- .../fabro-workflow/src/operations/timeline.rs | 3 +- .../src/pipeline/execute/tests.rs | 8 +- .../fabro-workflow/src/pipeline/finalize.rs | 6 +- .../fabro-workflow/src/pipeline/initialize.rs | 4 +- .../fabro-workflow/src/pipeline/persist.rs | 4 +- .../src/pipeline/pull_request.rs | 18 +- lib/crates/fabro-workflow/src/run_lookup.rs | 4 +- lib/crates/fabro-workflow/src/run_metadata.rs | 6 +- .../fabro-workflow/src/runtime_store.rs | 6 +- lib/crates/fabro-workflow/src/test_support.rs | 8 +- .../src/.openapi-generator/FILES | 2 - .../src/models/create-environment-request.ts | 4 - .../src/models/environment-settings.ts | 4 - .../src/models/environment-volume-settings.ts | 21 - .../src/models/environment.ts | 4 - .../fabro-api-client/src/models/index.ts | 2 - .../src/models/principal-anonymous.ts | 25 - .../fabro-api-client/src/models/principal.ts | 5 +- .../src/models/replace-environment-request.ts | 4 - .../src/models/run-environment-settings.ts | 4 - .../src/models/run-provenance.ts | 2 +- .../fabro-api-client/src/models/run-spec.ts | 2 +- .../fabro-api-client/src/models/run.ts | 2 +- .../tests/principal-exhaustive.ts | 2 - 150 files changed, 2338 insertions(+), 981 deletions(-) create mode 100644 apps/fabro-web/app/components/environment-form.test.ts create mode 100644 apps/fabro-web/app/components/environment-form.tsx create mode 100644 apps/fabro-web/app/lib/test-fixtures.ts 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 create mode 100644 lib/crates/fabro-types/src/test_support.rs delete mode 100644 lib/packages/fabro-api-client/src/models/environment-volume-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/principal-anonymous.ts diff --git a/Cargo.lock b/Cargo.lock index 9134984d3..4ad6efbd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2550,6 +2550,7 @@ dependencies = [ "assert_cmd", "axum", "fabro-config", + "fabro-environment", "fabro-http", "fabro-proc", "fabro-static", @@ -2609,6 +2610,7 @@ dependencies = [ "clap", "dirs", "fabro-model", + "fabro-types", "fabro-util", "hex", "serde", diff --git a/apps/fabro-web/app/components/environment-form.test.ts b/apps/fabro-web/app/components/environment-form.test.ts new file mode 100644 index 000000000..0b661278c --- /dev/null +++ b/apps/fabro-web/app/components/environment-form.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { + EMPTY_ENVIRONMENT_FORM, + createRequestFromForm, + isEnvironmentFormValid, + type EnvironmentFormValues, +} from "./environment-form"; + +function form(overrides: Partial): EnvironmentFormValues { + return { ...EMPTY_ENVIRONMENT_FORM, id: "docker", ...overrides }; +} + +describe("environment image source", () => { + test("image source requires a non-empty image reference", () => { + expect(isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "" }))).toBe(false); + expect( + isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "ubuntu:24.04" })), + ).toBe(true); + }); + + test("dockerfile source requires non-empty Dockerfile contents", () => { + expect(isEnvironmentFormValid(form({ imageSource: "dockerfile", dockerfile: "" }))).toBe(false); + expect( + isEnvironmentFormValid(form({ imageSource: "dockerfile", dockerfile: "FROM ubuntu" })), + ).toBe(true); + }); + + test("an empty Dockerfile does not satisfy the image-reference source", () => { + expect( + isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "", dockerfile: "FROM x" })), + ).toBe(false); + }); + + test("image source sends only the docker reference", () => { + const request = createRequestFromForm( + form({ imageSource: "image", dockerRef: "ubuntu:24.04", dockerfile: "FROM leftover" }), + ); + expect(request.image.docker).toBe("ubuntu:24.04"); + expect(request.image.dockerfile).toBeNull(); + }); + + test("dockerfile source sends only the inline Dockerfile", () => { + const request = createRequestFromForm( + form({ imageSource: "dockerfile", dockerRef: "leftover", dockerfile: "FROM ubuntu" }), + ); + expect(request.image.docker).toBeNull(); + expect(request.image.dockerfile?.value).toBe("FROM ubuntu"); + }); +}); 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..f4dbea49b --- /dev/null +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -0,0 +1,600 @@ +import type { ReactNode } from "react"; +import { Disclosure, DisclosureButton, DisclosurePanel, Switch } from "@headlessui/react"; +import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid"; +import { ChevronRightIcon } from "@heroicons/react/20/solid"; +import { + EnvironmentApiDockerfileSourceInlineTypeEnum, + EnvironmentNetworkMode, + EnvironmentProvider, +} from "@qltysh/fabro-api-client"; +import type { + CreateEnvironmentRequest, + Environment, + EnvironmentApiImageSettings, + EnvironmentLifecycleSettings, + EnvironmentNetworkSettings, + EnvironmentResourcesSettings, + ReplaceEnvironmentRequest, +} from "@qltysh/fabro-api-client"; + +import { Panel, Row } from "./settings-panel"; +import { INPUT_CLASS } from "./ui"; + +// Providers a managed environment can be created with. `local` is a reserved, +// in-memory environment, never a managed-environment provider, so it is never +// offered. The provider is fixed at creation time and cannot be changed. +export const CREATABLE_PROVIDERS = [ + EnvironmentProvider.DOCKER, + EnvironmentProvider.DAYTONA, +] as const; + +// Parse the `provider` query param used by the create flow into a creatable +// provider, defaulting to Docker for anything unexpected. +export function parseCreatableProvider(value: string | null): EnvironmentProvider { + return value === EnvironmentProvider.DAYTONA + ? EnvironmentProvider.DAYTONA + : EnvironmentProvider.DOCKER; +} + +// Environment ids are server-managed file names: lowercase, digits, hyphens. +const ENVIRONMENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/; + +// Resource sliders pick a concrete value within a fixed range. Memory and disk +// are expressed in whole GB; the wire format keeps the `GB` suffix string. +const CPU = { min: 1, max: 8, step: 1, default: 4 }; +const MEMORY = { min: 1, max: 16, step: 1, default: 8 }; +const DISK = { min: 1, max: 20, step: 1, default: 16 }; + +interface KeyValueEntry { + key: string; + value: string; +} + +// An environment image comes from exactly one source: a prebuilt image +// reference or an inline Dockerfile. The form keeps both field values around so +// switching back and forth doesn't lose typed text, and this discriminator +// decides which one is shown, required, and sent. +type ImageSource = "image" | "dockerfile"; + +export interface EnvironmentFormValues { + id: string; + provider: EnvironmentProvider; + imageSource: ImageSource; + dockerRef: string; + dockerfile: string; + cpu: number; + memory: number; + disk: number; + blockNetwork: boolean; + preserve: boolean; + stopOnTerminal: boolean; + autoStop: string; + // Labels are not editable in the web UI — they're managed through the REST + // API only. The form carries the loaded value verbatim so saving an edited + // environment preserves any API-set labels instead of clearing them. + labels: { [key: string]: string }; + envVars: KeyValueEntry[]; +} + +export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { + id: "", + provider: EnvironmentProvider.DOCKER, + imageSource: "image", + dockerRef: "", + dockerfile: "", + cpu: CPU.default, + memory: MEMORY.default, + disk: DISK.default, + blockNetwork: false, + preserve: false, + stopOnTerminal: true, + autoStop: "", + labels: {}, + envVars: [], +}; + +export function environmentToFormValues(environment: Environment): EnvironmentFormValues { + return { + id: environment.id, + provider: environment.provider, + imageSource: environment.image.dockerfile ? "dockerfile" : "image", + dockerRef: environment.image.docker ?? "", + dockerfile: environment.image.dockerfile?.value ?? "", + cpu: clampGb(environment.resources.cpu, CPU), + memory: parseGb(environment.resources.memory, MEMORY), + disk: parseGb(environment.resources.disk, DISK), + blockNetwork: environment.network.mode === EnvironmentNetworkMode.BLOCK, + preserve: environment.lifecycle.preserve, + stopOnTerminal: environment.lifecycle.stop_on_terminal, + autoStop: environment.lifecycle.auto_stop ?? "", + labels: environment.labels, + envVars: entriesFromMap(environment.env), + }; +} + +export function isEnvironmentFormValid(values: EnvironmentFormValues): boolean { + if (!ENVIRONMENT_ID_PATTERN.test(values.id.trim())) return false; + return imageSourceValue(values).trim() !== ""; +} + +// The currently selected image source's text, used both for validation and to +// drive which field is rendered as required. +function imageSourceValue(values: EnvironmentFormValues): string { + return values.imageSource === "dockerfile" ? values.dockerfile : values.dockerRef; +} + +// The Advanced disclosure (Network + Lifecycle) starts open when any of its +// values deviate from the defaults, so editing an environment never hides +// settings the operator already configured. +function hasNonDefaultAdvanced(values: EnvironmentFormValues): boolean { + return ( + values.blockNetwork !== EMPTY_ENVIRONMENT_FORM.blockNetwork || + values.preserve !== EMPTY_ENVIRONMENT_FORM.preserve || + values.stopOnTerminal !== EMPTY_ENVIRONMENT_FORM.stopOnTerminal || + values.autoStop.trim() !== "" + ); +} + +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: values.labels, + env: mapFromEntries(values.envVars), + }; +} + +function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings { + if (values.imageSource === "dockerfile") { + return { + docker: null, + dockerfile: { + type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, + value: values.dockerfile, + }, + }; + } + return { + docker: values.dockerRef.trim() || null, + dockerfile: null, + }; +} + +function resourcesFromForm(values: EnvironmentFormValues): EnvironmentResourcesSettings { + return { + cpu: values.cpu, + memory: `${values.memory}GB`, + disk: `${values.disk}GB`, + }; +} + +interface ResourceRange { + min: number; + max: number; + step: number; + default: number; +} + +// Snap a numeric value into the slider range, falling back to the default when +// the environment leaves the resource unset (provider default). +function clampGb(value: number | null, range: ResourceRange): number { + if (value === null) return range.default; + return Math.min(range.max, Math.max(range.min, Math.round(value))); +} + +// Parse a size string ("16GB", "512MiB", or a bare integer interpreted as GB) +// into whole GB within the slider range. Existing values may use other units or +// fall outside the range, so the result is rounded and clamped. +function parseGb(value: string | null, range: ResourceRange): number { + if (value === null) return range.default; + const match = value.trim().match(/^([\d.]+)\s*([a-zA-Z]*)$/); + if (!match) return range.default; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return range.default; + const perGb: { [unit: string]: number } = { + "": 1, g: 1, gb: 1, gib: 1, + m: 1 / 1000, mb: 1 / 1000, mib: 1 / 1000, + t: 1000, tb: 1000, tib: 1000, + }; + const factor = perGb[match[2].toLowerCase()] ?? 1; + return clampGb(amount * factor, range); +} + +function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings { + return { + mode: values.blockNetwork ? EnvironmentNetworkMode.BLOCK : EnvironmentNetworkMode.ALLOW_ALL, + allow: [], + }; +} + +function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleSettings { + return { + preserve: values.preserve, + stop_on_terminal: values.stopOnTerminal, + auto_stop: values.autoStop.trim() || null, + }; +} + +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 parseImageSource(value: string): ImageSource { + return value === "dockerfile" ? "dockerfile" : "image"; +} + +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`} + /> + )} +
+ Source} + help="Whether this environment runs a prebuilt image reference or builds from an inline Dockerfile." + > + + + {values.imageSource === "image" ? ( + 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." + > +