fabro/apps/fabro-web/app/components/run-summary-panel.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

231 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ReactNode } from "react";
import type {
Principal,
Run,
SandboxResources,
SandboxState,
} from "@qltysh/fabro-api-client";
import { Link } from "react-router";
import {
formatBytesAsMemory,
formatCpuCores,
formatUsdMicros,
} from "../lib/format";
import { principalDisplay } from "../lib/principal-display";
import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries";
import {
SANDBOX_LIFECYCLE_DISPLAY,
sandboxIsReady,
sandboxLifecycleKind,
} from "../lib/run-sandbox-lifecycle";
import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state";
import { Tooltip } from "./ui";
const LABEL_CLASS =
"text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted";
const VALUE_WRAPPER_CLASS = "mt-1.5";
const VALUE_CLASS = "text-sm text-fg";
const VALUE_MONO_CLASS = "text-sm text-fg font-mono tabular-nums";
const EMPTY_VALUE_CLASS = "text-sm text-fg-muted";
function EmptyValue() {
return <span className={EMPTY_VALUE_CLASS}>Not available</span>;
}
function Skeleton({ widthClass }: { widthClass: string }) {
return (
<div
aria-hidden="true"
className={`h-4 ${widthClass} animate-pulse rounded bg-overlay`}
/>
);
}
function Cell({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<div className={LABEL_CLASS}>{label}</div>
<div className={VALUE_WRAPPER_CLASS}>{children}</div>
</div>
);
}
function CreatedByValue({ actor }: { actor: Principal }) {
const created = principalDisplay(actor);
return (
<div className="flex items-center gap-2">
{created.glyph}
<span className={VALUE_CLASS}>{created.label}</span>
</div>
);
}
export interface RunSummaryPanelViewProps {
run: Run | null;
runLoading: boolean;
sandboxState: SandboxState | null;
sandboxResources: SandboxResources | null;
sandboxLoading: boolean;
artifactsCount: number | null;
artifactsLoading: boolean;
}
function SandboxValue({
state,
resources,
}: {
state: SandboxState;
resources: SandboxResources | null;
}) {
const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown;
const cpu = resources?.cpu_cores;
const memory = resources?.memory_bytes;
const valueText =
cpu != null && memory != null
? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}`
: display.label;
return (
<div className="flex items-center gap-2">
<Tooltip label={display.description}>
<span
aria-hidden="true"
className={`size-2 rounded-full ${display.dot}`}
/>
</Tooltip>
<span className={VALUE_CLASS}>{valueText}</span>
</div>
);
}
function SandboxLifecycleValue({
kind,
}: {
kind: keyof typeof SANDBOX_LIFECYCLE_DISPLAY;
}) {
const display = SANDBOX_LIFECYCLE_DISPLAY[kind];
return (
<div className="flex items-center gap-2">
<Tooltip label={display.description}>
<span
aria-hidden="true"
className={`size-2 rounded-full ${display.dot}`}
/>
</Tooltip>
<span className={`${VALUE_CLASS} ${display.text}`}>{display.label}</span>
</div>
);
}
export function RunSummaryPanelView({
run,
runLoading,
sandboxState,
sandboxResources,
sandboxLoading,
artifactsCount,
artifactsLoading,
}: RunSummaryPanelViewProps) {
const diff = run?.diff ?? null;
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
const sandboxKind = sandboxLifecycleKind(run?.sandbox);
return (
<div className="rounded-md border border-line bg-panel/60 px-6 py-4">
<div className="flex flex-wrap items-baseline gap-x-14 gap-y-3">
<Cell label="Created by">
{runLoading ? (
<Skeleton widthClass="w-20" />
) : run ? (
<CreatedByValue actor={run.created_by} />
) : (
<EmptyValue />
)}
</Cell>
<Cell label="Changes">
{runLoading ? (
<Skeleton widthClass="w-32" />
) : diff ? (
<div className="flex items-baseline gap-2 text-sm">
<span className="font-mono tabular-nums">
<span className="text-mint">+{diff.additions.toLocaleString()}</span>{" "}
<span className="text-coral">{diff.deletions.toLocaleString()}</span>
</span>
<span className="text-fg-3">
in {diff.files_changed.toLocaleString()} {diff.files_changed === 1 ? "file" : "files"}
</span>
</div>
) : (
<EmptyValue />
)}
</Cell>
<Cell label="Sandbox">
{sandboxLoading ? (
<Skeleton widthClass="w-24" />
) : sandboxState ? (
<SandboxValue state={sandboxState} resources={sandboxResources} />
) : sandboxKind ? (
<SandboxLifecycleValue kind={sandboxKind} />
) : (
<EmptyValue />
)}
</Cell>
<Cell label="Cost">
{runLoading ? (
<Skeleton widthClass="w-12" />
) : cost != null ? (
<span className={VALUE_MONO_CLASS}>{cost}</span>
) : (
<EmptyValue />
)}
</Cell>
<Cell label="Artifacts">
{artifactsLoading ? (
<Skeleton widthClass="w-8" />
) : artifactsCount != null && artifactsCount > 0 ? (
<span className={VALUE_MONO_CLASS}>{artifactsCount}</span>
) : (
<EmptyValue />
)}
</Cell>
{run?.retried_from && (
<Cell label="Retried from">
<Link
to={`/runs/${encodeURIComponent(run.retried_from)}`}
className="font-mono text-sm text-teal-500 hover:text-teal-300"
>
{run.retried_from.slice(0, 8)}
</Link>
</Cell>
)}
</div>
</div>
);
}
export function RunSummaryPanel({ runId }: { runId: string }) {
const runQuery = useRun(runId);
const sandboxQuery = useRunSandboxDetails(
sandboxIsReady(runQuery.data?.sandbox) ? runId : undefined,
);
const artifactsQuery = useRunArtifacts(runId);
const sandboxReady = sandboxIsReady(runQuery.data?.sandbox);
return (
<RunSummaryPanelView
run={runQuery.data ?? null}
runLoading={runQuery.isLoading && !runQuery.data}
sandboxState={sandboxQuery.data?.state ?? null}
sandboxResources={sandboxQuery.data?.resources ?? null}
sandboxLoading={sandboxReady && sandboxQuery.isLoading && !sandboxQuery.data}
artifactsCount={artifactsQuery.data?.data.length ?? null}
artifactsLoading={artifactsQuery.isLoading && !artifactsQuery.data}
/>
);
}