import { Disclosure, DisclosureButton, DisclosurePanel, Switch } from "@headlessui/react"; 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 { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { KeyValueEditor, entriesFromMap, mapFromEntries, type KeyValueEntry, } from "./key-value-editor"; // 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 }; // 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 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." >