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 index 1f114fc82..f4dbea49b 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { Switch } from "@headlessui/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, @@ -19,6 +20,22 @@ import type { 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}$/; @@ -33,37 +50,46 @@ interface KeyValueEntry { 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; - networkMode: EnvironmentNetworkMode; - allow: string; + blockNetwork: boolean; preserve: boolean; stopOnTerminal: boolean; autoStop: string; - labels: KeyValueEntry[]; + // 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, - networkMode: EnvironmentNetworkMode.ALLOW_ALL, - allow: "", + blockNetwork: false, preserve: false, stopOnTerminal: true, autoStop: "", - labels: [], + labels: {}, envVars: [], }; @@ -71,23 +97,42 @@ export function environmentToFormValues(environment: Environment): EnvironmentFo 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), - networkMode: environment.network.mode, - allow: environment.network.allow.join("\n"), + blockNetwork: environment.network.mode === EnvironmentNetworkMode.BLOCK, preserve: environment.lifecycle.preserve, stopOnTerminal: environment.lifecycle.stop_on_terminal, autoStop: environment.lifecycle.auto_stop ?? "", - labels: entriesFromMap(environment.labels), + labels: environment.labels, envVars: entriesFromMap(environment.env), }; } export function isEnvironmentFormValid(values: EnvironmentFormValues): boolean { - return ENVIRONMENT_ID_PATTERN.test(values.id.trim()); + 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 { @@ -105,18 +150,24 @@ function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequ resources: resourcesFromForm(values), network: networkFromForm(values), lifecycle: lifecycleFromForm(values), - labels: mapFromEntries(values.labels), + labels: values.labels, env: mapFromEntries(values.envVars), }; } function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings { - const dockerfile = values.dockerfile.trim(); + if (values.imageSource === "dockerfile") { + return { + docker: null, + dockerfile: { + type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, + value: values.dockerfile, + }, + }; + } return { - docker: values.dockerRef.trim() || null, - dockerfile: dockerfile - ? { type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, value: values.dockerfile } - : null, + docker: values.dockerRef.trim() || null, + dockerfile: null, }; } @@ -162,11 +213,8 @@ function parseGb(value: string | null, range: ResourceRange): number { function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings { return { - mode: values.networkMode, - allow: values.allow - .split("\n") - .map((line) => line.trim()) - .filter((line) => line !== ""), + mode: values.blockNetwork ? EnvironmentNetworkMode.BLOCK : EnvironmentNetworkMode.ALLOW_ALL, + allow: [], }; } @@ -190,26 +238,8 @@ function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } { ); } -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; - } +function parseImageSource(value: string): ImageSource { + return value === "dockerfile" ? "dockerfile" : "image"; } interface EnvironmentFormFieldsProps { @@ -231,7 +261,7 @@ export function EnvironmentFormFields({ return ( <> - + ID} help="Lowercase identifier (letters, digits, hyphens). Runs select this environment by id. Cannot be changed after creation." @@ -252,58 +282,56 @@ export function EnvironmentFormFields({ /> )} - Provider} help="Where runs using this environment execute."> + Source} + help="Whether this environment runs a prebuilt image reference or builds from an inline Dockerfile." + > - - - - 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." - > -