Skip to content

Run tags #1232

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e34c179
TaskRunTag migration. Removed unused TaskTag
matt-aitken Jul 20, 2024
bc20943
Show tags for a run in the run list
matt-aitken Jul 20, 2024
9a19c95
API endpoint for searching tags
matt-aitken Jul 20, 2024
f001244
Let’s not expose an API endpoint for tags at the moment
matt-aitken Jul 20, 2024
0e68aa9
Filter by tags working from the URL
matt-aitken Jul 20, 2024
39a86a9
Tag async filter working
matt-aitken Jul 20, 2024
81b0bb1
We don’t need a “None” option because it’s multi-select
matt-aitken Jul 20, 2024
1a934bc
When triggering a run you can add tags
matt-aitken Jul 20, 2024
2b45091
SDK runs.retrieve and runs.list with tag support
matt-aitken Jul 20, 2024
40e5556
Tidied imports
matt-aitken Jul 20, 2024
ecd6376
Changed the run list query so we show all the tags even if a run only…
matt-aitken Jul 20, 2024
72ed496
Fix for dealing with weird characters in tags
matt-aitken Jul 20, 2024
da6fc29
Run tags changeset
matt-aitken Jul 20, 2024
d6e8862
Creating a project has a proper loading state (and blocks multiple)
matt-aitken Jul 22, 2024
f9582a6
Improved the error message for tag length
matt-aitken Jul 22, 2024
ce255ce
Added a tags icon for display on the run screen
matt-aitken Jul 22, 2024
fbca11a
Convenient functions for creating and getting run tags
matt-aitken Jul 22, 2024
701e444
tags.set() from inside the run function
matt-aitken Jul 22, 2024
7bddaa0
Replay a run passes tags through
matt-aitken Jul 22, 2024
266cb5b
Less ridiculous tags for the catalog example
matt-aitken Jul 22, 2024
b56c2fc
Allow passing just a string for the tags
matt-aitken Jul 22, 2024
89459e6
Order by id because there’s an index on the primary key already
matt-aitken Jul 22, 2024
7bf0465
Trim the tags earlier so we don’t accidentally error if a blank strin…
matt-aitken Jul 22, 2024
50d6d96
Renamed some thing from setTags to addTags
matt-aitken Jul 22, 2024
831d28a
Use findFirst for the tags project lookup
matt-aitken Jul 23, 2024
4b4554a
Added an index for "TaskRunTag"("name", "id")
matt-aitken Jul 23, 2024
d1a2762
Use `array_agg` for the run list tags so pagination works and we get …
matt-aitken Jul 23, 2024
144db6c
Tidied imports
matt-aitken Jul 23, 2024
4c6ad3b
More comprehensive test of tags with all triggering functions
matt-aitken Jul 23, 2024
0137a97
Support tags with tasks.trigger, tasks.batchTrigger and tasks poll va…
matt-aitken Jul 23, 2024
3981c5d
Added tasks.batchTrigger tags
matt-aitken Jul 23, 2024
f9021de
Sort the tags in the UI so they’re always in the same order
matt-aitken Jul 23, 2024
b5c1ec0
Added tooltips in the run table for delay, ttl and tags
matt-aitken Jul 23, 2024
4f2eac6
Added costInCents, baseCostInCents and durationMs to runs.retrieve an…
matt-aitken Jul 23, 2024
9bb1bb8
Added support for displaying a split tag if you use key_value or key:…
matt-aitken Jul 23, 2024
0197d7e
Fix code comment
matt-aitken Jul 23, 2024
37d6e8a
Tweaked the JSDoc to make the prefixing clearer
matt-aitken Jul 23, 2024
033c61a
Added tasks.triggerAndWait to the tags test task
matt-aitken Jul 23, 2024
0dc7ff2
Merge branch 'main' into features/tags
matt-aitken Jul 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/odd-beds-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---

You can now add tags to runs and list runs using them
49 changes: 37 additions & 12 deletions apps/webapp/app/components/primitives/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,37 @@
import { cn } from "~/utils/cn";

type CustomColor = {
background: string;
foreground: string;
};

