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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-31 15:18:45 -04:00
parent a163c16cda
commit 1d14ded286
No known key found for this signature in database
2 changed files with 107 additions and 161 deletions

View file

@ -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({
</Panel>
<Panel title="Resources">
<Row title={<Label optional>CPU</Label>} help="Number of vCPUs. Leave blank for the provider default.">
<input
type="number"
name="cpu"
aria-label="CPU"
<Row title="CPU" help="Number of vCPUs allocated to each run.">
<ResourceSlider
ariaLabel="CPU"
range={CPU}
value={values.cpu}
onChange={(e) => 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`}
/>
</Row>
<Row title={<Label optional>Memory</Label>} help="Memory limit (e.g. 16GB). Leave blank for the provider default.">
<input
type="text"
name="memory"
aria-label="Memory"
<Row title="Memory" help="Memory limit for each run.">
<ResourceSlider
ariaLabel="Memory"
range={MEMORY}
value={values.memory}
onChange={(e) => patch({ memory: e.target.value })}
placeholder="16GB"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
onChange={(memory) => patch({ memory })}
format={(n) => `${n} GB`}
/>
</Row>
<Row title={<Label optional>Disk</Label>} help="Disk limit (e.g. 20GB). Leave blank for the provider default.">
<input
type="text"
name="disk"
aria-label="Disk"
<Row title="Disk" help="Disk limit for each run.">
<ResourceSlider
ariaLabel="Disk"
range={DISK}
value={values.disk}
onChange={(e) => patch({ disk: e.target.value })}
placeholder="20GB"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
onChange={(disk) => patch({ disk })}
format={(n) => `${n} GB`}
/>
</Row>
</Panel>
@ -435,18 +432,6 @@ export function EnvironmentFormFields({
</div>
</Panel>
<Panel title="Volumes">
<div className="px-4 py-3.5">
<p className="mb-3 text-xs/5 text-fg-3">
Named volumes mounted into the sandbox. Id and mount path are required.
</p>
<VolumeEditor
volumes={values.volumes}
onChange={(volumes) => patch({ volumes })}
/>
</div>
</Panel>
{!lockId && values.id.trim() !== "" && !idValid ? (
<p className="text-xs text-coral">
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<VolumeEntry>) {
onChange(volumes.map((volume, i) => (i === index ? { ...volume, ...partial } : volume)));
}
return (
<div className="space-y-2">
{volumes.map((volume, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
aria-label="Volume id"
value={volume.id}
onChange={(e) => update(index, { id: e.target.value })}
placeholder="cache"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<input
type="text"
aria-label="Mount path"
value={volume.mountPath}
onChange={(e) => update(index, { mountPath: e.target.value })}
placeholder="/cache"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<input
type="text"
aria-label="Subpath"
value={volume.subpath}
onChange={(e) => update(index, { subpath: e.target.value })}
placeholder="subpath (optional)"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<RemoveButton onClick={() => onChange(volumes.filter((_, i) => i !== index))} />
</div>
))}
<AddButton
label="Add volume"
onClick={() => onChange([...volumes, { id: "", mountPath: "", subpath: "" }])}
/>
</div>
);
}
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
@ -610,6 +539,44 @@ function Label({
);
}
function ResourceSlider({
value,
range,
ariaLabel,
format,
onChange,
}: {
value: number;
range: ResourceRange;
ariaLabel: string;
format: (value: number) => string;
onChange: (value: number) => void;
}) {
const fill = ((value - range.min) / (range.max - range.min)) * 100;
return (
<div className="flex items-center gap-4">
<div className="relative h-4 flex-1">
<div className="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-overlay-strong">
<div className="h-full rounded-full bg-teal-500" style={{ width: `${fill}%` }} />
</div>
<input
type="range"
aria-label={ariaLabel}
value={value}
min={range.min}
max={range.max}
step={range.step}
onChange={(e) => onChange(Number(e.target.value))}
className="relative h-4 w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-fg [&::-moz-range-thumb]:shadow-sm [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-fg [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:outline [&::-webkit-slider-thumb]:outline-1 [&::-webkit-slider-thumb]:-outline-offset-1 [&::-webkit-slider-thumb]:outline-line-strong focus-visible:[&::-webkit-slider-thumb]:outline-2 focus-visible:[&::-webkit-slider-thumb]:outline-teal-500"
/>
</div>
<output className="w-16 shrink-0 text-right font-mono text-sm tabular-nums text-fg">
{format(value)}
</output>
</div>
);
}
function ToggleSwitch({
checked,
onChange,

View file

@ -1,21 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface EnvironmentVolumeSettings {
'id': string;
'mount_path': string;
'subpath': string | null;
}