mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
feat(web): redesign environment form UI
Reshape the server-managed environment create/edit form: - Resources: replace CPU/memory/disk inputs with range sliders (CPU 1-8, memory 1-16 GB, disk 1-20 GB). Each always writes a concrete value; existing values are parsed and clamped onto range. - Image: add an Image-source selector (Image reference vs Dockerfile) that shows, requires, and sends only the selected, mutually exclusive source. - Network: replace the mode dropdown + CIDR list with a single "Block all network access" toggle (block vs allow-all). - Labels: remove from the UI entirely; they remain API-managed and are round-tripped untouched so edits never clear API-set labels. - Provider: drop the in-form provider control. "New environment" is now a dropdown of enabled sandbox providers; the chosen provider is passed via query param and fixed for the environment's lifetime. Editing can no longer switch providers. - Layout: merge Identity + Image into one "General" panel, move Environment variables up, and tuck Network + Lifecycle behind an "Advanced" progressive-disclosure section (open by default when any advanced value is non-default). Add environment-form unit tests for image-source validation and request construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d14ded286
commit
f8fdb5c2f4
4 changed files with 339 additions and 223 deletions
50
apps/fabro-web/app/components/environment-form.test.ts
Normal file
50
apps/fabro-web/app/components/environment-form.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
EMPTY_ENVIRONMENT_FORM,
|
||||
createRequestFromForm,
|
||||
isEnvironmentFormValid,
|
||||
type EnvironmentFormValues,
|
||||
} from "./environment-form";
|
||||
|
||||
function form(overrides: Partial<EnvironmentFormValues>): EnvironmentFormValues {
|
||||
return { ...EMPTY_ENVIRONMENT_FORM, id: "docker", ...overrides };
|
||||
}
|
||||
|
||||
describe("environment image source", () => {
|
||||
test("image source requires a non-empty image reference", () => {
|
||||
expect(isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "" }))).toBe(false);
|
||||
expect(
|
||||
isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "ubuntu:24.04" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("dockerfile source requires non-empty Dockerfile contents", () => {
|
||||
expect(isEnvironmentFormValid(form({ imageSource: "dockerfile", dockerfile: "" }))).toBe(false);
|
||||
expect(
|
||||
isEnvironmentFormValid(form({ imageSource: "dockerfile", dockerfile: "FROM ubuntu" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("an empty Dockerfile does not satisfy the image-reference source", () => {
|
||||
expect(
|
||||
isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "", dockerfile: "FROM x" })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("image source sends only the docker reference", () => {
|
||||
const request = createRequestFromForm(
|
||||
form({ imageSource: "image", dockerRef: "ubuntu:24.04", dockerfile: "FROM leftover" }),
|
||||
);
|
||||
expect(request.image.docker).toBe("ubuntu:24.04");
|
||||
expect(request.image.dockerfile).toBeNull();
|
||||
});
|
||||
|
||||
test("dockerfile source sends only the inline Dockerfile", () => {
|
||||
const request = createRequestFromForm(
|
||||
form({ imageSource: "dockerfile", dockerRef: "leftover", dockerfile: "FROM ubuntu" }),
|
||||
);
|
||||
expect(request.image.docker).toBeNull();
|
||||
expect(request.image.dockerfile?.value).toBe("FROM ubuntu");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel, Switch } from "@headlessui/react";
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
EnvironmentApiDockerfileSourceInlineTypeEnum,
|
||||
EnvironmentNetworkMode,
|
||||
|
|
@ -19,6 +20,22 @@ import type {
|
|||
import { Panel, Row } from "./settings-panel";
|
||||
import { INPUT_CLASS } from "./ui";
|
||||
|
||||
// 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}$/;
|
||||
|
||||
|
|
@ -33,37 +50,46 @@ interface KeyValueEntry {
|
|||
value: string;
|
||||
}
|
||||
|
||||
// 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;
|
||||
networkMode: EnvironmentNetworkMode;
|
||||
allow: string;
|
||||
blockNetwork: boolean;
|
||||
preserve: boolean;
|
||||
stopOnTerminal: boolean;
|
||||
autoStop: string;
|
||||
labels: KeyValueEntry[];
|
||||
// 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,
|
||||
networkMode: EnvironmentNetworkMode.ALLOW_ALL,
|
||||
allow: "",
|
||||
blockNetwork: false,
|
||||
preserve: false,
|
||||
stopOnTerminal: true,
|
||||
autoStop: "",
|
||||
labels: [],
|
||||
labels: {},
|
||||
envVars: [],
|
||||
};
|
||||
|
||||
|
|
@ -71,23 +97,42 @@ export function environmentToFormValues(environment: Environment): EnvironmentFo
|
|||
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),
|
||||
networkMode: environment.network.mode,
|
||||
allow: environment.network.allow.join("\n"),
|
||||
blockNetwork: environment.network.mode === EnvironmentNetworkMode.BLOCK,
|
||||
preserve: environment.lifecycle.preserve,
|
||||
stopOnTerminal: environment.lifecycle.stop_on_terminal,
|
||||
autoStop: environment.lifecycle.auto_stop ?? "",
|
||||
labels: entriesFromMap(environment.labels),
|
||||
labels: environment.labels,
|
||||
envVars: entriesFromMap(environment.env),
|
||||
};
|
||||
}
|
||||
|
||||
export function isEnvironmentFormValid(values: EnvironmentFormValues): boolean {
|
||||
return ENVIRONMENT_ID_PATTERN.test(values.id.trim());
|
||||
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 {
|
||||
|
|
@ -105,18 +150,24 @@ function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequ
|
|||
resources: resourcesFromForm(values),
|
||||
network: networkFromForm(values),
|
||||
lifecycle: lifecycleFromForm(values),
|
||||
labels: mapFromEntries(values.labels),
|
||||
labels: values.labels,
|
||||
env: mapFromEntries(values.envVars),
|
||||
};
|
||||
}
|
||||
|
||||
function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings {
|
||||
const dockerfile = values.dockerfile.trim();
|
||||
if (values.imageSource === "dockerfile") {
|
||||
return {
|
||||
docker: null,
|
||||
dockerfile: {
|
||||
type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE,
|
||||
value: values.dockerfile,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
docker: values.dockerRef.trim() || null,
|
||||
dockerfile: dockerfile
|
||||
? { type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, value: values.dockerfile }
|
||||
: null,
|
||||
docker: values.dockerRef.trim() || null,
|
||||
dockerfile: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -162,11 +213,8 @@ function parseGb(value: string | null, range: ResourceRange): number {
|
|||
|
||||
function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings {
|
||||
return {
|
||||
mode: values.networkMode,
|
||||
allow: values.allow
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== ""),
|
||||
mode: values.blockNetwork ? EnvironmentNetworkMode.BLOCK : EnvironmentNetworkMode.ALLOW_ALL,
|
||||
allow: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -190,26 +238,8 @@ function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } {
|
|||
);
|
||||
}
|
||||
|
||||
function parseProvider(value: string): EnvironmentProvider {
|
||||
switch (value) {
|
||||
case EnvironmentProvider.LOCAL:
|
||||
return EnvironmentProvider.LOCAL;
|
||||
case EnvironmentProvider.DAYTONA:
|
||||
return EnvironmentProvider.DAYTONA;
|
||||
default:
|
||||
return EnvironmentProvider.DOCKER;
|
||||
}
|
||||
}
|
||||
|
||||
function parseNetworkMode(value: string): EnvironmentNetworkMode {
|
||||
switch (value) {
|
||||
case EnvironmentNetworkMode.BLOCK:
|
||||
return EnvironmentNetworkMode.BLOCK;
|
||||
case EnvironmentNetworkMode.CIDR_ALLOW_LIST:
|
||||
return EnvironmentNetworkMode.CIDR_ALLOW_LIST;
|
||||
default:
|
||||
return EnvironmentNetworkMode.ALLOW_ALL;
|
||||
}
|
||||
function parseImageSource(value: string): ImageSource {
|
||||
return value === "dockerfile" ? "dockerfile" : "image";
|
||||
}
|
||||
|
||||
interface EnvironmentFormFieldsProps {
|
||||
|
|
@ -231,7 +261,7 @@ export function EnvironmentFormFields({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Panel title="Identity">
|
||||
<Panel title="General">
|
||||
<Row
|
||||
title={<Label required>ID</Label>}
|
||||
help="Lowercase identifier (letters, digits, hyphens). Runs select this environment by id. Cannot be changed after creation."
|
||||
|
|
@ -252,58 +282,56 @@ export function EnvironmentFormFields({
|
|||
/>
|
||||
)}
|
||||
</Row>
|
||||
<Row title={<Label required>Provider</Label>} help="Where runs using this environment execute.">
|
||||
<Row
|
||||
title={<Label required>Source</Label>}
|
||||
help="Whether this environment runs a prebuilt image reference or builds from an inline Dockerfile."
|
||||
>
|
||||
<select
|
||||
name="provider"
|
||||
aria-label="Provider"
|
||||
value={values.provider}
|
||||
onChange={(e) => patch({ provider: parseProvider(e.target.value) })}
|
||||
name="image_source"
|
||||
aria-label="Image source"
|
||||
value={values.imageSource}
|
||||
onChange={(e) => patch({ imageSource: parseImageSource(e.target.value) })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{Object.values(EnvironmentProvider)
|
||||
.filter((provider) => provider !== EnvironmentProvider.LOCAL)
|
||||
.map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
<option value="image">Image reference</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
</select>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Image">
|
||||
<Row
|
||||
title={<Label optional>Image reference</Label>}
|
||||
help="Docker image or Daytona snapshot name (e.g. fabro-v11)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="docker_ref"
|
||||
aria-label="Image reference"
|
||||
value={values.dockerRef}
|
||||
onChange={(e) => patch({ dockerRef: e.target.value })}
|
||||
placeholder="ubuntu:24.04"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label optional>Dockerfile</Label>}
|
||||
help="Inline Dockerfile contents. The REST API accepts inline Dockerfiles only — local paths are rejected."
|
||||
>
|
||||
<textarea
|
||||
name="dockerfile"
|
||||
aria-label="Dockerfile"
|
||||
value={values.dockerfile}
|
||||
onChange={(e) => patch({ dockerfile: e.target.value })}
|
||||
rows={5}
|
||||
placeholder={"FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y git"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
{values.imageSource === "image" ? (
|
||||
<Row
|
||||
title={<Label required>Image reference</Label>}
|
||||
help="Docker image or Daytona snapshot name (e.g. fabro-v11)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="docker_ref"
|
||||
aria-label="Image reference"
|
||||
value={values.dockerRef}
|
||||
onChange={(e) => patch({ dockerRef: e.target.value })}
|
||||
placeholder="ubuntu:24.04"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<Row
|
||||
title={<Label required>Dockerfile</Label>}
|
||||
help="Inline Dockerfile contents. The REST API accepts inline Dockerfiles only — local paths are rejected."
|
||||
>
|
||||
<textarea
|
||||
name="dockerfile"
|
||||
aria-label="Dockerfile"
|
||||
value={values.dockerfile}
|
||||
onChange={(e) => patch({ dockerfile: e.target.value })}
|
||||
rows={5}
|
||||
placeholder={"FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y git"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Resources">
|
||||
|
|
@ -336,87 +364,6 @@ export function EnvironmentFormFields({
|
|||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Network">
|
||||
<Row title={<Label required>Mode</Label>} help="How network egress is restricted for runs in this environment.">
|
||||
<select
|
||||
name="network_mode"
|
||||
aria-label="Network mode"
|
||||
value={values.networkMode}
|
||||
onChange={(e) => patch({ networkMode: parseNetworkMode(e.target.value) })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{Object.values(EnvironmentNetworkMode).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
{values.networkMode === EnvironmentNetworkMode.CIDR_ALLOW_LIST ? (
|
||||
<Row
|
||||
title={<Label optional>Allowed CIDRs</Label>}
|
||||
help="One CIDR per line. Only egress to these ranges is permitted."
|
||||
>
|
||||
<textarea
|
||||
name="allow"
|
||||
aria-label="Allowed CIDRs"
|
||||
value={values.allow}
|
||||
onChange={(e) => patch({ allow: e.target.value })}
|
||||
rows={3}
|
||||
placeholder={"10.0.0.0/8\n192.168.0.0/16"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Lifecycle">
|
||||
<Row title="Preserve" help="Keep the sandbox after the run finishes instead of tearing it down.">
|
||||
<ToggleSwitch
|
||||
checked={values.preserve}
|
||||
onChange={(preserve) => patch({ preserve })}
|
||||
label="Preserve sandbox after run"
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Stop on terminal" help="Stop the sandbox when the run reaches a terminal state.">
|
||||
<ToggleSwitch
|
||||
checked={values.stopOnTerminal}
|
||||
onChange={(stopOnTerminal) => patch({ stopOnTerminal })}
|
||||
label="Stop sandbox on terminal state"
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Auto-stop</Label>} help="Idle duration before the sandbox is stopped (e.g. 30m). Leave blank to disable.">
|
||||
<input
|
||||
type="text"
|
||||
name="auto_stop"
|
||||
aria-label="Auto-stop"
|
||||
value={values.autoStop}
|
||||
onChange={(e) => patch({ autoStop: e.target.value })}
|
||||
placeholder="30m"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Labels">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="mb-3 text-xs/5 text-fg-3">
|
||||
Arbitrary key/value labels attached to the environment.
|
||||
</p>
|
||||
<KeyValueEditor
|
||||
entries={values.labels}
|
||||
onChange={(labels) => patch({ labels })}
|
||||
keyPlaceholder="team"
|
||||
valuePlaceholder="infra"
|
||||
addLabel="Add label"
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Environment variables">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="mb-3 text-xs/5 text-fg-3">
|
||||
|
|
@ -432,6 +379,60 @@ export function EnvironmentFormFields({
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
<Disclosure as="div" className="space-y-4" defaultOpen={hasNonDefaultAdvanced(values)}>
|
||||
<DisclosureButton className="group flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-muted transition-colors hover:text-fg-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
|
||||
<ChevronRightIcon
|
||||
className="size-3.5 transition-transform duration-150 group-data-open:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Advanced
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel className="space-y-6">
|
||||
<Panel title="Network">
|
||||
<Row
|
||||
title="Block all network access"
|
||||
help="Block all outbound network access from the sandbox."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={values.blockNetwork}
|
||||
onChange={(blockNetwork) => patch({ blockNetwork })}
|
||||
label="Block all network access"
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Lifecycle">
|
||||
<Row title="Preserve" help="Keep the sandbox after the run finishes instead of tearing it down.">
|
||||
<ToggleSwitch
|
||||
checked={values.preserve}
|
||||
onChange={(preserve) => patch({ preserve })}
|
||||
label="Preserve sandbox after run"
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Stop on terminal" help="Stop the sandbox when the run reaches a terminal state.">
|
||||
<ToggleSwitch
|
||||
checked={values.stopOnTerminal}
|
||||
onChange={(stopOnTerminal) => patch({ stopOnTerminal })}
|
||||
label="Stop sandbox on terminal state"
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Auto-stop</Label>} help="Idle duration before the sandbox is stopped (e.g. 30m). Leave blank to disable.">
|
||||
<input
|
||||
type="text"
|
||||
name="auto_stop"
|
||||
aria-label="Auto-stop"
|
||||
value={values.autoStop}
|
||||
onChange={(e) => patch({ autoStop: e.target.value })}
|
||||
placeholder="30m"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
|
||||
{!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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
EnvironmentFormFields,
|
||||
createRequestFromForm,
|
||||
isEnvironmentFormValid,
|
||||
parseCreatableProvider,
|
||||
type EnvironmentFormValues,
|
||||
} from "../components/environment-form";
|
||||
import {
|
||||
|
|
@ -48,7 +49,13 @@ function CreateEnvironmentForm() {
|
|||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<EnvironmentFormValues>(EMPTY_ENVIRONMENT_FORM);
|
||||
const [searchParams] = useSearchParams();
|
||||
// The provider is selected via the "New environment" dropdown and arrives as
|
||||
// a query param; it's fixed for the lifetime of the environment.
|
||||
const [values, setValues] = useState<EnvironmentFormValues>(() => ({
|
||||
...EMPTY_ENVIRONMENT_FORM,
|
||||
provider: parseCreatableProvider(searchParams.get("provider")),
|
||||
}));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import { useState } from "react";
|
|||
import { Link } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { PlusIcon } from "@heroicons/react/16/solid";
|
||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/16/solid";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
|
||||
import type { Environment } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
|
||||
import { useEnvironments } from "../lib/queries";
|
||||
import { useEnvironments, useServerSettings } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { CREATABLE_PROVIDERS } from "../components/environment-form";
|
||||
import {
|
||||
Badge,
|
||||
Muted,
|
||||
|
|
@ -23,8 +24,9 @@ import { useToast } from "../components/toast";
|
|||
// in the UI instead of letting the delete fail with a 409.
|
||||
const PROTECTED_ID = "default";
|
||||
|
||||
// `local` is a reserved, in-memory environment (present only when the local
|
||||
// sandbox provider is enabled). It cannot be edited or deleted.
|
||||
// `local` is a reserved, in-memory environment the server includes only when
|
||||
// the local sandbox provider is enabled. It has no configurable settings, so it
|
||||
// gets its own panel instead of a row in the managed environments list.
|
||||
const RESERVED_ID = "local";
|
||||
|
||||
const MENU_ITEM_CLASS =
|
||||
|
|
@ -45,20 +47,9 @@ export default function SettingsEnvironments() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsPageIntro
|
||||
description={DESCRIPTION}
|
||||
action={
|
||||
<Link
|
||||
to="/settings/environments/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg"
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
New environment
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<SettingsPageIntro description={DESCRIPTION} action={<NewEnvironmentMenu />} />
|
||||
{query.data ? (
|
||||
<EnvironmentsPanel environments={query.data.data} />
|
||||
<EnvironmentsContent environments={query.data.data} />
|
||||
) : query.error ? (
|
||||
<Panel title="Environments">
|
||||
<div className="px-4 py-6 text-sm text-fg-2">
|
||||
|
|
@ -72,6 +63,95 @@ export default function SettingsEnvironments() {
|
|||
);
|
||||
}
|
||||
|
||||
const NEW_BUTTON_CLASS =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-line disabled:hover:bg-panel/80 disabled:hover:text-fg-3";
|
||||
|
||||
function providerLabel(provider: string): string {
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
// "New environment" is a provider picker: each enabled sandbox provider opens
|
||||
// the create form pre-set to that provider, which is then fixed for the
|
||||
// environment's lifetime. `local` is never offered (it's reserved/in-memory).
|
||||
function NewEnvironmentMenu() {
|
||||
const { data } = useServerSettings();
|
||||
const providers = data
|
||||
? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled)
|
||||
: [];
|
||||
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={data ? "Enable a sandbox provider to create environments" : "Loading providers…"}
|
||||
className={NEW_BUTTON_CLASS}
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
New environment
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<MenuButton className={NEW_BUTTON_CLASS}>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
New environment
|
||||
<ChevronDownIcon className="size-3.5" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
anchor={{ to: "bottom end", gap: 4 }}
|
||||
className="z-30 w-44 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
<MenuItem key={provider}>
|
||||
<Link
|
||||
to={`/settings/environments/new?provider=${encodeURIComponent(provider)}`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
{providerLabel(provider)}
|
||||
</Link>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentsContent({ environments }: { environments: Environment[] }) {
|
||||
const local = environments.find((environment) => environment.id === RESERVED_ID);
|
||||
const managed = environments.filter((environment) => environment.id !== RESERVED_ID);
|
||||
return (
|
||||
<>
|
||||
<EnvironmentsPanel environments={managed} />
|
||||
{local ? <LocalEnvironmentPanel environment={local} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalEnvironmentPanel({ environment }: { environment: Environment }) {
|
||||
return (
|
||||
<Panel title="Local sandbox">
|
||||
<div className="flex items-start justify-between gap-4 px-4 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-fg">{environment.id}</span>
|
||||
<Badge>{environment.provider}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs/5 text-fg-3 text-pretty">
|
||||
Built-in environment that runs tools directly on the host. Available because the local
|
||||
sandbox provider is enabled; it has no configurable settings and can't be edited or
|
||||
deleted.
|
||||
</p>
|
||||
</div>
|
||||
<StatusTag>reserved</StatusTag>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentsPanel({ environments }: { environments: Environment[] }) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
|
|
@ -157,14 +237,10 @@ function EnvironmentRow({
|
|||
{environment.id}
|
||||
</span>
|
||||
<Badge>{environment.provider}</Badge>
|
||||
{environment.id === RESERVED_ID ? (
|
||||
<StatusTag>reserved</StatusTag>
|
||||
) : environment.id === PROTECTED_ID ? (
|
||||
<StatusTag>protected</StatusTag>
|
||||
) : null}
|
||||
{environment.id === PROTECTED_ID ? <StatusTag>protected</StatusTag> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs/5 text-fg-3">
|
||||
{resourcesSummary(environment)} · network {environment.network.mode}
|
||||
{resourcesSummary(environment)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -210,7 +286,6 @@ function RowMenu({
|
|||
disabled: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const reserved = environment.id === RESERVED_ID;
|
||||
const protectedFromDelete = environment.id === PROTECTED_ID;
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
|
|
@ -229,40 +304,23 @@ function RowMenu({
|
|||
className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
{reserved ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="The local environment is reserved and cannot be edited"
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<hr className="my-1 h-px border-0 bg-line" />
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={disabled || protectedFromDelete || reserved}
|
||||
title={
|
||||
reserved
|
||||
? "The local environment is reserved"
|
||||
: protectedFromDelete
|
||||
? "The default environment is protected"
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || protectedFromDelete}
|
||||
title={protectedFromDelete ? "The default environment is protected" : undefined}
|
||||
className={MENU_ITEM_DANGER_CLASS}
|
||||
>
|
||||
{reserved ? "Reserved" : protectedFromDelete ? "Protected" : "Delete"}
|
||||
{protectedFromDelete ? "Protected" : "Delete"}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue