fabro/apps/fabro-web/app/routes/run-settings.tsx
2026-07-25 10:30:19 -04:00

271 lines
8.6 KiB
TypeScript

import { useMemo, useState } from "react";
import { useParams } from "react-router";
import type { WorkflowSettings } from "@qltysh/fabro-api-client";
import { CollapsibleFile } from "../components/collapsible-file";
import { StageSidebar } from "../components/stage-sidebar";
import {
Badge,
Count,
Mono,
Muted,
Panel,
PanelSkeleton,
Row,
type SettingsView,
Toggle,
ViewToggle,
} from "../components/settings-panel";
import { useRunSettings, useRunStages } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
import {
type UnknownRecord,
getArray,
getBool,
getObject,
getString,
objectKeyCount,
} from "../lib/run-settings-snapshot";
export const handle = { wide: true };
export default function RunSettingsPage() {
const { id } = useParams();
const stagesQuery = useRunStages(id);
const settingsQuery = useRunSettings<WorkflowSettings>(id);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const [view, setView] = useState<SettingsView>("settings");
const snapshot = settingsQuery.data;
return (
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="settings" />
<div className="min-w-0 flex-1 space-y-6">
<PageIntro view={view} setView={setView} />
{!snapshot ? (
<>
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
</>
) : view === "settings" ? (
<>
<WorkflowPanel snapshot={snapshot} />
<EnvironmentPanel snapshot={snapshot} />
<GitPanel snapshot={snapshot} />
<ArtifactsPanel snapshot={snapshot} />
</>
) : (
<CollapsibleFile
file={{
name: "settings.json",
contents: JSON.stringify(snapshot, null, 2),
lang: "json",
}}
/>
)}
</div>
</div>
);
}
function PageIntro({
view,
setView,
}: {
view: SettingsView;
setView: (v: SettingsView) => void;
}) {
return (
<div className="flex items-start justify-between gap-6">
<div>
<h2 className="text-base font-semibold text-fg">Run settings</h2>
<p className="mt-1 text-sm/6 text-fg-3">
Frozen settings snapshot used by this run.
</p>
</div>
<ViewToggle view={view} setView={setView} />
</div>
);
}
function WorkflowPanel({ snapshot }: { snapshot: WorkflowSettings }) {
const workflow = getObject(snapshot, "workflow");
const run = getObject(snapshot, "run");
const name = getString(workflow, "name");
const description = getString(workflow, "description");
const goal = getObject(run, "goal");
return (
<Panel title="Workflow">
<Row title="Name" help="Workflow identifier used by this run.">
{name ? <Mono>{name}</Mono> : <Muted>Unnamed</Muted>}
</Row>
{description ? (
<Row title="Description">
<span className="text-fg-2">{description}</span>
</Row>
) : null}
<Row title="Goal" help="Goal text or file used for this run.">
<GoalValue goal={goal} />
</Row>
<Row title="Inputs" help="Run input variables.">
<Count
n={objectKeyCount(run, "inputs")}
singular="value"
plural="values"
/>
</Row>
<Row title="Metadata" help="Combined run / workflow / project labels.">
<Count
n={
objectKeyCount(run, "metadata") +
objectKeyCount(workflow, "metadata") +
objectKeyCount(getObject(snapshot, "project"), "metadata")
}
singular="label"
plural="labels"
/>
</Row>
</Panel>
);
}
function EnvironmentPanel({ snapshot }: { snapshot: WorkflowSettings }) {
const environment = getObject(getObject(snapshot, "run"), "environment");
const provider = getString(environment, "provider");
const environmentId = getString(environment, "id");
const image = getObject(environment, "image");
const imageRef = getString(image, "ref");
const lifecycle = getObject(environment, "lifecycle");
return (
<Panel title="Environment">
<Row title="Name" help="Named environment selected for this run.">
{environmentId ? <Mono>{environmentId}</Mono> : <Muted>Default</Muted>}
</Row>
<Row title="Provider" help="Sandbox provider for this environment.">
{provider ? <Badge>{provider}</Badge> : <Muted>Unknown</Muted>}
</Row>
{imageRef ? (
<Row title="Image" help="Image or snapshot used for this environment.">
<Mono>{imageRef}</Mono>
</Row>
) : null}
<Row title="Preserve" help="Keep the sandbox after the run completes.">
<Toggle on={getBool(lifecycle, "preserve") ?? false} />
</Row>
<Row title="Stop on terminal" help="Stop the sandbox when the run reaches a terminal state.">
<Toggle on={getBool(lifecycle, "stop_on_terminal") ?? false} />
</Row>
<Row title="Env" help="Environment variables injected into the sandbox.">
<Count n={objectKeyCount(environment, "env")} singular="var" plural="vars" />
</Row>
</Panel>
);
}
function GitPanel({ snapshot }: { snapshot: WorkflowSettings }) {
const run = getObject(snapshot, "run");
const author = getObject(getObject(run, "git"), "author");
const authorName = getString(author, "name");
const authorEmail = getString(author, "email");
const scm = getObject(run, "scm");
const scmProvider = getString(scm, "provider");
const scmOwner = getString(scm, "owner");
const scmRepo = getString(scm, "repository");
const repoLabel = scmOwner && scmRepo ? `${scmOwner}/${scmRepo}` : scmRepo ?? scmOwner;
const pr = getObject(run, "pull_request");
return (
<Panel title="Git">
<Row title="Author" help="Identity used for commits made during the run.">
<AuthorValue name={authorName} email={authorEmail} />
</Row>
<Row title="SCM" help="Source-control provider and repository.">
{scmProvider ? (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<Badge>{scmProvider}</Badge>
{repoLabel ? <Mono>{repoLabel}</Mono> : null}
</span>
) : (
<Muted>None</Muted>
)}
</Row>
<Row title="Pull request" help="Whether this run opens a PR on completion.">
<PullRequestValue pr={pr} />
</Row>
</Panel>
);
}
function ArtifactsPanel({ snapshot }: { snapshot: WorkflowSettings }) {
const artifacts = getObject(getObject(snapshot, "run"), "artifacts");
const include = getArray(artifacts, "include");
return (
<Panel title="Artifacts">
<Row title="Include" help="Workspace-relative globs collected after each stage.">
{include && include.length > 0 ? (
<GlobList globs={include.filter((e): e is string => typeof e === "string")} />
) : (
<Muted>None</Muted>
)}
</Row>
</Panel>
);
}
function GoalValue({ goal }: { goal: UnknownRecord | undefined }) {
if (!goal) return <Muted>None</Muted>;
const type = getString(goal, "type");
const value = getString(goal, "value");
if (!value) return <Muted>None</Muted>;
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{type ? <Badge>{type}</Badge> : null}
<Mono>{value}</Mono>
</span>
);
}
function AuthorValue({ name, email }: { name?: string; email?: string }) {
if (!name && !email) return <Muted>Default</Muted>;
if (name && email) {
return (
<span className="text-fg-2">
{name} <span className="text-fg-muted">&lt;{email}&gt;</span>
</span>
);
}
return <span className="text-fg-2">{name ?? email}</span>;
}
function PullRequestValue({ pr }: { pr: UnknownRecord | undefined }) {
const enabled = getBool(pr, "enabled") ?? false;
if (!enabled) return <Toggle on={false} />;
const draft = getBool(pr, "draft") ?? false;
const autoMerge = getBool(pr, "auto_merge") ?? false;
const strategy = getString(pr, "merge_strategy");
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<Toggle on={true} />
{draft ? <Badge>draft</Badge> : null}
{autoMerge ? <Badge>auto-merge</Badge> : null}
{strategy ? (
<span className="font-mono text-xs text-fg-3">{strategy}</span>
) : null}
</span>
);
}
function GlobList({ globs }: { globs: string[] }) {
return (
<span className="inline-flex flex-wrap items-center gap-1.5">
{globs.map((g) => (
<Badge key={g}>{g}</Badge>
))}
</span>
);
}