mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +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>
322 lines
11 KiB
TypeScript
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'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'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>
|
|
);
|
|
}
|