export function Spinner({
className,
color = "blue",
}: {
className?: string;
color?: "blue" | "white" | "muted" | "dark";
color?: "blue" | "white" | "muted" | "dark" | CustomColor;
}) {
const colors = {
blue: {
light: "rgba(59, 130, 246, 0.4)",
dark: "rgba(59, 130, 246)",
background: "rgba(59, 130, 246, 0.4)",
foreground: "rgba(59, 130, 246)",
},
white: {
light: "rgba(255, 255, 255, 0.4)",
dark: "rgba(255, 255, 255)",
background: "rgba(255, 255, 255, 0.4)",
foreground: "rgba(255, 255, 255)",
},
muted: {
light: "#1C2433",
dark: "#3C4B62",
background: "#1C2433",
foreground: "#3C4B62",
},
dark: {
light: "#15171A",
dark: "#272A2E",
background: "#15171A",
foreground: "#272A2E",
},
};

const currentColor = colors[color];
const currentColor = typeof color === "string" ? colors[color] : color;

return (
<svg
Expand All @@ -37,13 +42,33 @@ export function Spinner({
xmlns="http://www.w3.org/2000/svg"
className={cn("animate-spin motion-reduce:hidden", className)}
>
<rect x="2" y="2" width="16" height="16" rx="8" stroke={currentColor.light} strokeWidth="3" />
<rect
x="2"
y="2"
width="16"
height="16"
rx="8"
stroke={currentColor.background}
strokeWidth="3"
/>
<path
d="M10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2"
stroke={currentColor.dark}
stroke={currentColor.foreground}
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}

export function ButtonSpinner() {
return (
<Spinner
className="size-3"
color={{
foreground: "rgba(0, 0, 0, 1)",
background: "rgba(0, 0, 0, 0.25)",
}}
/>
);
}
138 changes: 135 additions & 3 deletions apps/webapp/app/components/runs/v3/RunFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {
CalendarIcon,
CpuChipIcon,
InboxStackIcon,
TagIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { Form, useFetcher } from "@remix-run/react";
import type {
RuntimeEnvironment,
TaskTriggerSource,
Expand All @@ -15,7 +16,7 @@ import type {
} from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import type { ReactNode } from "react";
import { startTransition, useCallback, useMemo, useState } from "react";
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
Expand Down Expand Up @@ -51,6 +52,10 @@ import {
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { DateTime } from "~/components/primitives/DateTime";
import { BulkActionStatusCombo } from "./BulkAction";
import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { useProject } from "~/hooks/useProject";
import { Spinner } from "~/components/primitives/Spinner";
import { matchSorter } from "match-sorter";

export const TaskAttemptStatus = z.enum(allTaskRunStatuses);

Expand All @@ -73,6 +78,10 @@ export const TaskRunListSearchFilters = z.object({
(value) => (typeof value === "string" ? [value] : value),
TaskAttemptStatus.array().optional()
),
tags: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
bulkId: z.string().optional(),
from: z.coerce.number().optional(),
Expand Down Expand Up @@ -104,7 +113,8 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("environments") ||
searchParams.has("tasks") ||
searchParams.has("period") ||
searchParams.has("bulkId");
searchParams.has("bulkId") ||
searchParams.has("tags");

return (
<div className="flex flex-row flex-wrap items-center gap-1">
Expand Down Expand Up @@ -133,6 +143,7 @@ const filterTypes = [
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
] as const;
Expand Down Expand Up @@ -209,6 +220,7 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedPeriodFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
</>
Expand Down Expand Up @@ -237,6 +249,8 @@ function Menu(props: MenuProps) {
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "bulk":
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}

Expand Down Expand Up @@ -648,6 +662,124 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkAc
);
}

function TagsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const project = useProject();
const { values, replace } = useSearchParams();

const handleChange = (values: string[]) => {
clearSearchValue();
replace({
tags: values,
cursor: undefined,
direction: undefined,
});
};

const fetcher = useFetcher<typeof loader>();

useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
}
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
}, [searchValue]);

const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = values("tags");
}

if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}

items.push(...fetcher.data.tags.map((t) => t.name));

return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);

return (
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}

return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by tags..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((tag, index) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No tags found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}

function AppliedTagsFilter() {
const { values, del } = useSearchParams();

const tags = values("tags");

if (tags.length === 0) {
return null;
}

return (
<FilterMenuProvider>
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Tags"
value={appliedSummary(values("tags"))}
onRemove={() => del(["tags", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}

const timePeriods = [
{
label: "All periods",
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/components/runs/v3/RunIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
HandRaisedIcon,
InformationCircleIcon,
Squares2X2Icon,
TagIcon,
} from "@heroicons/react/20/solid";
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
Expand Down Expand Up @@ -48,6 +49,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <ClockIcon className={cn(className, "text-teal-500")} />;
case "trace":
return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
case "tag":
return <TagIcon className={cn(className, "text-text-dimmed")} />;
//log levels
case "debug":
case "log":
Expand Down
48 changes: 48 additions & 0 deletions apps/webapp/app/components/runs/v3/RunTag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import tagLeftPath from "./tag-left.svg";

type Tag = string | { key: string; value: string };

export function RunTag({ tag }: { tag: string }) {
const tagResult = useMemo(() => splitTag(tag), [tag]);

if (typeof tagResult === "string") {
return (
<span className="flex h-6 items-stretch">
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tag}
</span>
</span>
);
} else {
return (
<span className="flex h-6 items-stretch">
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tagResult.key}
</span>
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
{tagResult.value}
</span>
</span>
);
}
}

/** Takes a string and turns it into a tag
*
* 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
* Otherwise we return the original string
*/
function splitTag(tag: string): Tag {
if (tag.match(/^[a-zA-Z]{1,12}[_:]/)) {
const components = tag.split(/[_:]/);
if (components.length !== 2) {
return tag;
}
return { key: components[0], value: components[1] };
}

return tag;
}
Loading
Loading