773 lines
29 KiB
TypeScript
773 lines
29 KiB
TypeScript
import {
|
||
ArrowPathIcon,
|
||
ArrowRightIcon,
|
||
ClockIcon,
|
||
CpuChipIcon,
|
||
NoSymbolIcon,
|
||
RectangleStackIcon,
|
||
} from "@heroicons/react/20/solid";
|
||
import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
|
||
import { useLocation } from "@remix-run/react";
|
||
import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||
import { useCallback, useRef } from "react";
|
||
import { TasksIcon } from "~/assets/icons/TasksIcon";
|
||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||
import { MachineTooltipInfo } from "~/components/MachineTooltipInfo";
|
||
import { Badge } from "~/components/primitives/Badge";
|
||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||
import { Header3 } from "~/components/primitives/Headers";
|
||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
|
||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||
import { useRegions } from "~/hooks/useRegions";
|
||
import { useFeatures } from "~/hooks/useFeatures";
|
||
import { useOrganization } from "~/hooks/useOrganizations";
|
||
import { useProject } from "~/hooks/useProject";
|
||
import {
|
||
type NextRunListAppliedFilters,
|
||
type NextRunListItem,
|
||
} from "~/presenters/v3/NextRunListPresenter.server";
|
||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||
import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
|
||
import { DateTime } from "../../primitives/DateTime";
|
||
import { Paragraph } from "../../primitives/Paragraph";
|
||
import { Spinner } from "../../primitives/Spinner";
|
||
import {
|
||
Table,
|
||
TableBlankRow,
|
||
TableBody,
|
||
TableCell,
|
||
TableCellMenu,
|
||
TableHeader,
|
||
TableHeaderCell,
|
||
TableRow,
|
||
type TableVariant,
|
||
} from "../../primitives/Table";
|
||
import { CancelRunDialog } from "./CancelRunDialog";
|
||
import { RegionLabel } from "./RegionLabel";
|
||
import { LiveTimer } from "./LiveTimer";
|
||
import { ReplayRunDialog } from "./ReplayRunDialog";
|
||
import { RunTag } from "./RunTag";
|
||
import {
|
||
descriptionForTaskRunStatus,
|
||
filterableTaskRunStatuses,
|
||
TaskRunStatusCombo,
|
||
} from "./TaskRunStatus";
|
||
import { RunStatusCellTooltip } from "./RunStatusCellTooltip";
|
||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
|
||
|
||
type RunsTableProps = {
|
||
total: number;
|
||
hasFilters: boolean;
|
||
filters: NextRunListAppliedFilters;
|
||
showJob?: boolean;
|
||
runs: NextRunListItem[];
|
||
rootOnlyDefault?: boolean;
|
||
isLoading?: boolean;
|
||
allowSelection?: boolean;
|
||
variant?: TableVariant;
|
||
disableAdjacentRows?: boolean;
|
||
additionalTableState?: Record<string, string>;
|
||
showTopBorder?: boolean;
|
||
stickyHeader?: boolean;
|
||
childrenStatusesBasePath?: string;
|
||
/**
|
||
* Display-only write:runs flags from the caller's loader. Default true so
|
||
* callers that don't pass them (and OSS, where the ability is permissive)
|
||
* keep the controls enabled. The cancel/replay action routes enforce
|
||
* write:runs regardless.
|
||
*/
|
||
canCancelRuns?: boolean;
|
||
canReplayRuns?: boolean;
|
||
};
|
||
|
||
export function TaskRunsTable({
|
||
total,
|
||
hasFilters,
|
||
filters,
|
||
runs,
|
||
rootOnlyDefault,
|
||
disableAdjacentRows = false,
|
||
isLoading = false,
|
||
allowSelection = false,
|
||
variant = "dimmed",
|
||
additionalTableState,
|
||
showTopBorder = true,
|
||
stickyHeader = false,
|
||
childrenStatusesBasePath,
|
||
canCancelRuns = true,
|
||
canReplayRuns = true,
|
||
}: RunsTableProps) {
|
||
const regions = useRegions();
|
||
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||
const { isManagedCloud } = useFeatures();
|
||
const { value } = useSearchParams();
|
||
const location = useOptimisticLocation();
|
||
const params = new URLSearchParams(location.search || "");
|
||
if (!value("rootOnly")) {
|
||
params.set("rootOnly", String(rootOnlyDefault));
|
||
}
|
||
if (additionalTableState) {
|
||
for (const [key, val] of Object.entries(additionalTableState)) {
|
||
params.set(key, val);
|
||
}
|
||
}
|
||
const search = params.toString();
|
||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||
const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search);
|
||
|
||
const showCompute = isManagedCloud;
|
||
const showRegion = environment.type !== "DEVELOPMENT";
|
||
|
||
const navigateCheckboxes = useCallback(
|
||
(event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
||
//indexes are out by one because of the header row
|
||
if (event.key === "ArrowUp" && index > 0) {
|
||
checkboxes.current[index - 1]?.focus();
|
||
|
||
if (event.shiftKey) {
|
||
const oldItem = runs.at(index - 1);
|
||
const newItem = runs.at(index - 2);
|
||
const itemsIds = [oldItem?.friendlyId, newItem?.friendlyId].filter(Boolean);
|
||
select(itemsIds);
|
||
}
|
||
} else if (event.key === "ArrowDown" && index < checkboxes.current.length - 1) {
|
||
checkboxes.current[index + 1]?.focus();
|
||
|
||
if (event.shiftKey) {
|
||
const oldItem = runs.at(index - 1);
|
||
const newItem = runs.at(index);
|
||
const itemsIds = [oldItem?.friendlyId, newItem?.friendlyId].filter(Boolean);
|
||
select(itemsIds);
|
||
}
|
||
}
|
||
},
|
||
[checkboxes, runs]
|
||
);
|
||
|
||
return (
|
||
<Table
|
||
variant={variant}
|
||
className="max-h-full overflow-y-auto"
|
||
showTopBorder={showTopBorder}
|
||
stickyHeader={stickyHeader}
|
||
>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{allowSelection && (
|
||
<TableHeaderCell className="pl-3 pr-0">
|
||
{runs.length > 0 && (
|
||
<Checkbox
|
||
checked={hasAll(runs.map((r) => r.friendlyId))}
|
||
onChange={(element) => {
|
||
const ids = runs.map((r) => r.friendlyId);
|
||
const checked = element.currentTarget.checked;
|
||
if (checked) {
|
||
select(ids);
|
||
} else {
|
||
deselect(ids);
|
||
}
|
||
}}
|
||
ref={(r) => {
|
||
checkboxes.current[0] = r;
|
||
}}
|
||
onKeyDown={(event) => navigateCheckboxes(event, 0)}
|
||
/>
|
||
)}
|
||
</TableHeaderCell>
|
||
)}
|
||
<TableHeaderCell>ID</TableHeaderCell>
|
||
<TableHeaderCell>Task</TableHeaderCell>
|
||
<TableHeaderCell>Version</TableHeaderCell>
|
||
<TableHeaderCell
|
||
disableTooltipHoverableContent
|
||
tooltip={
|
||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||
{filterableTaskRunStatuses.map((status) => (
|
||
<div
|
||
key={status}
|
||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||
>
|
||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||
<TaskRunStatusCombo status={status} />
|
||
</div>
|
||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||
{descriptionForTaskRunStatus(status)}
|
||
</Paragraph>
|
||
</div>
|
||
))}
|
||
</div>
|
||
}
|
||
>
|
||
Status
|
||
</TableHeaderCell>
|
||
<TableHeaderCell>Started</TableHeaderCell>
|
||
<TableHeaderCell
|
||
colSpan={3}
|
||
disableTooltipHoverableContent
|
||
tooltip={
|
||
<div className="flex max-w-xs flex-col gap-4 p-1">
|
||
<div>
|
||
<div className="mb-0.5 flex items-center gap-1.5">
|
||
<RectangleStackIcon className="size-4 text-text-dimmed" />
|
||
<Header3>Queued duration</Header3>
|
||
</div>
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||
The amount of time from when the run was created to it starting to run.
|
||
</Paragraph>
|
||
</div>
|
||
<div>
|
||
<div className="mb-0.5 flex items-center gap-1.5">
|
||
<ClockIcon className="size-4 text-blue-500" /> <Header3>Run duration</Header3>
|
||
</div>
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||
The total amount of time from the run starting to it finishing. This includes
|
||
all time spent waiting.
|
||
</Paragraph>
|
||
</div>
|
||
<div>
|
||
<div className="mb-0.5 flex items-center gap-1.5">
|
||
<CpuChipIcon className="size-4 text-success" />
|
||
<Header3>Compute duration</Header3>
|
||
</div>
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||
The amount of compute time used in the run. This does not include time spent
|
||
waiting.
|
||
</Paragraph>
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
Duration
|
||
</TableHeaderCell>
|
||
{showCompute && (
|
||
<>
|
||
<TableHeaderCell>Compute</TableHeaderCell>
|
||
</>
|
||
)}
|
||
<TableHeaderCell className="pl-4" tooltip={<MachineTooltipInfo />}>
|
||
Machine
|
||
</TableHeaderCell>
|
||
<TableHeaderCell>Queue</TableHeaderCell>
|
||
{showRegion && <TableHeaderCell>Region</TableHeaderCell>}
|
||
<TableHeaderCell>Test</TableHeaderCell>
|
||
<TableHeaderCell>Created at</TableHeaderCell>
|
||
<TableHeaderCell
|
||
tooltip={
|
||
<div className="max-w-xs p-1">
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||
When you want to trigger a task now, but have it run at a later time, you can use
|
||
the delay option.
|
||
</Paragraph>
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||
Runs that are delayed and have not been enqueued yet will display in the dashboard
|
||
with a “Delayed” status.
|
||
</Paragraph>
|
||
<LinkButton
|
||
to={docsPath("v3/triggering")}
|
||
variant="docs/small"
|
||
LeadingIcon={BookOpenIcon}
|
||
className="mt-3"
|
||
>
|
||
Read docs
|
||
</LinkButton>
|
||
</div>
|
||
}
|
||
>
|
||
Delayed until
|
||
</TableHeaderCell>
|
||
<TableHeaderCell
|
||
tooltip={
|
||
<div className="max-w-xs p-1">
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||
You can set a TTL (time to live) when triggering a task, which will automatically
|
||
expire the run if it hasn’t started within the specified time.
|
||
</Paragraph>
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||
All runs in development have a default ttl of 10 minutes. You can disable this by
|
||
setting the ttl option.
|
||
</Paragraph>
|
||
<LinkButton
|
||
to={docsPath("v3/triggering")}
|
||
variant="docs/small"
|
||
LeadingIcon={BookOpenIcon}
|
||
className="mt-3"
|
||
>
|
||
Read docs
|
||
</LinkButton>
|
||
</div>
|
||
}
|
||
>
|
||
TTL
|
||
</TableHeaderCell>
|
||
<TableHeaderCell
|
||
tooltip={
|
||
<div className="max-w-xs p-1">
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||
You can add tags to a run and then filter runs using them.
|
||
</Paragraph>
|
||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||
You can add tags when triggering a run or inside the run function.
|
||
</Paragraph>
|
||
<LinkButton
|
||
to={docsPath("v3/tags")}
|
||
variant="docs/small"
|
||
LeadingIcon={BookOpenIcon}
|
||
className="mt-3"
|
||
>
|
||
Read docs
|
||
</LinkButton>
|
||
</div>
|
||
}
|
||
>
|
||
Tags
|
||
</TableHeaderCell>
|
||
<TableHeaderCell>
|
||
<span className="sr-only">Go to page</span>
|
||
</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{total === 0 && !hasFilters ? (
|
||
<TableBlankRow colSpan={showRegion ? 16 : 15}>
|
||
{!isLoading && <NoRuns title="No runs found" />}
|
||
</TableBlankRow>
|
||
) : runs.length === 0 ? (
|
||
<BlankState isLoading={isLoading} filters={filters} showRegion={showRegion} />
|
||
) : (
|
||
runs.map((run, index) => {
|
||
const searchParams = new URLSearchParams();
|
||
if (tableStateParam) {
|
||
searchParams.set("tableState", tableStateParam);
|
||
}
|
||
const path = v3RunSpanPath(
|
||
organization,
|
||
project,
|
||
run.environment,
|
||
run,
|
||
{
|
||
spanId: run.spanId,
|
||
},
|
||
searchParams
|
||
);
|
||
return (
|
||
<TableRow key={run.id}>
|
||
{allowSelection && (
|
||
<TableCell className="pl-3 pr-0">
|
||
<Checkbox
|
||
checked={has(run.friendlyId)}
|
||
onChange={() => {
|
||
toggle(run.friendlyId);
|
||
}}
|
||
ref={(r) => {
|
||
checkboxes.current[index + 1] = r;
|
||
}}
|
||
onKeyDown={(event) => navigateCheckboxes(event, index + 1)}
|
||
/>
|
||
</TableCell>
|
||
)}
|
||
<TableCell to={path} isTabbableCell>
|
||
<TruncatedCopyableValue value={run.friendlyId} />
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
<span className="flex items-center gap-x-1">
|
||
<TaskTriggerSourceIcon
|
||
source={run.taskKind as TaskTriggerSource}
|
||
className="size-3.5 flex-none"
|
||
/>
|
||
{run.taskIdentifier}
|
||
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell to={path}>{run.version ?? "–"}</TableCell>
|
||
<TableCell to={path}>
|
||
{run.rootTaskRunId === null && childrenStatusesBasePath ? (
|
||
<RunStatusCellTooltip
|
||
friendlyId={run.friendlyId}
|
||
status={run.status}
|
||
hasFinished={run.hasFinished}
|
||
childrenStatusesBasePath={childrenStatusesBasePath}
|
||
/>
|
||
) : (
|
||
<SimpleTooltip
|
||
content={descriptionForTaskRunStatus(run.status)}
|
||
disableHoverableContent
|
||
button={<TaskRunStatusCombo status={run.status} />}
|
||
/>
|
||
)}
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}
|
||
</TableCell>
|
||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||
<div className="flex items-center gap-1">
|
||
<RectangleStackIcon className="size-4 text-text-dimmed" />
|
||
{run.isPending ? (
|
||
"–"
|
||
) : run.startedAt ? (
|
||
formatDuration(new Date(run.createdAt), new Date(run.startedAt), {
|
||
style: "short",
|
||
})
|
||
) : run.isCancellable ? (
|
||
<LiveTimer startTime={new Date(run.createdAt)} />
|
||
) : (
|
||
formatDuration(new Date(run.createdAt), new Date(run.updatedAt), {
|
||
style: "short",
|
||
})
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell to={path} className="w-[1%]" actionClassName="px-4 tabular-nums">
|
||
<div className="flex items-center gap-1">
|
||
<ClockIcon className="size-4 text-blue-500" />
|
||
{run.startedAt && run.finishedAt ? (
|
||
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
|
||
style: "short",
|
||
})
|
||
) : run.startedAt ? (
|
||
<LiveTimer startTime={new Date(run.startedAt)} />
|
||
) : (
|
||
"–"
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell to={path} actionClassName="pl-0 tabular-nums">
|
||
<div className="flex items-center gap-1">
|
||
<CpuChipIcon className="size-4 text-success" />
|
||
{run.usageDurationMs > 0
|
||
? formatDurationMilliseconds(run.usageDurationMs, {
|
||
style: "short",
|
||
})
|
||
: "–"}
|
||
</div>
|
||
</TableCell>
|
||
{showCompute && (
|
||
<TableCell to={path} className="tabular-nums">
|
||
{run.costInCents > 0
|
||
? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100)
|
||
: "–"}
|
||
</TableCell>
|
||
)}
|
||
<TableCell to={path}>
|
||
<MachineLabelCombo preset={run.machinePreset} />
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
{run.queue.type === "task" ? (
|
||
<SimpleTooltip
|
||
buttonClassName="w-fit"
|
||
button={
|
||
<span className="flex items-center gap-1">
|
||
<TasksIcon className="size-[1.125rem] text-blue-500" />
|
||
<span>{run.queue.name}</span>
|
||
</span>
|
||
}
|
||
content={`This queue was automatically created from your "${run.queue.name}" task`}
|
||
disableHoverableContent
|
||
/>
|
||
) : (
|
||
<SimpleTooltip
|
||
buttonClassName="w-fit"
|
||
button={
|
||
<span className="flex items-center gap-1">
|
||
<RectangleStackIcon className="size-[1.125rem] text-purple-500" />
|
||
<span>{run.queue.name}</span>
|
||
</span>
|
||
}
|
||
content={`This is a custom queue you added in your code.`}
|
||
disableHoverableContent
|
||
/>
|
||
)}
|
||
</TableCell>
|
||
{showRegion && (
|
||
<TableCell to={path}>
|
||
{run.region ? (
|
||
<RegionLabel
|
||
region={regionByMasterQueue.get(run.region) ?? { name: run.region }}
|
||
iconClassName="size-4"
|
||
/>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
)}
|
||
<TableCell to={path}>
|
||
{run.isTest ? (
|
||
<CheckIcon className="size-4 text-text-dimmed group-hover/table-row:text-text-bright" />
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
{run.delayUntil ? <DateTime date={run.delayUntil} /> : "–"}
|
||
</TableCell>
|
||
<TableCell to={path}>{run.ttl ?? "–"}</TableCell>
|
||
<TableCell to={path} actionClassName="py-1" className="pr-16">
|
||
<div className="flex gap-1">
|
||
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
|
||
</div>
|
||
</TableCell>
|
||
<RunActionsCell
|
||
run={run}
|
||
path={path}
|
||
canCancelRuns={canCancelRuns}
|
||
canReplayRuns={canReplayRuns}
|
||
/>
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
{isLoading && (
|
||
<TableBlankRow
|
||
colSpan={showRegion ? 16 : 15}
|
||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed"
|
||
>
|
||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||
</TableBlankRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
function RunActionsCell({
|
||
run,
|
||
path,
|
||
canCancelRuns,
|
||
canReplayRuns,
|
||
}: {
|
||
run: NextRunListItem;
|
||
path: string;
|
||
canCancelRuns: boolean;
|
||
canReplayRuns: boolean;
|
||
}) {
|
||
const location = useLocation();
|
||
|
||
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
|
||
|
||
return (
|
||
<TableCellMenu
|
||
isSticky
|
||
popoverContent={
|
||
<>
|
||
<PopoverMenuItem
|
||
to={path}
|
||
icon={ArrowRightIcon}
|
||
leadingIconClassName="text-blue-500"
|
||
title="View run"
|
||
/>
|
||
{run.isCancellable &&
|
||
(canCancelRuns ? (
|
||
<Dialog>
|
||
<DialogTrigger
|
||
asChild
|
||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-background-raised hover:text-text-bright"
|
||
>
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={NoSymbolIcon}
|
||
leadingIconClassName="text-error"
|
||
fullWidth
|
||
textAlignLeft
|
||
className="w-full px-1.5 py-[0.9rem]"
|
||
>
|
||
Cancel run
|
||
</Button>
|
||
</DialogTrigger>
|
||
<CancelRunDialog
|
||
runFriendlyId={run.friendlyId}
|
||
redirectPath={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
) : (
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={NoSymbolIcon}
|
||
leadingIconClassName="text-error"
|
||
fullWidth
|
||
textAlignLeft
|
||
className="w-full px-1.5 py-[0.9rem]"
|
||
disabled
|
||
tooltip="You don't have permission to cancel runs"
|
||
>
|
||
Cancel run
|
||
</Button>
|
||
))}
|
||
{run.isReplayable &&
|
||
(canReplayRuns ? (
|
||
<Dialog>
|
||
<DialogTrigger
|
||
asChild
|
||
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-background-raised hover:text-text-bright"
|
||
>
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={ArrowPathIcon}
|
||
leadingIconClassName="text-success"
|
||
fullWidth
|
||
textAlignLeft
|
||
className="w-full px-1.5 py-[0.9rem]"
|
||
>
|
||
Replay run…
|
||
</Button>
|
||
</DialogTrigger>
|
||
<ReplayRunDialog
|
||
runFriendlyId={run.friendlyId}
|
||
failedRedirect={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
) : (
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={ArrowPathIcon}
|
||
leadingIconClassName="text-success"
|
||
fullWidth
|
||
textAlignLeft
|
||
className="w-full px-1.5 py-[0.9rem]"
|
||
disabled
|
||
tooltip="You don't have permission to replay runs"
|
||
>
|
||
Replay run…
|
||
</Button>
|
||
))}
|
||
</>
|
||
}
|
||
hiddenButtons={
|
||
<>
|
||
{run.isCancellable && canCancelRuns && (
|
||
<SimpleTooltip
|
||
button={
|
||
<Dialog>
|
||
<DialogTrigger
|
||
asChild
|
||
className="size-6 rounded-sm p-1 text-text-bright transition hover:bg-background-raised"
|
||
>
|
||
<NoSymbolIcon className="size-3" />
|
||
</DialogTrigger>
|
||
<CancelRunDialog
|
||
runFriendlyId={run.friendlyId}
|
||
redirectPath={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
}
|
||
content="Cancel run"
|
||
side="left"
|
||
disableHoverableContent
|
||
/>
|
||
)}
|
||
{run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
|
||
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
|
||
)}
|
||
{run.isReplayable && canReplayRuns && (
|
||
<SimpleTooltip
|
||
button={
|
||
<Dialog>
|
||
<DialogTrigger
|
||
asChild
|
||
className="h-6 w-6 rounded-sm p-1 text-text-bright transition hover:bg-background-raised"
|
||
>
|
||
<ArrowPathIcon className="size-3" />
|
||
</DialogTrigger>
|
||
<ReplayRunDialog
|
||
runFriendlyId={run.friendlyId}
|
||
failedRedirect={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
}
|
||
content="Replay run…"
|
||
side="left"
|
||
disableHoverableContent
|
||
/>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NoRuns({ title }: { title: string }) {
|
||
return (
|
||
<div className="flex items-center justify-center">
|
||
<Paragraph className="w-auto">{title}</Paragraph>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BlankState({
|
||
isLoading,
|
||
filters,
|
||
showRegion,
|
||
}: Pick<RunsTableProps, "isLoading" | "filters"> & { showRegion: boolean }) {
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
const colSpan = showRegion ? 16 : 15;
|
||
if (isLoading) return <TableBlankRow colSpan={colSpan}></TableBlankRow>;
|
||
|
||
const { tasks, from, to, ...otherFilters } = filters;
|
||
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
|
||
const testPath = singleTaskFromFilters
|
||
? v3TestTaskPath(organization, project, environment, { taskIdentifier: singleTaskFromFilters })
|
||
: v3TestPath(organization, project, environment);
|
||
|
||
if (
|
||
filters.tasks.length === 1 &&
|
||
filters.from === undefined &&
|
||
filters.to === undefined &&
|
||
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
|
||
) {
|
||
return (
|
||
<TableBlankRow colSpan={colSpan}>
|
||
<Paragraph className="w-auto" variant="base/bright" spacing>
|
||
There are no runs for {filters.tasks[0]}
|
||
</Paragraph>
|
||
</TableBlankRow>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<TableBlankRow colSpan={colSpan}>
|
||
<div className="flex flex-col items-center justify-center gap-6">
|
||
<Paragraph className="w-auto" variant="base/bright">
|
||
No runs match your filters. Try refreshing, modifying your filters or run a test.
|
||
</Paragraph>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
LeadingIcon={ArrowPathIcon}
|
||
variant="secondary/medium"
|
||
onClick={() => {
|
||
window.location.reload();
|
||
}}
|
||
>
|
||
Refresh
|
||
</Button>
|
||
<Paragraph>or</Paragraph>
|
||
<LinkButton
|
||
LeadingIcon={BeakerIcon}
|
||
leadingIconClassName="text-tests"
|
||
variant="secondary/medium"
|
||
to={testPath}
|
||
>
|
||
Run a test
|
||
</LinkButton>
|
||
</div>
|
||
</div>
|
||
</TableBlankRow>
|
||
);
|
||
}
|