mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import { Fragment, type ReactNode } from "react";
|
|
import {
|
|
ArrowPathIcon,
|
|
CheckIcon,
|
|
ChevronRightIcon,
|
|
ClockIcon,
|
|
FolderIcon,
|
|
RectangleStackIcon,
|
|
SignalIcon,
|
|
} from "@heroicons/react/20/solid";
|
|
import { Link } from "react-router";
|
|
|
|
import { EditableRunTitle } from "../../components/editable-run-title";
|
|
import { GitPullRequestIcon } from "../../components/icons";
|
|
import { SizeChip } from "../../components/size-chip";
|
|
import {
|
|
HoverCard,
|
|
PopoverHeader,
|
|
PopoverRow,
|
|
PopoverRows,
|
|
PRIMARY_BUTTON_CLASS,
|
|
SECONDARY_BUTTON_CLASS,
|
|
Tooltip,
|
|
} from "../../components/ui";
|
|
import type {
|
|
PullRequestDetails,
|
|
RepositoryRef,
|
|
RunLifecycle,
|
|
RunTiming,
|
|
WorkflowRef,
|
|
} from "@qltysh/fabro-api-client";
|
|
import type { Run } from "../../data/runs";
|
|
import {
|
|
formatAbsoluteTs,
|
|
formatDurationMs,
|
|
formatRelativeTime,
|
|
} from "../../lib/format";
|
|
import { classNames } from "../../lib/class-names";
|
|
import { useRunPullRequest } from "../../lib/queries";
|
|
import { sandboxRuntime } from "../../lib/run-sandbox-lifecycle";
|
|
import { ActionsMenu, type ActionsMenuProps } from "./actions";
|
|
import type { RunDetailRun } from "./model";
|
|
|
|
export interface RunDetailHeaderActions {
|
|
approval: {
|
|
visible: boolean;
|
|
pending: boolean;
|
|
onApprove: () => void;
|
|
};
|
|
menu: ActionsMenuProps;
|
|
askTrigger: ReactNode;
|
|
}
|
|
|
|
export function RunDetailHeader({
|
|
runId,
|
|
run,
|
|
summary,
|
|
fullHeight,
|
|
now,
|
|
actions,
|
|
}: {
|
|
runId: string;
|
|
run: RunDetailRun;
|
|
summary: Run;
|
|
fullHeight: boolean;
|
|
now: number;
|
|
actions: RunDetailHeaderActions;
|
|
}) {
|
|
const showStatusPopover =
|
|
summary.lifecycle.status.kind === "failed" ||
|
|
summary.lifecycle.archived ||
|
|
summary.lifecycle.error != null;
|
|
const showWorkflowPopover =
|
|
summary.workflow.node_count > 0 ||
|
|
summary.workflow.edge_count > 0 ||
|
|
Object.keys(summary.labels).length > 0;
|
|
const statusBadge = (
|
|
<span className="flex items-center gap-1.5">
|
|
<span className={`size-2 rounded-full ${run.statusDot}`} />
|
|
<span className={`font-medium ${run.statusText}`}>{run.statusLabel}</span>
|
|
</span>
|
|
);
|
|
const repoChip = (
|
|
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
|
<FolderIcon className="size-3.5" aria-hidden="true" />
|
|
{run.repo}
|
|
</span>
|
|
);
|
|
const workflowChip = (
|
|
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
|
<RectangleStackIcon className="size-3.5" aria-hidden="true" />
|
|
{run.workflow}
|
|
</span>
|
|
);
|
|
const sizeChip = (
|
|
<SizeChip size={summary.size} totalUsdMicros={summary.billing?.total_usd_micros} />
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<nav
|
|
className={classNames(
|
|
"mb-4 flex items-center gap-1 text-sm text-fg-muted",
|
|
fullHeight && "shrink-0",
|
|
)}
|
|
>
|
|
<Link to="/runs" className="text-fg-3 hover:text-fg">Runs</Link>
|
|
<ChevronRightIcon className="size-3" />
|
|
<Link
|
|
to={`/runs?workflow=${encodeURIComponent(run.workflow)}`}
|
|
className="text-fg-3 hover:text-fg"
|
|
>
|
|
{run.workflow}
|
|
</Link>
|
|
<ChevronRightIcon className="size-3" />
|
|
<span>{run.title}</span>
|
|
</nav>
|
|
|
|
<div
|
|
className={classNames(
|
|
"mb-6 flex flex-wrap items-start gap-4",
|
|
fullHeight && "shrink-0",
|
|
)}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<EditableRunTitle runId={runId} title={run.title} />
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
|
{showStatusPopover ? (
|
|
<HoverCard content={<StatusPopover lifecycle={summary.lifecycle} />}>
|
|
{statusBadge}
|
|
</HoverCard>
|
|
) : (
|
|
statusBadge
|
|
)}
|
|
{summary.repository ? (
|
|
<HoverCard
|
|
content={
|
|
<RepositoryPopover
|
|
repository={summary.repository}
|
|
cloneBranch={sandboxRuntime(summary.sandbox)?.clone_branch}
|
|
/>
|
|
}
|
|
>
|
|
{repoChip}
|
|
</HoverCard>
|
|
) : (
|
|
repoChip
|
|
)}
|
|
{showWorkflowPopover ? (
|
|
<HoverCard
|
|
content={
|
|
<WorkflowPopover workflow={summary.workflow} labels={summary.labels} />
|
|
}
|
|
>
|
|
{workflowChip}
|
|
</HoverCard>
|
|
) : (
|
|
workflowChip
|
|
)}
|
|
{run.elapsed && summary.timing && (
|
|
<HoverCard
|
|
content={
|
|
<DurationPopover
|
|
timing={summary.timing}
|
|
createdAt={summary.timestamps.created_at}
|
|
completedAt={summary.timestamps.completed_at}
|
|
now={now}
|
|
/>
|
|
}
|
|
>
|
|
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
|
<ClockIcon className="size-3.5" aria-hidden="true" />
|
|
{run.elapsed}
|
|
</span>
|
|
</HoverCard>
|
|
)}
|
|
{run.lastEventAt && (
|
|
<Tooltip label={`Last event ${formatAbsoluteTs(run.lastEventAt)}`}>
|
|
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
|
<SignalIcon className="size-3.5" aria-hidden="true" />
|
|
{formatRelativeTime(run.lastEventAt, now)}
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
{sizeChip}
|
|
</div>
|
|
</div>
|
|
|
|
{run.pullRequestUrl && run.number != null && (
|
|
<HoverCard content={<PullRequestPopover runId={runId} />}>
|
|
<a
|
|
href={run.pullRequestUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={SECONDARY_BUTTON_CLASS}
|
|
>
|
|
<GitPullRequestIcon className="size-4 text-mint" />
|
|
<span className="font-mono">#{run.number}</span>
|
|
</a>
|
|
</HoverCard>
|
|
)}
|
|
|
|
{actions.approval.visible && (
|
|
<button
|
|
type="button"
|
|
onClick={actions.approval.onApprove}
|
|
disabled={actions.approval.pending}
|
|
className={PRIMARY_BUTTON_CLASS}
|
|
>
|
|
{actions.approval.pending ? (
|
|
<ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<CheckIcon className="size-4" aria-hidden="true" />
|
|
)}
|
|
{actions.approval.pending ? "Approving…" : "Approve"}
|
|
</button>
|
|
)}
|
|
|
|
<ActionsMenu {...actions.menu} />
|
|
|
|
{actions.askTrigger}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function humanizeFailureReason(reason: string): string {
|
|
const spaced = reason.replace(/_/g, " ");
|
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
}
|
|
|
|
/** Shown only when the run failed or is archived — see `showStatusPopover`. */
|
|
function StatusPopover({ lifecycle }: { lifecycle: RunLifecycle }) {
|
|
const status = lifecycle.status;
|
|
return (
|
|
<>
|
|
<PopoverHeader>Run status</PopoverHeader>
|
|
<PopoverRows>
|
|
{status.kind === "failed" && (
|
|
<PopoverRow label="Reason">{humanizeFailureReason(status.reason)}</PopoverRow>
|
|
)}
|
|
{lifecycle.error && (
|
|
<PopoverRow label="Error">
|
|
<span className="break-words">{lifecycle.error.message}</span>
|
|
</PopoverRow>
|
|
)}
|
|
{lifecycle.archived && (
|
|
<PopoverRow label="Archived">
|
|
{lifecycle.archived_at ? formatAbsoluteTs(lifecycle.archived_at) : "Yes"}
|
|
</PopoverRow>
|
|
)}
|
|
</PopoverRows>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RepositoryPopover({
|
|
repository,
|
|
cloneBranch,
|
|
}: {
|
|
repository: RepositoryRef;
|
|
cloneBranch: string | null | undefined;
|
|
}) {
|
|
return (
|
|
<>
|
|
<PopoverHeader>Repository</PopoverHeader>
|
|
<PopoverRows>
|
|
<PopoverRow label="Name">
|
|
<span className="font-mono break-all">{repository.name}</span>
|
|
</PopoverRow>
|
|
{cloneBranch && (
|
|
<PopoverRow label="Branch">
|
|
<span className="font-mono break-all">{cloneBranch}</span>
|
|
</PopoverRow>
|
|
)}
|
|
</PopoverRows>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function WorkflowPopover({
|
|
workflow,
|
|
labels,
|
|
}: {
|
|
workflow: WorkflowRef;
|
|
labels: Record<string, string>;
|
|
}) {
|
|
const labelEntries = Object.entries(labels);
|
|
const hasCounts = workflow.node_count > 0 || workflow.edge_count > 0;
|
|
return (
|
|
<>
|
|
<PopoverHeader>Workflow</PopoverHeader>
|
|
{hasCounts && (
|
|
<div className="text-fg">
|
|
{workflow.node_count} {workflow.node_count === 1 ? "node" : "nodes"}
|
|
<span className="text-fg-muted"> · </span>
|
|
{workflow.edge_count} {workflow.edge_count === 1 ? "edge" : "edges"}
|
|
</div>
|
|
)}
|
|
{labelEntries.length > 0 && (
|
|
<div className={hasCounts ? "mt-2" : undefined}>
|
|
<div className="mb-1 text-fg-3">Labels</div>
|
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
|
{labelEntries.map(([key, value]) => (
|
|
<Fragment key={key}>
|
|
<dt className="font-mono text-fg-3">{key}</dt>
|
|
<dd className="min-w-0 font-mono break-all text-fg">{value}</dd>
|
|
</Fragment>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DurationPopover({
|
|
timing,
|
|
createdAt,
|
|
completedAt,
|
|
now,
|
|
}: {
|
|
timing: RunTiming;
|
|
createdAt: string;
|
|
completedAt: string | null;
|
|
now: number;
|
|
}) {
|
|
const endMs = completedAt != null ? Date.parse(completedAt) : now;
|
|
const sinceCreatedMs = Math.max(0, endMs - Date.parse(createdAt));
|
|
const isRunning = completedAt == null;
|
|
return (
|
|
<>
|
|
<PopoverHeader>Duration</PopoverHeader>
|
|
<dl className="space-y-2">
|
|
<div>
|
|
<dt className="text-fg-3">Wall-clock since created</dt>
|
|
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(sinceCreatedMs)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-fg-3">
|
|
Active (inference + tools){isRunning ? " — estimated" : ""}
|
|
</dt>
|
|
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(timing.active_time_ms)}</dd>
|
|
<dd className="mt-0.5 text-fg-3">
|
|
{formatDurationMs(timing.inference_time_ms)} inference ·{" "}
|
|
{formatDurationMs(timing.tool_time_ms)} tools
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function prStateBadge(details: PullRequestDetails): { label: string; className: string } {
|
|
if (details.merged) return { label: "Merged", className: "bg-mint/15 text-mint" };
|
|
if (details.draft) return { label: "Draft", className: "bg-overlay-strong text-fg-3" };
|
|
if (details.state === "closed") {
|
|
return { label: "Closed", className: "bg-coral/15 text-coral" };
|
|
}
|
|
return { label: "Open", className: "bg-teal-500/15 text-teal-300" };
|
|
}
|
|
|
|
/** Fetches live PR details on hover — mounted only while the card is open. */
|
|
function PullRequestPopover({ runId }: { runId: string }) {
|
|
const prQuery = useRunPullRequest(runId);
|
|
const response = prQuery.data;
|
|
const details =
|
|
response?.meta.details_status === "available" ? response.data.details : null;
|
|
|
|
let body: ReactNode;
|
|
if (prQuery.isLoading) {
|
|
body = <div className="text-fg-3">Loading…</div>;
|
|
} else if (!details) {
|
|
body = <div className="text-fg-3">Live details unavailable.</div>;
|
|
} else {
|
|
const badge = prStateBadge(details);
|
|
body = (
|
|
<div className="space-y-2">
|
|
<div className="break-words text-fg">{details.title}</div>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium ${badge.className}`}
|
|
>
|
|
{badge.label}
|
|
</span>
|
|
<span className="flex min-w-0 items-center gap-1 font-mono text-fg-3">
|
|
<span className="truncate">{details.head_branch}</span>
|
|
<span className="shrink-0 text-fg-muted">→</span>
|
|
<span className="truncate">{details.base_branch}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<PopoverHeader>Pull request</PopoverHeader>
|
|
{body}
|
|
</>
|
|
);
|
|
}
|