feat(web): add server-managed Environments CRUD settings UI

Add /settings/environments for the server-owned Environments REST API:
list, create, edit, and delete environments. The page lives in the new
Workflows settings nav section, before Variables.

- Shared environment-form component covering provider, image (reference +
  inline Dockerfile), resources, network, lifecycle, labels, env vars, and
  volumes
- Edit uses the environment revision as If-Match for optimistic concurrency
  and surfaces 409 conflicts; the default environment is protected from
  deletion
- Adds environmentsApi client, environments query keys, and
  useEnvironments/useEnvironment hooks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-30 12:04:28 -04:00
parent 4abce34ba4
commit c4dbbb4c27
No known key found for this signature in database
9 changed files with 1135 additions and 0 deletions

View file

@ -0,0 +1,630 @@
import type { ReactNode } from "react";
import { Switch } from "@headlessui/react";
import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid";
import {
EnvironmentApiDockerfileSourceInlineTypeEnum,
EnvironmentNetworkMode,
EnvironmentProvider,
} from "@qltysh/fabro-api-client";
import type {
CreateEnvironmentRequest,
Environment,
EnvironmentApiImageSettings,
EnvironmentLifecycleSettings,
EnvironmentNetworkSettings,
EnvironmentResourcesSettings,
EnvironmentVolumeSettings,
ReplaceEnvironmentRequest,
} from "@qltysh/fabro-api-client";
import { Panel, Row } from "./settings-panel";
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}$/;
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;
networkMode: EnvironmentNetworkMode;
allow: string;
preserve: boolean;
stopOnTerminal: boolean;
autoStop: string;
labels: KeyValueEntry[];
envVars: KeyValueEntry[];
volumes: VolumeEntry[];
}
export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = {
id: "",
provider: EnvironmentProvider.DOCKER,
dockerRef: "",
dockerfile: "",
cpu: "",
memory: "",
disk: "",
networkMode: EnvironmentNetworkMode.ALLOW_ALL,
allow: "",
preserve: false,
stopOnTerminal: true,
autoStop: "",
labels: [],
envVars: [],
volumes: [],
};
export function environmentToFormValues(environment: Environment): EnvironmentFormValues {
return {
id: environment.id,
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 ?? "",
networkMode: environment.network.mode,
allow: environment.network.allow.join("\n"),
preserve: environment.lifecycle.preserve,
stopOnTerminal: environment.lifecycle.stop_on_terminal,
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;
}
export function createRequestFromForm(values: EnvironmentFormValues): CreateEnvironmentRequest {
return { id: values.id.trim(), ...settingsFromForm(values) };
}
export function replaceRequestFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest {
return settingsFromForm(values);
}
function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest {
return {
provider: values.provider,
image: imageFromForm(values),
resources: resourcesFromForm(values),
network: networkFromForm(values),
lifecycle: lifecycleFromForm(values),
labels: mapFromEntries(values.labels),
volumes: volumesFromForm(values),
env: mapFromEntries(values.envVars),
};
}
function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings {
const dockerfile = values.dockerfile.trim();
return {
docker: values.dockerRef.trim() || null,
dockerfile: dockerfile
? { type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, value: values.dockerfile }
: null,
};
}
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,
};
}
function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings {
return {
mode: values.networkMode,
allow: values.allow
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== ""),
};
}
function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleSettings {
return {
preserve: values.preserve,
stop_on_terminal: values.stopOnTerminal,
auto_stop: values.autoStop.trim() || null,
};
}
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 }));
}
function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } {
return Object.fromEntries(
entries
.map((entry): [string, string] => [entry.key.trim(), entry.value])
.filter((entry) => entry[0] !== ""),
);
}
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;
}
}
interface EnvironmentFormFieldsProps {
values: EnvironmentFormValues;
onChange: (values: EnvironmentFormValues) => void;
lockId?: boolean;
}
export function EnvironmentFormFields({
values,
onChange,
lockId = false,
}: EnvironmentFormFieldsProps) {
function patch(partial: Partial<EnvironmentFormValues>) {
onChange({ ...values, ...partial });
}
const idValid = ENVIRONMENT_ID_PATTERN.test(values.id.trim());
return (
<>
<Panel title="Identity">
<Row
title={<Label required>ID</Label>}
help="Lowercase identifier (letters, digits, hyphens). Runs select this environment by id. Cannot be changed after creation."
>
{lockId ? (
<div className="font-mono text-sm text-fg">{values.id}</div>
) : (
<input
type="text"
name="id"
aria-label="Environment ID"
value={values.id}
onChange={(e) => patch({ id: e.target.value })}
placeholder="fabro-dev"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
)}
</Row>
<Row title={<Label required>Provider</Label>} help="Where runs using this environment execute.">
<select
name="provider"
aria-label="Provider"
value={values.provider}
onChange={(e) => patch({ provider: parseProvider(e.target.value) })}
className={INPUT_CLASS}
>
{Object.values(EnvironmentProvider).map((provider) => (
<option key={provider} value={provider}>
{provider}
</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>
</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"
value={values.cpu}
onChange={(e) => patch({ cpu: e.target.value })}
placeholder="8"
min={0}
step={1}
autoComplete="off"
className={`${INPUT_CLASS} font-mono`}
/>
</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"
value={values.memory}
onChange={(e) => patch({ memory: e.target.value })}
placeholder="16GB"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</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"
value={values.disk}
onChange={(e) => patch({ disk: e.target.value })}
placeholder="20GB"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</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">
Variables injected into the sandbox for every run.
</p>
<KeyValueEditor
entries={values.envVars}
onChange={(envVars) => patch({ envVars })}
keyPlaceholder="TZ"
valuePlaceholder="UTC"
addLabel="Add variable"
/>
</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.
</p>
) : null}
</>
);
}
function KeyValueEditor({
entries,
onChange,
keyPlaceholder,
valuePlaceholder,
addLabel,
}: {
entries: KeyValueEntry[];
onChange: (entries: KeyValueEntry[]) => void;
keyPlaceholder: string;
valuePlaceholder: string;
addLabel: string;
}) {
function update(index: number, partial: Partial<KeyValueEntry>) {
onChange(entries.map((entry, i) => (i === index ? { ...entry, ...partial } : entry)));
}
return (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
aria-label="Key"
value={entry.key}
onChange={(e) => update(index, { key: e.target.value })}
placeholder={keyPlaceholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<input
type="text"
aria-label="Value"
value={entry.value}
onChange={(e) => update(index, { value: e.target.value })}
placeholder={valuePlaceholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<RemoveButton onClick={() => onChange(entries.filter((_, i) => i !== index))} />
</div>
))}
<AddButton label={addLabel} onClick={() => onChange([...entries, { key: "", value: "" }])} />
</div>
);
}
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
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-xs 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" />
{label}
</button>
);
}
function RemoveButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label="Remove row"
title="Remove"
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-overlay hover:text-coral"
>
<XMarkIcon className="size-4" aria-hidden="true" />
</button>
);
}
function Label({
children,
required,
optional,
}: {
children: ReactNode;
required?: boolean;
optional?: boolean;
}) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span>{children}</span>
{required ? (
<span aria-label="required" className="text-coral">
*
</span>
) : null}
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
</span>
);
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
}) {
return (
<Switch
checked={checked}
onChange={onChange}
aria-label={label}
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
>
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
</Switch>
);
}

