From 1d14ded286a58c47d6d3c97e9e233cc2741bdd21 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 31 May 2026 15:18:45 -0400 Subject: [PATCH] feat(web): use sliders for environment CPU/memory/disk Replace the CPU number field and memory/disk text inputs in the environment form with range sliders: CPU 1-8, memory 1-16 GB, disk 1-20 GB in 1-unit steps. Each slider always writes a concrete value, so the previous "leave blank for provider default" option is gone; saving now persists explicit cpu/memory/disk. When editing, existing values are parsed (GB/MiB/bare int) and clamped onto the slider range. This file also carried the in-progress removal of the Volumes panel from the form's data model, which rides along in this commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/environment-form.tsx | 247 ++++++++---------- .../src/models/environment-volume-settings.ts | 21 -- 2 files changed, 107 insertions(+), 161 deletions(-) delete mode 100644 lib/packages/fabro-api-client/src/models/environment-volume-settings.ts diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 514d0b987..1f114fc82 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -13,7 +13,6 @@ import type { EnvironmentLifecycleSettings, EnvironmentNetworkSettings, EnvironmentResourcesSettings, - EnvironmentVolumeSettings, ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; @@ -23,25 +22,25 @@ 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}$/; +// 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; } -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; + cpu: number; + memory: number; + disk: number; networkMode: EnvironmentNetworkMode; allow: string; preserve: boolean; @@ -49,7 +48,6 @@ export interface EnvironmentFormValues { autoStop: string; labels: KeyValueEntry[]; envVars: KeyValueEntry[]; - volumes: VolumeEntry[]; } export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { @@ -57,9 +55,9 @@ export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { provider: EnvironmentProvider.DOCKER, dockerRef: "", dockerfile: "", - cpu: "", - memory: "", - disk: "", + cpu: CPU.default, + memory: MEMORY.default, + disk: DISK.default, networkMode: EnvironmentNetworkMode.ALLOW_ALL, allow: "", preserve: false, @@ -67,7 +65,6 @@ export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { autoStop: "", labels: [], envVars: [], - volumes: [], }; export function environmentToFormValues(environment: Environment): EnvironmentFormValues { @@ -76,9 +73,9 @@ export function environmentToFormValues(environment: Environment): EnvironmentFo 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 ?? "", + 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"), preserve: environment.lifecycle.preserve, @@ -86,18 +83,11 @@ export function environmentToFormValues(environment: Environment): EnvironmentFo 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; + return ENVIRONMENT_ID_PATTERN.test(values.id.trim()); } export function createRequestFromForm(values: EnvironmentFormValues): CreateEnvironmentRequest { @@ -116,7 +106,6 @@ function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequ network: networkFromForm(values), lifecycle: lifecycleFromForm(values), labels: mapFromEntries(values.labels), - volumes: volumesFromForm(values), env: mapFromEntries(values.envVars), }; } @@ -132,14 +121,45 @@ function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettin } 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, + 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.networkMode, @@ -158,16 +178,6 @@ function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleS }; } -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 })); } @@ -297,44 +307,31 @@ export function EnvironmentFormFields({ - CPU} help="Number of vCPUs. Leave blank for the provider default."> - + patch({ cpu: e.target.value })} - placeholder="8" - min={0} - step={1} - autoComplete="off" - className={`${INPUT_CLASS} font-mono`} + onChange={(cpu) => patch({ cpu })} + format={(n) => `${n} CPU`} /> - Memory} help="Memory limit (e.g. 16GB). Leave blank for the provider default."> - + patch({ memory: e.target.value })} - placeholder="16GB" - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} + onChange={(memory) => patch({ memory })} + format={(n) => `${n} GB`} /> - Disk} help="Disk limit (e.g. 20GB). Leave blank for the provider default."> - + patch({ disk: e.target.value })} - placeholder="20GB" - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} + onChange={(disk) => patch({ disk })} + format={(n) => `${n} GB`} /> @@ -435,18 +432,6 @@ export function EnvironmentFormFields({ - -
-

- Named volumes mounted into the sandbox. Id and mount path are required. -

- patch({ volumes })} - /> -
-
- {!lockId && values.id.trim() !== "" && !idValid ? (

ID must be lowercase letters, digits, or hyphens and start with a letter or digit. @@ -505,62 +490,6 @@ function KeyValueEditor({ ); } -function VolumeEditor({ - volumes, - onChange, -}: { - volumes: VolumeEntry[]; - onChange: (volumes: VolumeEntry[]) => void; -}) { - function update(index: number, partial: Partial) { - onChange(volumes.map((volume, i) => (i === index ? { ...volume, ...partial } : volume))); - } - - return ( -

- {volumes.map((volume, index) => ( -
- update(index, { id: e.target.value })} - placeholder="cache" - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} - /> - update(index, { mountPath: e.target.value })} - placeholder="/cache" - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} - /> - update(index, { subpath: e.target.value })} - placeholder="subpath (optional)" - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} - /> - onChange(volumes.filter((_, i) => i !== index))} /> -
- ))} - onChange([...volumes, { id: "", mountPath: "", subpath: "" }])} - /> -
- ); -} - function AddButton({ label, onClick }: { label: string; onClick: () => void }) { return (