mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
feat(web): add detail popovers to the run header
Hovering the run header items now reveals a popover with extra context: - Run status: failure reason and error message for failed runs; archived timestamp for archived runs (no popover otherwise) - Repository: full owner/repo name and the cloned branch - Workflow: node and edge counts plus run labels - PR: live GitHub details fetched lazily on hover — title, an open/draft/merged/closed badge, and the head -> base branch arrow Workflow node/edge counts are new: WorkflowRef now carries node_count/edge_count, computed in build_summary from the parsed graph that is already in hand there. Adds a HoverCard primitive alongside Tooltip (shared useHoverAnchor hook) for rich, viewport-aware popovers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2925cce38
commit
5095873ddd
12 changed files with 368 additions and 42 deletions
|
|
@ -2,7 +2,7 @@
|
|||
// exposes the primary button, secondary button, input, error message, and
|
||||
// copy button so the auth and in-app surfaces can match.
|
||||
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import { useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
|
||||
import {
|
||||
|
|
@ -153,30 +153,36 @@ export function ConfirmDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// Shared hover/focus state for `Tooltip` and `HoverCard`. Returns the trigger
|
||||
// props to spread and the trigger rect (only while open) for positioning.
|
||||
function useHoverAnchor() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const rect = open ? (triggerRef.current?.getBoundingClientRect() ?? null) : null;
|
||||
const triggerProps = {
|
||||
ref: triggerRef,
|
||||
onMouseEnter: () => setOpen(true),
|
||||
onMouseLeave: () => setOpen(false),
|
||||
onFocus: () => setOpen(true),
|
||||
onBlur: () => setOpen(false),
|
||||
};
|
||||
return { open, rect, triggerProps };
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const { open, rect, triggerProps } = useHoverAnchor();
|
||||
const id = useId();
|
||||
const rect = open ? triggerRef.current?.getBoundingClientRect() : null;
|
||||
const portalTarget = typeof document === "undefined" ? null : document.body;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
aria-describedby={open ? id : undefined}
|
||||
className="inline-flex"
|
||||
>
|
||||
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
|
||||
{children}
|
||||
</span>
|
||||
{rect && portalTarget
|
||||
|
|
@ -198,3 +204,54 @@ export function Tooltip({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Anchors the card just below the trigger. Triggers in the left half of the
|
||||
// viewport grow rightward from their left edge; triggers in the right half
|
||||
// grow leftward from their right edge — keeping the card on-screen without
|
||||
// measuring its width.
|
||||
function hoverCardStyle(rect: DOMRect): CSSProperties {
|
||||
const margin = 12;
|
||||
const top = rect.bottom + 6;
|
||||
if (rect.left > window.innerWidth / 2) {
|
||||
return { top, right: Math.max(margin, window.innerWidth - rect.right) };
|
||||
}
|
||||
return { top, left: Math.max(margin, rect.left) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover-triggered rich popover. Unlike `Tooltip`, it anchors below the trigger,
|
||||
* stays within the viewport, and allows multi-line wrapping content. The
|
||||
* `content` node is mounted only while open, so consumers may fetch lazily.
|
||||
*/
|
||||
export function HoverCard({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { open, rect, triggerProps } = useHoverAnchor();
|
||||
const id = useId();
|
||||
const portalTarget = typeof document === "undefined" ? null : document.body;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
|
||||
{children}
|
||||
</span>
|
||||
{rect && portalTarget
|
||||
? createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
id={id}
|
||||
style={hoverCardStyle(rect)}
|
||||
className="pointer-events-none fixed z-50 max-w-[18rem] rounded-lg bg-panel p-3 text-xs text-fg-2 shadow-xl outline-1 -outline-offset-1 outline-line-strong"
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
portalTarget,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
PaginatedRunStageList,
|
||||
PaginatedWorkflowListResponse,
|
||||
ProviderList,
|
||||
PullRequestResponse,
|
||||
RunArtifactListResponse,
|
||||
RunBilling,
|
||||
RunProjection,
|
||||
|
|
@ -290,6 +291,15 @@ export function useRunQuestions(id: string | undefined, enabled: boolean) {
|
|||
);
|
||||
}
|
||||
|
||||
// Fetches live pull request details from GitHub. The header popover mounts the
|
||||
// consumer of this hook only on hover, so the request stays lazy.
|
||||
export function useRunPullRequest(id: string | undefined) {
|
||||
return useSWR<PullRequestResponse | null>(
|
||||
id ? queryKeys.runs.pullRequest(id) : null,
|
||||
() => apiNullableData(() => runsApi.getRunPullRequest(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunStageEvents(id: string | undefined, stageId: string | undefined) {
|
||||
return useSWR<EventEnvelope[]>(
|
||||
id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export const queryKeys = {
|
|||
["runs", "sandbox-file", id, path] as const,
|
||||
sandboxVnc: (id: string) => ["runs", "sandbox-vnc", id] as const,
|
||||
sandboxServices: (id: string) => ["runs", "sandbox-services", id] as const,
|
||||
pullRequest: (id: string) => ["runs", "pull-request", id] as const,
|
||||
preview: (id: string) => ["runs", "preview", id] as const,
|
||||
cancel: (id: string) => ["runs", "cancel", id] as const,
|
||||
archive: (id: string) => ["runs", "archive", id] as const,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
|
|
@ -22,13 +24,19 @@ import { InterviewDock } from "../components/interview-dock";
|
|||
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { useToast } from "../components/toast";
|
||||
import { ConfirmDialog, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
|
||||
import { ConfirmDialog, HoverCard, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
|
||||
import {
|
||||
isRunStatus,
|
||||
mapRunToRunItem,
|
||||
runStatusDisplay,
|
||||
type Run,
|
||||
} from "../data/runs";
|
||||
import type {
|
||||
PullRequestDetails,
|
||||
RepositoryRef,
|
||||
RunLifecycle,
|
||||
WorkflowRef,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import { useSWRConfig } from "swr";
|
||||
import {
|
||||
|
|
@ -44,7 +52,7 @@ import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
|
|||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useRunEvents } from "../lib/run-events";
|
||||
import { useRunToasts } from "../hooks/use-run-toasts";
|
||||
import { useRun, useRunQuestions, useRunState } from "../lib/queries";
|
||||
import { useRun, useRunPullRequest, useRunQuestions, useRunState } from "../lib/queries";
|
||||
import {
|
||||
canArchive,
|
||||
canCancel,
|
||||
|
|
@ -167,11 +175,174 @@ export function meta({ data }: any) {
|
|||
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
|
||||
}
|
||||
|
||||
// ---- Header hover-card popovers ----
|
||||
|
||||
function PopoverHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverRows({ children }: { children: ReactNode }) {
|
||||
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
|
||||
}
|
||||
|
||||
function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-fg-3">{label}</dt>
|
||||
<dd className="min-w-0 text-fg">{children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunDetail({ params }: { params: { id: string } }) {
|
||||
const demoMode = useDemoMode();
|
||||
const runQuery = useRun(params.id);
|
||||
const runStateQuery = useRunState(params.id);
|
||||
const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null;
|
||||
const summary = runQuery.data;
|
||||
const run = summary ? buildRunDetailRun(summary) : null;
|
||||
const statusKind = runQuery.data?.lifecycle.status.kind;
|
||||
const isBlocked = statusKind === "blocked";
|
||||
const questionsQuery = useRunQuestions(params.id, isBlocked);
|
||||
|
|
@ -246,7 +417,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
return <div className="py-12" />;
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
if (!run || !summary) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<ErrorState
|
||||
|
|
@ -257,6 +428,33 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
);
|
||||
}
|
||||
|
||||
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 visibility = lifecycleActionVisibility(run.lifecycleStatus);
|
||||
const previewPending = previewMutation.isMutating;
|
||||
const cancelPending = cancelMutation.isMutating;
|
||||
|
|
@ -315,18 +513,38 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
<div className="min-w-0 flex-1">
|
||||
<EditableRunTitle runId={params.id} title={run.title} />
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{showStatusPopover ? (
|
||||
<HoverCard content={<StatusPopover lifecycle={summary.lifecycle} />}>
|
||||
{statusBadge}
|
||||
</HoverCard>
|
||||
) : (
|
||||
statusBadge
|
||||
)}
|
||||
{summary.repository ? (
|
||||
<HoverCard
|
||||
content={
|
||||
<RepositoryPopover
|
||||
repository={summary.repository}
|
||||
cloneBranch={summary.sandbox?.runtime?.clone_branch}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{repoChip}
|
||||
</HoverCard>
|
||||
) : (
|
||||
repoChip
|
||||
)}
|
||||
{showWorkflowPopover ? (
|
||||
<HoverCard
|
||||
content={
|
||||
<WorkflowPopover workflow={summary.workflow} labels={summary.labels} />
|
||||
}
|
||||
>
|
||||
{workflowChip}
|
||||
</HoverCard>
|
||||
) : (
|
||||
workflowChip
|
||||
)}
|
||||
{run.elapsed && (
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
||||
<ClockIcon className="size-3.5" aria-hidden="true" />
|
||||
|
|
@ -347,15 +565,17 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
{demoMode && <ConnectMenu />}
|
||||
|
||||
{run.pullRequestUrl && run.number != null && (
|
||||
<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 content={<PullRequestPopover runId={params.id} />}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<ActionsMenu
|
||||
|
|
|
|||
|
|
@ -7987,7 +7987,7 @@ components:
|
|||
|
||||
WorkflowRef:
|
||||
type: object
|
||||
required: [slug, name, graph_name]
|
||||
required: [slug, name, graph_name, node_count, edge_count]
|
||||
properties:
|
||||
slug:
|
||||
type: ["string", "null"]
|
||||
|
|
@ -7995,6 +7995,14 @@ components:
|
|||
type: ["string", "null"]
|
||||
graph_name:
|
||||
type: ["string", "null"]
|
||||
node_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of nodes in the workflow graph.
|
||||
edge_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of edges in the workflow graph.
|
||||
|
||||
AutomationRef:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
slug: Some("workflow".to_string()),
|
||||
name: Some("Ship workflow".to_string()),
|
||||
graph_name: Some("GraphName".to_string()),
|
||||
node_count: 7,
|
||||
edge_count: 9,
|
||||
},
|
||||
automation: None,
|
||||
repository: Some(RepositoryRef {
|
||||
|
|
@ -91,7 +93,9 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
"workflow": {
|
||||
"slug": "workflow",
|
||||
"name": "Ship workflow",
|
||||
"graph_name": "GraphName"
|
||||
"graph_name": "GraphName",
|
||||
"node_count": 7,
|
||||
"edge_count": 9
|
||||
},
|
||||
"automation": null,
|
||||
"repository": {
|
||||
|
|
@ -197,6 +201,8 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
|
|||
assert_eq!(summary.workflow.name, None);
|
||||
assert_eq!(summary.workflow.graph_name.as_deref(), Some("GraphName"));
|
||||
assert_eq!(summary.workflow.slug, None);
|
||||
assert_eq!(summary.workflow.node_count, 0);
|
||||
assert_eq!(summary.workflow.edge_count, 0);
|
||||
assert_eq!(summary.goal, "ship it");
|
||||
assert_eq!(summary.title, "ship it");
|
||||
assert_eq!(summary.labels, HashMap::new());
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ mod tests {
|
|||
slug: Some("simple".to_string()),
|
||||
name: Some("Simple".to_string()),
|
||||
graph_name: Some("GraphName".to_string()),
|
||||
node_count: 0,
|
||||
edge_count: 0,
|
||||
},
|
||||
automation: None,
|
||||
repository: None,
|
||||
|
|
|
|||
|
|
@ -437,6 +437,8 @@ mod tests {
|
|||
slug: Some("simple".to_string()),
|
||||
name: Some("Simple".to_string()),
|
||||
graph_name: Some("GraphName".to_string()),
|
||||
node_count: 0,
|
||||
edge_count: 0,
|
||||
},
|
||||
automation: None,
|
||||
repository: None,
|
||||
|
|
|
|||
|
|
@ -1117,6 +1117,8 @@ mod runs {
|
|||
slug: Some(workflow_slug.into()),
|
||||
name: Some(workflow_name.into()),
|
||||
graph_name: None,
|
||||
node_count: 0,
|
||||
edge_count: 0,
|
||||
},
|
||||
automation: None,
|
||||
repository: Some(RepositoryRef::from_origin_and_source(
|
||||
|
|
|
|||
|
|
@ -640,6 +640,10 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
slug: state.spec.workflow_slug.clone(),
|
||||
name: state.spec.workflow_name().map(ToOwned::to_owned),
|
||||
graph_name: state.spec.graph_name().map(ToOwned::to_owned),
|
||||
node_count: i64::try_from(state.spec.graph.nodes.len())
|
||||
.expect("graph node count should fit in i64"),
|
||||
edge_count: i64::try_from(state.spec.graph.edges.len())
|
||||
.expect("graph edge count should fit in i64"),
|
||||
},
|
||||
automation: None,
|
||||
repository: Some(RepositoryRef::from_origin_and_source(
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ pub struct WorkflowRef {
|
|||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub graph_name: Option<String>,
|
||||
/// Number of nodes in the workflow graph.
|
||||
#[serde(default)]
|
||||
pub node_count: i64,
|
||||
/// Number of edges in the workflow graph.
|
||||
#[serde(default)]
|
||||
pub edge_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -18,5 +18,13 @@ export interface WorkflowRef {
|
|||
'slug': string | null;
|
||||
'name': string | null;
|
||||
'graph_name': string | null;
|
||||
/**
|
||||
* Number of nodes in the workflow graph.
|
||||
*/
|
||||
'node_count': number;
|
||||
/**
|
||||
* Number of edges in the workflow graph.
|
||||
*/
|
||||
'edge_count': number;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue