Skip to content

Commit 5526465

Browse files
authored
Run tags (#1232)
* TaskRunTag migration. Removed unused TaskTag * Show tags for a run in the run list * API endpoint for searching tags * Let’s not expose an API endpoint for tags at the moment * Filter by tags working from the URL * Tag async filter working * We don’t need a “None” option because it’s multi-select * When triggering a run you can add tags * SDK runs.retrieve and runs.list with tag support * Tidied imports * Changed the run list query so we show all the tags even if a run only matches one of them * Fix for dealing with weird characters in tags * Run tags changeset * Creating a project has a proper loading state (and blocks multiple) * Improved the error message for tag length * Added a tags icon for display on the run screen * Convenient functions for creating and getting run tags * tags.set() from inside the run function * Replay a run passes tags through * Less ridiculous tags for the catalog example * Allow passing just a string for the tags * Order by id because there’s an index on the primary key already * Trim the tags earlier so we don’t accidentally error if a blank string is passed * Renamed some thing from setTags to addTags * Use findFirst for the tags project lookup * Added an index for "TaskRunTag"("name", "id") It massively improves the performance of run filtering based on tags * Use `array_agg` for the run list tags so pagination works and we get a single result for each run * Tidied imports * More comprehensive test of tags with all triggering functions * Support tags with tasks.trigger, tasks.batchTrigger and tasks poll variants * Added tasks.batchTrigger tags * Sort the tags in the UI so they’re always in the same order * Added tooltips in the run table for delay, ttl and tags * Added costInCents, baseCostInCents and durationMs to runs.retrieve and runs.list * Added support for displaying a split tag if you use key_value or key:value format * Fix code comment * Tweaked the JSDoc to make the prefixing clearer * Added tasks.triggerAndWait to the tags test task
1 parent be3157a commit 5526465

File tree

30 files changed

+1057
-125
lines changed

30 files changed

+1057
-125
lines changed

.changeset/odd-beds-wonder.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
You can now add tags to runs and list runs using them
Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,37 @@
11
import { cn } from "~/utils/cn";
22

3+
type CustomColor = {
4+
background: string;
5+
foreground: string;
6+
};
7+
38
export function Spinner({
49
className,
510
color = "blue",
611
}: {
712
className?: string;
8-
color?: "blue" | "white" | "muted" | "dark";
13+
color?: "blue" | "white" | "muted" | "dark" | CustomColor;
914
}) {
1015
const colors = {
1116
blue: {
12-
light: "rgba(59, 130, 246, 0.4)",
13-
dark: "rgba(59, 130, 246)",
17+
background: "rgba(59, 130, 246, 0.4)",
18+
foreground: "rgba(59, 130, 246)",
1419
},
1520
white: {
16-
light: "rgba(255, 255, 255, 0.4)",
17-
dark: "rgba(255, 255, 255)",
21+
background: "rgba(255, 255, 255, 0.4)",
22+
foreground: "rgba(255, 255, 255)",
1823
},
1924
muted: {
20-
light: "#1C2433",
21-
dark: "#3C4B62",
25+
background: "#1C2433",
26+
foreground: "#3C4B62",
2227
},
2328
dark: {
24-
light: "#15171A",
25-
dark: "#272A2E",
29+
background: "#15171A",
30+
foreground: "#272A2E",
2631
},
2732
};
2833

29-
const currentColor = colors[color];
34+
const currentColor = typeof color === "string" ? colors[color] : color;
3035

3136
return (
3237
<svg
@@ -37,13 +42,33 @@ export function Spinner({
3742
xmlns="http://www.w3.org/2000/svg"
3843
className={cn("animate-spin motion-reduce:hidden", className)}
3944
>
40-
<rect x="2" y="2" width="16" height="16" rx="8" stroke={currentColor.light} strokeWidth="3" />
45+
<rect
46+
x="2"
47+
y="2"
48+
width="16"
49+
height="16"
50+
rx="8"
51+
stroke={currentColor.background}
52+
strokeWidth="3"
53+
/>
4154
<path
4255
d="M10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2"
43-
stroke={currentColor.dark}
56+
stroke={currentColor.foreground}
4457
strokeWidth="3"
4558
strokeLinecap="round"
4659
/>
4760
</svg>
4861
);
4962
}
63+
64+
export function ButtonSpinner() {
65+
return (
66+
<Spinner
67+
className="size-3"
68+
color={{
69+
foreground: "rgba(0, 0, 0, 1)",
70+
background: "rgba(0, 0, 0, 0.25)",
71+
}}
72+
/>
73+
);
74+
}

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import {
44
CalendarIcon,
55
CpuChipIcon,
66
InboxStackIcon,
7+
TagIcon,
78
XMarkIcon,
89
} from "@heroicons/react/20/solid";
9-
import { Form } from "@remix-run/react";
10+
import { Form, useFetcher } from "@remix-run/react";
1011
import type {
1112
RuntimeEnvironment,
1213
TaskTriggerSource,
@@ -15,7 +16,7 @@ import type {
1516
} from "@trigger.dev/database";
1617
import { ListFilterIcon } from "lucide-react";
1718
import type { ReactNode } from "react";
18-
import { startTransition, useCallback, useMemo, useState } from "react";
19+
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
1920
import { z } from "zod";
2021
import { TaskIcon } from "~/assets/icons/TaskIcon";
2122
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
@@ -51,6 +52,10 @@ import {
5152
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
5253
import { DateTime } from "~/components/primitives/DateTime";
5354
import { BulkActionStatusCombo } from "./BulkAction";
55+
import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags";
56+
import { useProject } from "~/hooks/useProject";
57+
import { Spinner } from "~/components/primitives/Spinner";
58+
import { matchSorter } from "match-sorter";
5459

5560
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
5661

@@ -73,6 +78,10 @@ export const TaskRunListSearchFilters = z.object({
7378
(value) => (typeof value === "string" ? [value] : value),
7479
TaskAttemptStatus.array().optional()
7580
),
81+
tags: z.preprocess(
82+
(value) => (typeof value === "string" ? [value] : value),
83+
z.string().array().optional()
84+
),
7685
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
7786
bulkId: z.string().optional(),
7887
from: z.coerce.number().optional(),
@@ -104,7 +113,8 @@ export function RunsFilters(props: RunFiltersProps) {
104113
searchParams.has("environments") ||
105114
searchParams.has("tasks") ||
106115
searchParams.has("period") ||
107-
searchParams.has("bulkId");
116+
searchParams.has("bulkId") ||
117+
searchParams.has("tags");
108118

109119
return (
110120
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -133,6 +143,7 @@ const filterTypes = [
133143
},
134144
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
135145
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
146+
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
136147
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
137148
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
138149
] as const;
@@ -209,6 +220,7 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
209220
<AppliedStatusFilter />
210221
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
211222
<AppliedTaskFilter possibleTasks={possibleTasks} />
223+
<AppliedTagsFilter />
212224
<AppliedPeriodFilter />
213225
<AppliedBulkActionsFilter bulkActions={bulkActions} />
214226
</>
@@ -237,6 +249,8 @@ function Menu(props: MenuProps) {
237249
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
238250
case "bulk":
239251
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
252+
case "tags":
253+
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
240254
}
241255
}
242256

@@ -648,6 +662,124 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkAc
648662
);
649663
}
650664

665+
function TagsDropdown({
666+
trigger,
667+
clearSearchValue,
668+
searchValue,
669+
onClose,
670+
}: {
671+
trigger: ReactNode;
672+
clearSearchValue: () => void;
673+
searchValue: string;
674+
onClose?: () => void;
675+
}) {
676+
const project = useProject();
677+
const { values, replace } = useSearchParams();
678+
679+
const handleChange = (values: string[]) => {
680+
clearSearchValue();
681+
replace({
682+
tags: values,
683+
cursor: undefined,
684+
direction: undefined,
685+
});
686+
};
687+
688+
const fetcher = useFetcher<typeof loader>();
689+
690+
useEffect(() => {
691+
const searchParams = new URLSearchParams();
692+
if (searchValue) {
693+
searchParams.set("name", encodeURIComponent(searchValue));
694+
}
695+
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
696+
}, [searchValue]);
697+
698+
const filtered = useMemo(() => {
699+
let items: string[] = [];
700+
if (searchValue === "") {
701+
items = values("tags");
702+
}
703+
704+
if (fetcher.data === undefined) {
705+
return matchSorter(items, searchValue);
706+
}
707+
708+
items.push(...fetcher.data.tags.map((t) => t.name));
709+
710+
return matchSorter(Array.from(new Set(items)), searchValue);
711+
}, [searchValue, fetcher.data]);
712+
713+
return (
714+
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
715+
{trigger}
716+
<SelectPopover
717+
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
718+
hideOnEscape={() => {
719+
if (onClose) {
720+
onClose();
721+
return false;
722+
}
723+
724+
return true;
725+
}}
726+
>
727+
<ComboBox
728+
value={searchValue}
729+
render={(props) => (
730+
<div className="flex items-center justify-stretch">
731+
<input {...props} placeholder={"Filter by tags..."} />
732+
{fetcher.state === "loading" && <Spinner color="muted" />}
733+
</div>
734+
)}
735+
/>
736+
<SelectList>
737+
{filtered.length > 0
738+
? filtered.map((tag, index) => (
739+
<SelectItem key={tag} value={tag}>
740+
{tag}
741+
</SelectItem>
742+
))
743+
: null}
744+
{filtered.length === 0 && fetcher.state !== "loading" && (
745+
<SelectItem disabled>No tags found</SelectItem>
746+
)}
747+
</SelectList>
748+
</SelectPopover>
749+
</SelectProvider>
750+
);
751+
}
752+
753+
function AppliedTagsFilter() {
754+
const { values, del } = useSearchParams();
755+
756+
const tags = values("tags");
757+
758+
if (tags.length === 0) {
759+
return null;
760+
}
761+
762+
return (
763+
<FilterMenuProvider>
764+
{(search, setSearch) => (
765+
<TagsDropdown
766+
trigger={
767+
<Ariakit.Select render={<div className="group cursor-pointer" />}>
768+
<AppliedFilter
769+
label="Tags"
770+
value={appliedSummary(values("tags"))}
771+
onRemove={() => del(["tags", "cursor", "direction"])}
772+
/>
773+
</Ariakit.Select>
774+
}
775+
searchValue={search}
776+
clearSearchValue={() => setSearch("")}
777+
/>
778+
)}
779+
</FilterMenuProvider>
780+
);
781+
}
782+
651783
const timePeriods = [
652784
{
653785
label: "All periods",

apps/webapp/app/components/runs/v3/RunIcon.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
HandRaisedIcon,
44
InformationCircleIcon,
55
Squares2X2Icon,
6+
TagIcon,
67
} from "@heroicons/react/20/solid";
78
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
89
import { TaskIcon } from "~/assets/icons/TaskIcon";
@@ -48,6 +49,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
4849
return <ClockIcon className={cn(className, "text-teal-500")} />;
4950
case "trace":
5051
return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
52+
case "tag":
53+
return <TagIcon className={cn(className, "text-text-dimmed")} />;
5154
//log levels
5255
case "debug":
5356
case "log":
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useMemo } from "react";
2+
import tagLeftPath from "./tag-left.svg";
3+
4+
type Tag = string | { key: string; value: string };
5+
6+
export function RunTag({ tag }: { tag: string }) {
7+
const tagResult = useMemo(() => splitTag(tag), [tag]);
8+
9+
if (typeof tagResult === "string") {
10+
return (
11+
<span className="flex h-6 items-stretch">
12+
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
13+
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
14+
{tag}
15+
</span>
16+
</span>
17+
);
18+
} else {
19+
return (
20+
<span className="flex h-6 items-stretch">
21+
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
22+
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
23+
{tagResult.key}
24+
</span>
25+
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
26+
{tagResult.value}
27+
</span>
28+
</span>
29+
);
30+
}
31+
}
32+
33+
/** Takes a string and turns it into a tag
34+
*
35+
* If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value
36+
* Otherwise we return the original string
37+
*/
38+
function splitTag(tag: string): Tag {
39+
if (tag.match(/^[a-zA-Z]{1,12}[_:]/)) {
40+
const components = tag.split(/[_:]/);
41+
if (components.length !== 2) {
42+
return tag;
43+
}
44+
return { key: components[0], value: components[1] };
45+
}
46+
47+
return tag;
48+
}

0 commit comments

Comments
 (0)