mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +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>
231 lines
6.6 KiB
TypeScript
231 lines
6.6 KiB
TypeScript
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}
|
||
/>
|
||
);
|
||
}
|