fabro/apps/fabro-web/app/data/runs.ts
Bryan Helmkamp 716ba17780
Show billed amount in runs list size tooltip
The Size column in the runs list rendered SizeChip without the billed
total, so its tooltip read "Size M" while the run detail header showed
"Size M · $12.34 billed".

The tooltip was also unreachable: the row title link paints a
`before:absolute before:inset-0` overlay across the whole row, which sat
above the chip and swallowed hover. Wrapping the chip in `relative z-10`
lifts it above that overlay, matching how the created-by and pull request
cells already handle interactive content.

Runs without terminal billing keep the plain "Size M" label, same as the
header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:19:57 -04:00

232 lines
7.6 KiB
TypeScript

import { formatDurationMs } from "../lib/format";
import {
BoardColumn,
type Principal,
type Run,
type RunControlAction,
type RunSize,
type RunStatus as ApiRunStatus,
} from "@qltysh/fabro-api-client";
import { sandboxRuntime } from "../lib/run-sandbox-lifecycle";
export type CiStatus = "passing" | "failing" | "pending";
export type CheckStatus = "success" | "failure" | "skipped" | "pending" | "queued";
export interface CheckRun {
name: string;
status: CheckStatus;
duration?: string;
}
export interface RunItem {
id: string;
repo: string;
title: string;
workflow: string;
column?: BoardColumn;
lifecycleStatus?: RunStatus | null;
lifecycleStatusLabel?: string;
pendingControl?: RunControlAction | null;
number?: number;
pullRequestUrl?: string;
additions?: number;
deletions?: number;
checks?: CheckRun[];
elapsed?: string;
resources?: string;
actionDisabled?: boolean;
comments?: number;
question?: string;
pendingApproval?: boolean;
sandboxId?: string;
sandboxWorkingDirectory?: string;
sourceDirectory?: string;
createdAt?: string;
createdBy: Principal;
lastEventAt?: string;
size?: RunSize;
totalUsdMicros?: number;
}
export const columnStatuses = [
BoardColumn.PENDING,
BoardColumn.RUNNABLE,
BoardColumn.INITIALIZING,
BoardColumn.RUNNING,
BoardColumn.BLOCKED,
BoardColumn.SUCCEEDED,
BoardColumn.FAILED,
BoardColumn.ARCHIVED,
BoardColumn.REMOVING,
] as const satisfies readonly BoardColumn[];
export const columnStatusDisplay: Record<BoardColumn, { label: string; dot: string; text: string }> = {
pending: { label: "Pending", dot: "bg-fg-muted", text: "text-fg-muted" },
runnable: { label: "Runnable", dot: "bg-cyan-500", text: "text-cyan-500" },
initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" },
running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" },
blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" },
succeeded: { label: "Succeeded", dot: "bg-teal-300", text: "text-teal-300" },
failed: { label: "Failed", dot: "bg-coral", text: "text-coral" },
archived: { label: "Archived", dot: "bg-fg-muted", text: "text-fg-muted" },
removing: { label: "Removing", dot: "bg-fg-muted", text: "text-fg-muted" },
};
export interface RunWithStatus extends RunItem {
status: BoardColumn;
statusLabel: string;
}
function displayRunTitle(title: string | null | undefined): string {
return title?.trim() ? title : "Untitled run";
}
function displayRepoName(name: string): string {
const slash = name.lastIndexOf("/");
return slash >= 0 ? name.slice(slash + 1) : name;
}
function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | null {
return status?.kind ?? null;
}
export function mapRunListItem(item: Run): RunItem {
const lifecycleStatus = item.lifecycle.archived ? "archived" : runStatusKind(item.lifecycle.status);
const runtime = sandboxRuntime(item.sandbox);
return {
id: item.id,
repo: displayRepoName(item.repository?.name ?? "unknown"),
title: displayRunTitle(item.title),
workflow: item.workflow.name ?? item.workflow.graph_name ?? item.workflow.slug ?? "unknown",
column: columnForRun(item) ?? undefined,
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(item.lifecycle.status, item.lifecycle.archived),
pendingControl: item.lifecycle.pending_control,
number: item.pull_request?.number,
pullRequestUrl: item.pull_request?.html_url,
elapsed: item.timing != null ? formatDurationMs(item.timing.wall_time_ms) : undefined,
resources: undefined,
question: item.current_question?.text,
pendingApproval:
item.lifecycle.status.kind === "pending"
&& item.lifecycle.approval?.state === "pending",
sandboxId: runtime?.id ?? undefined,
sandboxWorkingDirectory: runtime?.working_directory ?? undefined,
sourceDirectory: item.source_directory ?? undefined,
createdAt: item.timestamps.created_at,
createdBy: item.created_by,
lastEventAt: item.timestamps.last_event_at ?? undefined,
additions: item.diff?.additions,
deletions: item.diff?.deletions,
size: item.size,
totalUsdMicros: item.billing?.total_usd_micros ?? undefined,
};
}
export type { Run };
export function mapRunToRunItem(run: Run): RunItem {
return mapRunListItem(run);
}
export function columnForStatus(status: ApiRunStatus | null | undefined): BoardColumn | null {
switch (status?.kind) {
case "submitted":
case "pending":
return "pending";
case "runnable":
return "runnable";
case "starting":
return "initializing";
case "running":
case "paused":
return "running";
case "blocked":
return "blocked";
case "succeeded":
return "succeeded";
case "failed":
case "dead":
return "failed";
case "removing":
return "removing";
default:
return null;
}
}
export function columnForRun(run: Run): BoardColumn | null {
if (run.lifecycle.archived) return "archived";
return columnForStatus(run.lifecycle.status);
}
export function toRunWithStatus(run: Run): RunWithStatus {
const item = mapRunListItem(run);
const column = columnForRun(run) ?? "pending";
return {
...item,
status: column,
statusLabel: columnStatusDisplay[column].label,
};
}
export function deriveCiStatus(checks: CheckRun[]): CiStatus {
if (checks.some((c) => c.status === "failure")) return "failing";
if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending";
return "passing";
}
export type RunStatus =
| "submitted"
| "pending"
| "runnable"
| "starting"
| "running"
| "blocked"
| "paused"
| "removing"
| "succeeded"
| "failed"
| "dead"
| "archived";
export const runStatusDisplay: Record<RunStatus, { label: string; dot: string; text: string }> = {
submitted: { label: "Submitted", dot: "bg-fg-muted", text: "text-fg-muted" },
pending: { label: "Pending", dot: "bg-fg-muted", text: "text-fg-muted" },
runnable: { label: "Runnable", dot: "bg-cyan-500", text: "text-cyan-500" },
starting: { label: "Starting", dot: "bg-amber", text: "text-amber" },
running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" },
blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" },
paused: { label: "Paused", dot: "bg-amber", text: "text-amber" },
removing: { label: "Removing", dot: "bg-fg-muted", text: "text-fg-muted" },
succeeded: { label: "Succeeded", dot: "bg-mint", text: "text-mint" },
failed: { label: "Failed", dot: "bg-coral", text: "text-coral" },
dead: { label: "Dead", dot: "bg-coral", text: "text-coral" },
archived: { label: "Archived", dot: "bg-fg-muted", text: "text-fg-muted" },
};
const knownRunStatuses = new Set<string>(Object.keys(runStatusDisplay));
export function isRunStatus(s: string): s is RunStatus {
return knownRunStatuses.has(s);
}
function lifecycleStatusLabel(status: ApiRunStatus | null | undefined, archived = false): string | undefined {
const kind = archived ? "archived" : runStatusKind(status);
if (!kind) return undefined;
return runStatusDisplay[kind].label;
}
/** Graph control nodes hidden from stage lists in the UI. */
const hiddenStageIds = new Set(["start", "exit"]);
export function isVisibleStage(id: string): boolean {
return !hiddenStageIds.has(id);
}
export const ciConfig: Record<CiStatus, { label: string; dot: string; text: string }> = {
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: { label: "Changes needed", dot: "bg-coral", text: "text-coral" },
pending: { label: "Pending", dot: "bg-amber", text: "text-amber" },
};