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({
- Named volumes mounted into the sandbox. Id and mount path are required.
-
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