fabro/apps/fabro-web/app/routes/settings-environments.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

322 lines
11 KiB
TypeScript

import { useState } from "react";
import { Link } from "react-router";
import { useSWRConfig } from "swr";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
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, useServerSettings } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { CREATABLE_PROVIDERS } from "../components/environment-form";
import {
Badge,
Muted,
Panel,
PanelSkeleton,
SettingsPageIntro,
} from "../components/settings-panel";
import { ConfirmDialog } from "../components/ui";
import { useToast } from "../components/toast";
// `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 =
"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={<NewEnvironmentMenu />} />
{query.data ? (
<EnvironmentsContent 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>
);
}
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&apos;t be edited or
deleted.
</p>
</div>
<StatusTag>reserved</StatusTag>
</div>
</Panel>
);
}
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)}
</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 StatusTag({ children }: { children: string }) {
return (
<span className="rounded-sm bg-overlay px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
{children}
</span>
);
}
function RowMenu({
environment,
disabled,
onDelete,
}: {
environment: Environment;
disabled: boolean;
onDelete: () => void;
}) {
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}
className={MENU_ITEM_DANGER_CLASS}
>
Delete
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}