mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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
## 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>
129 lines
4 KiB
TypeScript
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'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.";
|
|
}
|