View file

@ -8,6 +8,7 @@ import {
AuthApi,
AutomationsApi,
Configuration,
EnvironmentsApi,
HumanInTheLoopApi,
InsightsApi,
InstallApi,
@ -81,6 +82,11 @@ export const automationsApi = new AutomationsApi(
"",
generatedAxios,
);
export const environmentsApi = new EnvironmentsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const humanInTheLoopApi = new HumanInTheLoopApi(
generatedApiConfiguration,
"",

View file

@ -8,6 +8,8 @@ import type {
AutomationListResponse,
BoardColumn,
CommandLogResponse,
Environment,
EnvironmentListResponse,
EventEnvelope,
ListRunsDirectionEnum,
ListRunsSortEnum,
@ -45,6 +47,7 @@ import {
apiResponse,
authApi,
automationsApi,
environmentsApi,
fetchAllPages,
fetchAllStageEvents,
generatedAxios,
@ -427,6 +430,20 @@ export function useAutomationRuns(id: string | undefined, opts: AutomationRunsPa
);
}
export function useEnvironments() {
return useSWR<EnvironmentListResponse>(
queryKeys.environments.list(),
() => apiData(() => environmentsApi.listEnvironments()),
);
}
export function useEnvironment(id: string | undefined) {
return useSWR<Environment | null>(
id ? queryKeys.environments.detail(id) : null,
id ? () => apiNullableData(() => environmentsApi.retrieveEnvironment(id)) : null,
);
}
export function useWorkflows() {
return useSWR<PaginatedWorkflowListResponse | null>(
queryKeys.workflows.list(),

View file

@ -117,4 +117,8 @@ export const queryKeys = {
list: () => ["variables", "list"] as const,
detail: (name: string) => ["variables", "detail", name] as const,
},
environments: {
list: () => ["environments", "list"] as const,
detail: (id: string) => ["environments", "detail", id] as const,
},
};

View file

@ -37,6 +37,9 @@ import * as SettingsGeneral from "./routes/settings-general";
import * as SettingsIntegrations from "./routes/settings-integrations";
import * as SettingsModels from "./routes/settings-models";
import * as SettingsSandboxes from "./routes/settings-sandboxes";
import * as SettingsEnvironments from "./routes/settings-environments";
import * as SettingsEnvironmentsNew from "./routes/settings-environments-new";
import * as SettingsEnvironmentsEdit from "./routes/settings-environments-edit";
import * as SettingsSecrets from "./routes/settings-secrets";
import * as SettingsSecretsNew from "./routes/settings-secrets-new";
import * as SettingsVariables from "./routes/settings-variables";
@ -146,6 +149,9 @@ export const routes: RouteObject[] = [
route("integrations", SettingsIntegrations),
route("sandboxes", SettingsSandboxes),
route("security", SettingsSecurity),
route("environments", SettingsEnvironments),
route("environments/new", SettingsEnvironmentsNew),
route("environments/:id/edit", SettingsEnvironmentsEdit),
route("variables", SettingsVariables),
route("variables/new", SettingsVariablesNew),
route("variables/:name/edit", SettingsVariablesEdit),

View file

@ -0,0 +1,129 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { useEnvironment } from "../lib/queries";
import {
EnvironmentFormFields,
environmentToFormValues,
isEnvironmentFormValid,
replaceRequestFromForm,
type EnvironmentFormValues,
} from "../components/environment-form";
import { Panel, PanelSkeleton } from "../components/settings-panel";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
import { useToast } from "../components/toast";
export function meta() {
return [{ title: "Edit environment — Fabro" }];
}
export default function SettingsEnvironmentsEdit() {
const { id } = useParams<{ id: string }>();
const query = useEnvironment(id);
return (
<div className="space-y-6">
<PageHeader id={id ?? ""} />
{query.data ? (
<EditEnvironmentForm key={query.data.revision} environment={query.data} />
) : query.error ? (
<Panel title="Environment">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load this environment. It may have been deleted.
</div>
</Panel>
) : (
<PanelSkeleton />
)}
</div>
);
}
function PageHeader({ id }: { id: string }) {
return (
<nav className="flex items-center gap-1 text-sm text-fg-muted">
<Link to="/settings/environments" className="text-fg-3 hover:text-fg">
Environments
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span className="font-mono text-fg-2">{id}</span>
</nav>
);
}
function EditEnvironmentForm({ environment }: { environment: Environment }) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
const [values, setValues] = useState<EnvironmentFormValues>(() =>
environmentToFormValues(environment),
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = isEnvironmentFormValid(values) && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
await apiData(() =>
environmentsApi.replaceEnvironment(
environment.id,
environment.revision,
replaceRequestFromForm(values),
),
);
await mutate(queryKeys.environments.list());
await mutate(queryKeys.environments.detail(environment.id));
toast.push({ message: `Environment “${environment.id}” updated.` });
navigate("/settings/environments");
} catch (cause) {
setError(staleAwareMessage(cause));
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<EnvironmentFormFields values={values} onChange={setValues} lockId />
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => navigate("/settings/environments")}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
{submitting ? "Saving…" : "Save changes"}
</button>
</div>
</form>
);
}
function staleAwareMessage(cause: unknown): string {
if (cause instanceof ApiError && cause.status === 409) {
return "This environment changed since you opened it. Reload the page to get the latest version, then reapply your edits.";
}
if (cause instanceof ApiError && cause.message) {
return cause.message;
}
return "Couldn't update the environment. Please try again.";
}

View file

@ -0,0 +1,99 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import {
EMPTY_ENVIRONMENT_FORM,
EnvironmentFormFields,
createRequestFromForm,
isEnvironmentFormValid,
type EnvironmentFormValues,
} from "../components/environment-form";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
import { useToast } from "../components/toast";
export function meta() {
return [{ title: "New environment — Fabro" }];
}
export default function SettingsEnvironmentsNew() {
return (
<div className="space-y-6">
<PageHeader />
<CreateEnvironmentForm />
</div>
);
}
function PageHeader() {
return (
<nav className="flex items-center gap-1 text-sm text-fg-muted">
<Link to="/settings/environments" className="text-fg-3 hover:text-fg">
Environments
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span>New environment</span>
</nav>
);
}
function CreateEnvironmentForm() {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
const [values, setValues] = useState<EnvironmentFormValues>(EMPTY_ENVIRONMENT_FORM);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = isEnvironmentFormValid(values) && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
const id = values.id.trim();
try {
await apiData(() => environmentsApi.createEnvironment(createRequestFromForm(values)));
await mutate(queryKeys.environments.list());
toast.push({ message: `Environment “${id}” created.` });
navigate("/settings/environments");
} catch (cause) {
setError(
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't create the environment. Please try again.",
);
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<EnvironmentFormFields values={values} onChange={setValues} />
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => navigate("/settings/environments")}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
{submitting ? "Creating…" : "Create environment"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,236 @@
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 { 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 { queryKeys } from "../lib/query-keys";
import {
Badge,
Muted,
Panel,
PanelSkeleton,
SettingsPageIntro,
} from "../components/settings-panel";
import { ConfirmDialog } from "../components/ui";
import { useToast } from "../components/toast";
// `default` is seeded and protected from deletion by the server; reflect that
// in the UI instead of letting the delete fail with a 409.
const PROTECTED_ID = "default";
const MENU_ITEM_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
const MENU_ITEM_DANGER_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
export function meta() {
return [{ title: "Environments — Fabro" }];
}
const DESCRIPTION =
"Environments are server-managed runtime definitions — provider, image, resources, network, and lifecycle — that workflow runs select by id. They are operator policy stored on this Fabro server.";
export default function SettingsEnvironments() {
const query = useEnvironments();
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>
}
/>
{query.data ? (
<EnvironmentsPanel environments={query.data.data} />
) : query.error ? (
<Panel title="Environments">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load environments. Please try again.
</div>
</Panel>
) : (
<PanelSkeleton />
)}
</div>
);
}
function EnvironmentsPanel({ environments }: { environments: Environment[] }) {
const { mutate } = useSWRConfig();
const toast = useToast();
const [pendingDelete, setPendingDelete] = useState<Environment | null>(null);
const [deleting, setDeleting] = useState(false);
async function confirmDelete() {
if (!pendingDelete) return;
const target = pendingDelete;
setDeleting(true);
try {
await apiData(() => environmentsApi.deleteEnvironment(target.id, target.revision));
await mutate(queryKeys.environments.list());
toast.push({ message: `Environment “${target.id}” deleted.` });
setPendingDelete(null);
} catch (cause) {
toast.push({
tone: "error",
message:
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't delete the environment. Please try again.",
});
} finally {
setDeleting(false);
}
}
return (
<>
<Panel title="Environments">
{environments.length === 0 ? (
<div className="px-4 py-6 text-sm text-fg-muted">
No environments defined yet.
</div>
) : (
environments.map((environment) => (
<EnvironmentRow
key={environment.id}
environment={environment}
disabled={deleting}
onDelete={() => setPendingDelete(environment)}
/>
))
)}
</Panel>
<ConfirmDialog
open={pendingDelete !== null}
title="Delete environment"
description={
<>
Delete{" "}
<span className="font-mono text-fg-2">{pendingDelete?.id}</span>? Runs that
select this environment will fail until it is recreated.
</>
}
confirmLabel="Delete"
pendingLabel="Deleting…"
pending={deleting}
onConfirm={confirmDelete}
onCancel={() => {
if (!deleting) setPendingDelete(null);
}}
/>
</>
);
}
function EnvironmentRow({
environment,
disabled,
onDelete,
}: {
environment: Environment;
disabled: boolean;
onDelete: () => void;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_auto] items-center gap-4 px-4 py-3.5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-sm text-fg" title={environment.id}>
{environment.id}
</span>
<Badge>{environment.provider}</Badge>
</div>
<div className="mt-0.5 truncate text-xs/5 text-fg-3">
{resourcesSummary(environment)} · network {environment.network.mode}
</div>
</div>
<div
className="min-w-0 truncate font-mono text-xs text-fg-2"
title={imageSummary(environment) ?? undefined}
>
{imageSummary(environment) ?? <Muted>No image</Muted>}
</div>
<RowMenu environment={environment} disabled={disabled} onDelete={onDelete} />
</div>
);
}
function imageSummary(environment: Environment): string | null {
if (environment.image.docker) return environment.image.docker;
if (environment.image.dockerfile) return "Dockerfile (inline)";
return null;
}
function resourcesSummary(environment: Environment): string {
const parts = [
environment.resources.cpu === null ? null : `${environment.resources.cpu} CPU`,
environment.resources.memory,
environment.resources.disk,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" · ") : "Default resources";
}
function RowMenu({
environment,
disabled,
onDelete,
}: {
environment: Environment;
disabled: boolean;
onDelete: () => void;
}) {
const protectedFromDelete = environment.id === PROTECTED_ID;
return (
<Menu as="div" className="relative inline-block">
<MenuButton
type="button"
disabled={disabled}
aria-label={`Actions for ${environment.id}`}
title="Actions"
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-60"
>
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
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>
<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}
title={protectedFromDelete ? "The default environment is protected" : undefined}
className={MENU_ITEM_DANGER_CLASS}
>
{protectedFromDelete ? "Protected" : "Delete"}
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}

View file

@ -9,6 +9,7 @@ import {
KeyIcon,
PuzzlePieceIcon,
ShieldCheckIcon,
Square3Stack3DIcon,
} from "@heroicons/react/24/outline";
import { Fragment } from "react";
import { Link, Outlet, useLocation, useMatches } from "react-router";
@ -65,6 +66,13 @@ export const navSections: NavSection[] = [
key: "workflows",
label: "Workflows",
items: [
{
name: "Environments",
href: "/settings/environments",
icon: Square3Stack3DIcon,
description: "Server-managed runtime definitions for runs.",
match: (p) => p.startsWith("/settings/environments"),
},
{
name: "Variables",
href: "/settings/variables",