fabro/apps/fabro-web/app/routes/settings-environments-edit.tsx
Bryan Helmkamp bc0bda73a6
Some checks are pending
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Build (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
feat(web): add server-managed Environments CRUD settings UI (#462)
## What

Adds a CRUD interface for **server-managed Environments** at
`/settings/environments`, driven by the `/api/v1/environments` REST API
(list / create / retrieve / replace / delete), and reshapes how built-in
environments are provisioned and protected.

The page lives in the **Workflows** settings nav section (also
introduced in this branch), positioned before Variables.

## Why

The Environments REST API shipped (#453) but had no UI — environments
could only be managed via the API/CLI. This gives operators a web UI
alongside Variables and Secrets, and along the way tightens the model:
environments are seeded at install time (not silently re-created on
every boot), and the `default` fallback is an ordinary, deletable
environment.

## Web UI

**Pages & component**
- `settings-environments.tsx` — list view: provider badge,
image/resource summary, row actions (Edit/Delete). **"New environment"
is a dropdown** of the enabled sandbox providers; the chosen provider is
fixed for the environment's lifetime.
- `settings-environments-new.tsx` / `settings-environments-edit.tsx` —
create/edit flows; create reads the provider from a query param.
- `environment-form.tsx` — shared form, reorganized:
- **General** panel (merged identity + image): id, and an **image-source
selector** (Image reference *vs* inline Dockerfile) that shows,
requires, and sends only the selected, mutually-exclusive source.
- **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8,
memory 1–16 GB, disk 1–20 GB), each always writing a concrete value.
  - **Environment variables** key/value editor.
- **Advanced** progressive-disclosure section holding **Network** (a
single "Block all network access" toggle — allow-all vs block) and
**Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by
default when any advanced value is non-default.
- The in-form **provider control and the Labels editor were removed** —
labels remain API-managed and are round-tripped untouched so UI edits
never clear them.

**Data layer**: `environmentsApi` client, `queryKeys.environments`,
`useEnvironments` / `useEnvironment` SWR hooks.

**Nav & routing**: "Environments" item in the Workflows section before
Variables; routes registered in `router.tsx`.

## Backend: seed at install, deletable `default`

- **Seeding moved to install time.** The server no longer seeds
built-ins on startup; `EnvironmentStore::load_or_seed` → `load`
(load-only). A new public `seed_environments(dir)` (idempotent,
preserves operator edits) is called by both the web installer and the
CLI installer. An uninstalled instance therefore has no managed
environments, and a run selecting an absent environment fails explicitly
(`unknown environment: default`) rather than resurrecting a built-in.
- **`default` is no longer protected.** The delete guard and the
`Protected` error variant are gone; deleting `default` succeeds (204)
and removes the run fallback on purpose — forcing an explicit choice.
`local` is unchanged (reserved, in-memory).
- **`volumes` removed** from environment settings across the OpenAPI
spec, generated Rust + TS clients, config layers,
sandbox/server/workflow plumbing, docs, and tests.

## API contract details honored
- Edit sends the environment `revision` as `If-Match`; 409 conflicts
surface a "changed since you opened it" message.
- The REST API accepts inline Dockerfiles only — the form never sends a
Dockerfile path.

## Verification
- Rust: `cargo build` (touched crates) ✅, `cargo nextest -p
fabro-environment` 21/21 ✅, server env unit + `tests/it` integration 2/2
+ 15/15 ✅, `clippy` (nightly, touched crates, all targets) clean ✅, `fmt
--check` clean ✅. Full `--workspace` suite not run here — worth a CI
pass.
- Web: `bun run typecheck` ✅, `bun run build` ✅,
`environment-form.test.ts` 5/5 ✅. Web suite: 512 pass / 1 unrelated
pre-existing `RunDetail` failure.
- **Not visually verified in-browser** — the local app is login-gated
and automated loads redirect to `/login`; rendering of the form, the
New-environment dropdown, and `default` delete should be confirmed in a
logged-in session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Release Repro <release-repro@example.com>
2026-06-13 08:44:38 -04:00

129 lines
4 KiB
TypeScript

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.";
}