mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
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:
parent
a163c16cda
commit
1d14ded286
2 changed files with 107 additions and 161 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue