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." + > +