mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
feat(web): show rich hover popovers on waterfall rows
Replaces the native title tooltips on waterfall phase and stage rows with HoverCard popovers that surface the status pill, started timestamp, and live elapsed/duration. Promotes PopoverHeader, Rows, and Row from run-detail.tsx into components/ui.tsx so the waterfall and run header share one set of primitives, and lets HoverCard accept a className so a full-row block trigger can host the popover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14e2283fcd
commit
6cb3d03ade
3 changed files with 112 additions and 47 deletions
|
|
@ -1,10 +1,15 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { StageState, type RunStage } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatAbsoluteTs, formatDurationMs } from "../lib/format";
|
||||
import { formatStageLabel } from "../lib/stage-sidebar";
|
||||
import {
|
||||
formatStageLabel,
|
||||
stageStatusLabel,
|
||||
stageStatusTone,
|
||||
} from "../lib/stage-sidebar";
|
||||
import { deriveRunPhases, type RunPhase } from "../lib/run-phases";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -25,7 +30,7 @@ interface Row {
|
|||
durationMs: number | null;
|
||||
barClass: string;
|
||||
href: string | null;
|
||||
tooltip: string;
|
||||
popover: ReactNode;
|
||||
}
|
||||
|
||||
const MIN_BAR_WIDTH_PCT = 0.4;
|
||||
|
|
@ -71,23 +76,72 @@ function chooseTickIntervalMs(rangeMs: number): number {
|
|||
return 60 * 60_000;
|
||||
}
|
||||
|
||||
function phasePopover(phase: RunPhase, durationMs: number | null, inFlight: boolean): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<PopoverHeader>{phase.label}</PopoverHeader>
|
||||
<PopoverRows>
|
||||
<PopoverRow label="Started">
|
||||
{formatAbsoluteTs(new Date(phase.startMs).toISOString())}
|
||||
</PopoverRow>
|
||||
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
|
||||
<span className="font-mono">
|
||||
{durationMs != null ? formatDurationMs(durationMs) : "--"}
|
||||
</span>
|
||||
</PopoverRow>
|
||||
</PopoverRows>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseRow(phase: RunPhase, nowMs: number): Row {
|
||||
const endMs = phase.endMs;
|
||||
const inFlight = endMs == null;
|
||||
const closedEnd = endMs ?? nowMs;
|
||||
const durationMs = closedEnd - phase.startMs;
|
||||
const rawDuration = closedEnd - phase.startMs;
|
||||
const durationMs = rawDuration >= 0 ? rawDuration : null;
|
||||
return {
|
||||
key: `phase:${phase.kind}`,
|
||||
kind: "phase",
|
||||
label: phase.label,
|
||||
startMs: phase.startMs,
|
||||
endMs,
|
||||
durationMs: durationMs >= 0 ? durationMs : null,
|
||||
barClass: endMs == null ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40",
|
||||
durationMs,
|
||||
barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40",
|
||||
href: null,
|
||||
tooltip: `${phase.label} • started ${formatAbsoluteTs(new Date(phase.startMs).toISOString())}`,
|
||||
popover: phasePopover(phase, durationMs, inFlight),
|
||||
};
|
||||
}
|
||||
|
||||
function stagePopover(
|
||||
stage: RunStage,
|
||||
durationMs: number | null,
|
||||
inFlight: boolean,
|
||||
): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<PopoverHeader>{formatStageLabel(stage)}</PopoverHeader>
|
||||
<PopoverRows>
|
||||
<PopoverRow label="Status">
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium ${stageStatusTone(stage.status)}`}
|
||||
>
|
||||
{stageStatusLabel(stage.status)}
|
||||
</span>
|
||||
</PopoverRow>
|
||||
{stage.started_at && (
|
||||
<PopoverRow label="Started">{formatAbsoluteTs(stage.started_at)}</PopoverRow>
|
||||
)}
|
||||
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
|
||||
<span className="font-mono">
|
||||
{durationMs != null ? formatDurationMs(durationMs) : "--"}
|
||||
</span>
|
||||
</PopoverRow>
|
||||
</PopoverRows>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null {
|
||||
if (!stage.started_at) return null;
|
||||
const startMs = Date.parse(stage.started_at);
|
||||
|
|
@ -105,7 +159,7 @@ function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null {
|
|||
durationMs,
|
||||
barClass: stageBarClass(stage.status),
|
||||
href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`,
|
||||
tooltip: `${formatStageLabel(stage)} • ${stage.status} • started ${formatAbsoluteTs(stage.started_at)}`,
|
||||
popover: stagePopover(stage, durationMs, inFlight),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -242,11 +296,8 @@ function WaterfallRow({
|
|||
row.kind === "phase"
|
||||
? "text-fg-muted"
|
||||
: "text-fg-2";
|
||||
const content = (
|
||||
<div
|
||||
className="group flex items-center gap-3 px-3 py-1.5 hover:bg-overlay"
|
||||
title={row.tooltip}
|
||||
>
|
||||
const inner = (
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 hover:bg-overlay">
|
||||
<div className={`w-48 shrink-0 truncate font-mono text-xs ${labelClass}`}>
|
||||
{row.label}
|
||||
</div>
|
||||
|
|
@ -261,15 +312,19 @@ function WaterfallRow({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
if (row.href) {
|
||||
return (
|
||||
<Link
|
||||
to={row.href}
|
||||
className="block focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
const trigger = row.href ? (
|
||||
<Link
|
||||
to={row.href}
|
||||
className="block focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
inner
|
||||
);
|
||||
return (
|
||||
<HoverCard content={row.popover} className="block">
|
||||
{trigger}
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,9 +226,11 @@ function hoverCardStyle(rect: DOMRect): CSSProperties {
|
|||
export function HoverCard({
|
||||
content,
|
||||
children,
|
||||
className = "inline-flex",
|
||||
}: {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { open, rect, triggerProps } = useHoverAnchor();
|
||||
const id = useId();
|
||||
|
|
@ -236,7 +238,7 @@ export function HoverCard({
|
|||
|
||||
return (
|
||||
<>
|
||||
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
|
||||
<span {...triggerProps} aria-describedby={open ? id : undefined} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
{rect && portalTarget
|
||||
|
|
@ -255,3 +257,24 @@ export function HoverCard({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverRows({ children }: { children: ReactNode }) {
|
||||
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
|
||||
}
|
||||
|
||||
export 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,15 @@ 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, HoverCard, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
|
||||
import {
|
||||
ConfirmDialog,
|
||||
HoverCard,
|
||||
PopoverHeader,
|
||||
PopoverRow,
|
||||
PopoverRows,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
Tooltip,
|
||||
} from "../components/ui";
|
||||
import {
|
||||
isRunStatus,
|
||||
mapRunToRunItem,
|
||||
|
|
@ -193,27 +201,6 @@ export function meta({ data }: any) {
|
|||
|
||||
// ---- 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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue