Feat(webapp) filters UX update (#3451)

## Lots of filter UX improvements across lots of routes

### General
- Promoted important filters out of the "More filters" so they're always
visible
- SearchInput primitive is now reusable and Esc now clears the field (AI
filter input also clears with Esc)
- Tooltips + keyboard shortcuts on every primary filter button
- Brighter text on selected filter items / queue items 
- Filter dropdowns reordered for better hierarchy
- Removed debounce on Tasks page search for faster filtering

### Tasks page search
- Esc now clears the field
- ENTER submits a search to improve performance when you have lots of
tasks


https://github.com/user-attachments/assets/4b30521e-dbc4-4468-b2af-8c85bdfb9002

### Runs filters
- Moves Status and Tasks out of the More filters menu
- "Root only" toggle is set to false when you filter for a Task. This
state isn't stored and flips back to the stored value if filters are
cleared
<img width="1690" height="986" alt="CleanShot 2026-04-26 at 19 24 08@2x"
src="https://github.com/user-attachments/assets/b07da73c-140e-451f-a7bf-c32129317f63"
/>

### Batches filters
- General consistency improvements
<img width="1429" height="948" alt="CleanShot 2026-05-08 at 09 50 35"
src="https://github.com/user-attachments/assets/e5ec267f-2aa3-43ef-991e-93bf01bdaea5"
/>

### Schedules
- General consistency improvements
<img width="1567" height="1141" alt="CleanShot 2026-05-08 at 09 51 11"
src="https://github.com/user-attachments/assets/34b7da88-87c6-4e4d-a70f-fe13ea9f87ec"
/>

### Queues
- General consistency improvements
<img width="824" height="416" alt="CleanShot 2026-05-08 at 09 52 02"
src="https://github.com/user-attachments/assets/b4adc102-8192-4a68-b199-a175c2645a6c"
/>

### Waitpoint tokens
- General consistency improvements
<img width="941" height="363" alt="CleanShot 2026-05-08 at 09 52 19"
src="https://github.com/user-attachments/assets/d43aeb3f-7f80-454d-b183-fd077a4e3ff7"
/>

### Models
- General consistency improvements
<img width="1570" height="509" alt="CleanShot 2026-05-08 at 09 53 17"
src="https://github.com/user-attachments/assets/066d7646-4672-4cae-8ec0-e30a82889914"
/>

### AI metrics
- General consistency improvements
<img width="1568" height="624" alt="CleanShot 2026-05-08 at 09 53 43"
src="https://github.com/user-attachments/assets/fdfc4806-26fa-458d-a5ed-5c226b3bbc9f"
/>

### Logs
- General consistency improvements
<img width="1267" height="752" alt="CleanShot 2026-05-08 at 09 54 30"
src="https://github.com/user-attachments/assets/3e9ba871-b9dd-490e-aded-5d87134fd2bb"
/>

### Errors
- General consistency improvements
<img width="1568" height="670" alt="CleanShot 2026-05-08 at 09 54 50"
src="https://github.com/user-attachments/assets/fdda027a-e24f-4804-b4bb-203a6c2db960"
/>

### Query
- General consistency improvements
- History, Scope, Triggered (date) filters all have shortcut tooltips
- Scope filter now reuses the metrics ScopeFilter component
<img width="1566" height="716" alt="CleanShot 2026-05-08 at 09 55 22"
src="https://github.com/user-attachments/assets/0130b4a2-9daf-4edc-bada-3380aff4022a"
/>

### Dashboards
- General consistency improvements
- Scope filter gets nicer icons and a shortcut
- Nice icons for the Scope menu items
<img width="1567" height="769" alt="CleanShot 2026-05-08 at 09 56 10"
src="https://github.com/user-attachments/assets/7bea25f7-6c33-4d4a-a36d-3a1cb56afe09"
/>

### Custom dashboard
- General consistency improvements
- Add chart, Add title, and the kebab menu now have tooltips + shortcuts
<img width="1566" height="782" alt="CleanShot 2026-05-08 at 09 58 11"
src="https://github.com/user-attachments/assets/9df4db25-b2c0-43a2-b92f-00256337d5a9"
/>

### Environment variables
- General consistency improvements
<img width="1569" height="930" alt="CleanShot 2026-05-08 at 09 58 55"
src="https://github.com/user-attachments/assets/26e614b4-88e7-400b-aa6d-a96bad488fb8"
/>

### Preview branches
- General consistency improvements
<img width="1570" height="986" alt="CleanShot 2026-05-08 at 09 59 17"
src="https://github.com/user-attachments/assets/57a2b939-3670-4252-ab2c-d6dc65bdda1b"
/>
This commit is contained in:
James Ritchie
2026-05-08 17:35:09 +01:00
committed by GitHub
parent 3cb6b5e9c4
commit f7a2bc7c96
45 changed files with 1829 additions and 1792 deletions
@@ -31,7 +31,9 @@ export function MachineLabel({
className?: string;
}) {
return (
<span className={cn("text-text-dimmed", className)}>{formatMachinePresetName(preset)}</span>
<span className={cn("text-text-dimmed group-hover/table-row:text-text-bright", className)}>
{formatMachinePresetName(preset)}
</span>
);
}
@@ -53,7 +53,7 @@ export function LogsLevelFilter() {
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
if (hasLevels) {
return <AppliedLevelFilter/>;
return <AppliedLevelFilter />;
}
return (
@@ -64,19 +64,16 @@ export function LogsLevelFilter() {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by level"
className="pl-1.5"
>
Level
<span className="ml-1">Level</span>
</SelectTrigger>
}
/>
);
}
function LevelDropdown({
trigger,
}: {
trigger: ReactNode;
}) {
function LevelDropdown({ trigger }: { trigger: ReactNode }) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
@@ -6,11 +6,7 @@ import { Button } from "~/components/primitives/Buttons";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import {
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { SelectPopover, SelectProvider, SelectTrigger } from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
@@ -34,8 +30,9 @@ export function LogsRunIdFilter() {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by run ID"
className="pl-1.5"
>
Run ID
<span className="ml-1">Run ID</span>
</SelectTrigger>
}
clearSearchValue={() => setSearch("")}
@@ -45,8 +45,9 @@ export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by task"
className="pl-1.5"
>
<span className="ml-0.5">Tasks</span>
<span className="ml-1">Tasks</span>
</SelectTrigger>
}
searchValue={search}
@@ -133,8 +134,9 @@ function TasksDropdown({
.filter((item) => item.isInLatestDeployment)
.map((item) => (
<SelectItem
key={item.slug}
key={`${item.triggerSource}-${item.slug}`}
value={item.slug}
className="text-text-bright"
icon={
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
@@ -149,8 +151,9 @@ function TasksDropdown({
.filter((item) => !item.isInLatestDeployment)
.map((item) => (
<SelectItem
key={item.slug}
key={`${item.triggerSource}-${item.slug}`}
value={item.slug}
className="text-text-bright"
icon={
<span className="opacity-50">
<TaskTriggerSourceIcon
@@ -22,8 +22,9 @@ export function LogsVersionFilter() {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by version"
className="pl-1.5"
>
<span className="ml-0.5">Versions</span>
<span className="ml-1">Versions</span>
</SelectTrigger>
}
searchValue={search}
@@ -16,7 +16,7 @@ import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
const shortcut = { key: "l" };
const shortcut = { key: "m" };
export type ModelOption = {
model: string;
@@ -38,19 +38,19 @@ function modelIcon(system: string, model: string): ReactNode {
// Special case: Anthropic uses a custom SVG icon
if (provider === "anthropic") {
return <AnthropicLogoIcon className="size-4 shrink-0" />;
return <AnthropicLogoIcon className="size-4 shrink-0 text-text-dimmed" />;
}
const iconName = `tabler-brand-${provider}`;
if (tablerIcons.has(iconName)) {
return (
<svg className="size-4 shrink-0 stroke-[1.5]">
<svg className="size-4 shrink-0 stroke-[1.5] text-text-dimmed">
<use xlinkHref={`${tablerSpritePath}#${iconName}`} />
</svg>
);
}
return <CubeIcon className="size-4 shrink-0" />;
return <CubeIcon className="size-4 shrink-0 text-text-dimmed" />;
}
export function ModelsFilter({ possibleModels }: ModelsFilterProps) {
@@ -68,8 +68,9 @@ export function ModelsFilter({ possibleModels }: ModelsFilterProps) {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by model"
className="pl-1.5"
>
<span className="ml-0.5">Models</span>
<span className="ml-1">Models</span>
</SelectTrigger>
}
searchValue={search}
@@ -147,7 +148,7 @@ function ModelsDropdown({
<ComboBox placeholder="Filter by model..." value={searchValue} />
<SelectList>
{filtered.map((m) => (
<SelectItem key={m.model} value={m.model} icon={modelIcon(m.system, m.model)}>
<SelectItem key={m.model} value={m.model} className="text-text-bright" icon={modelIcon(m.system, m.model)}>
{m.model}
</SelectItem>
))}
@@ -13,7 +13,7 @@ import {
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "n" };
const shortcut = { key: "o" };
interface OperationsFilterProps {
possibleOperations: string[];
@@ -45,8 +45,9 @@ export function OperationsFilter({ possibleOperations }: OperationsFilterProps)
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by operation"
className="pl-1.5"
>
<span className="ml-0.5">Operations</span>
<span className="ml-1">Operations</span>
</SelectTrigger>
}
searchValue={search}
@@ -125,7 +126,7 @@ function OperationsDropdown({
<ComboBox placeholder="Filter by operation..." value={searchValue} />
<SelectList>
{filtered.map((op) => (
<SelectItem key={op} value={op} icon={<CommandLineIcon className="size-4" />}>
<SelectItem key={op} value={op} className="text-text-bright" icon={<CommandLineIcon className="size-4 text-text-dimmed" />}>
{formatOperation(op)}
</SelectItem>
))}
@@ -34,8 +34,9 @@ export function PromptsFilter({ possiblePrompts }: PromptsFilterProps) {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by prompt"
className="pl-1.5"
>
<span className="ml-0.5">Prompts</span>
<span className="ml-1">Prompts</span>
</SelectTrigger>
}
searchValue={search}
@@ -113,7 +114,7 @@ function PromptsDropdown({
<ComboBox placeholder="Filter by prompt..." value={searchValue} />
<SelectList>
{filtered.map((slug) => (
<SelectItem key={slug} value={slug} icon={<DocumentTextIcon className="size-4" />}>
<SelectItem key={slug} value={slug} className="text-text-bright" icon={<DocumentTextIcon className="size-4 text-text-dimmed" />}>
{slug}
</SelectItem>
))}
@@ -34,8 +34,9 @@ export function ProvidersFilter({ possibleProviders }: ProvidersFilterProps) {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by provider"
className="pl-1.5"
>
<span className="ml-0.5">Providers</span>
<span className="ml-1">Providers</span>
</SelectTrigger>
}
searchValue={search}
@@ -111,7 +112,7 @@ function ProvidersDropdown({
<ComboBox placeholder="Filter by provider..." value={searchValue} />
<SelectList>
{filtered.map((provider) => (
<SelectItem key={provider} value={provider} icon={<ServerIcon className="size-4" />}>
<SelectItem key={provider} value={provider} className="text-text-bright" icon={<ServerIcon className="size-4 text-text-dimmed" />}>
{provider}
</SelectItem>
))}
@@ -39,6 +39,7 @@ export function QueuesFilter() {
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by queue"
className="pl-1.5"
>
<span className="ml-1">Queues</span>
</SelectTrigger>
@@ -190,6 +191,7 @@ function QueuesDropdown({
<SelectItem
key={queue.value}
value={queue.value}
className="text-text-bright"
icon={
queue.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
@@ -1,14 +1,17 @@
import * as Ariakit from "@ariakit/react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { FolderIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
import { EnvironmentIcon, EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Avatar } from "~/components/primitives/Avatar";
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import type { QueryScope } from "~/services/queryService.server";
import { CubeTransparentIcon, GlobeAltIcon } from "@heroicons/react/20/solid";
import { IconListLetters } from "@tabler/icons-react";
const scopeOptions = [
{ value: "environment", label: "Environment" },
@@ -16,29 +19,76 @@ const scopeOptions = [
{ value: "organization", label: "Organization" },
] as const;
export function ScopeFilter() {
const { value, replace } = useSearchParams();
const scope = (value("scope") as QueryScope) ?? "environment";
export type ScopeFilterProps = {
shortcut?: ShortcutDefinition;
/** Controlled value. If provided, the filter uses controlled mode and ignores search params. */
value?: QueryScope;
/** Called when the user selects a new scope. Required when `value` is provided. */
onValueChange?: (scope: QueryScope) => void;
};
export function ScopeFilter({ shortcut, value, onValueChange }: ScopeFilterProps = {}) {
const { value: paramValue, replace } = useSearchParams();
const isControlled = value !== undefined;
const scope: QueryScope = isControlled
? value
: ((paramValue("scope") as QueryScope) ?? "environment");
const triggerRef = useRef<HTMLButtonElement>(null);
const handleChange = (newScope: string) => {
if (isControlled) {
onValueChange?.(newScope as QueryScope);
return;
}
replace({ scope: newScope === "environment" ? undefined : newScope });
};
useShortcutKeys({
shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
disabled: !shortcut,
});
return (
<SelectProvider value={scope} setValue={handleChange}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Scope"
icon={<CubeTransparentIcon className="size-4" />}
value={<ScopeItem scope={scope} />}
removable={false}
variant="secondary/small"
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
label="Scope"
value={<ScopeItem scope={scope} />}
removable={false}
variant="secondary/small"
/>
</Ariakit.TooltipAnchor>
{shortcut && (
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright py-1.5 pl-2.5 pr-2 text-xs text-text-dimmed">
<div className="flex items-center gap-1.5">
<span>Change scope</span>
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant="small" />
</div>
</Ariakit.Tooltip>
)}
</Ariakit.TooltipProvider>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ScopeItem scope={option.value} />
<SelectItem
key={option.value}
value={option.value}
className="gap-x-2 text-text-bright"
icon={<ScopeIcon scope={option.value} />}
>
<ScopeLabel scope={option.value} />
</SelectItem>
))}
</SelectPopover>
@@ -46,19 +96,44 @@ export function ScopeFilter() {
);
}
function ScopeItem({ scope }: { scope: QueryScope }) {
function ScopeIcon({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const environment = useEnvironment();
switch (scope) {
case "organization":
return <Avatar avatar={organization.avatar} size={1} orgName={organization.title} />;
case "project":
return <FolderIcon className="size-4 text-indigo-500" />;
case "environment":
return <EnvironmentIcon environment={environment} className="size-4" />;
default:
return null;
}
}
function ScopeLabel({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
switch (scope) {
case "organization":
return `Org: ${organization.title}`;
return <span className="text-text-bright">{organization.title}</span>;
case "project":
return `Project: ${project.name}`;
return <span className="text-text-bright">{project.name}</span>;
case "environment":
return <EnvironmentLabel environment={environment} />;
return <EnvironmentLabel environment={environment} disableTooltip />;
default:
return scope;
}
}
function ScopeItem({ scope }: { scope: QueryScope }) {
return (
<span className="flex items-center gap-1">
<ScopeIcon scope={scope} />
<ScopeLabel scope={scope} />
</span>
);
}
@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid";
import { useEffect, useState } from "react";
import { type ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Feedback } from "~/components/Feedback";
@@ -118,17 +119,19 @@ export function CreateDashboardPageButton({
organization,
project,
environment,
shortcut,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
shortcut?: ShortcutDefinition;
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
<Button variant="primary/small" LeadingIcon={PlusIcon} shortcut={shortcut} className="pr-2">
Create custom dashboard
</Button>
</DialogTrigger>
@@ -162,7 +165,6 @@ function CreateDashboardUpgradeDialog({
isFreePlan: boolean;
organization: { slug: string };
}) {
if (isFreePlan) {
return (
<DialogContent>
@@ -489,7 +489,7 @@ export function SideMenu({
/>
)}
<SideMenuItem
name="AI Metrics"
name="AI metrics"
icon={AIMetricsIcon}
trailingIconClassName="size-5"
activeIconColor="text-aiMetrics"
@@ -305,7 +305,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
<TooltipContent className="flex items-center gap-1.5 py-1.5 pl-2.5 pr-2 text-xs text-text-bright">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
@@ -353,7 +353,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
form={props.form}
autoFocus={autoFocus}
>
<ButtonContent {...props} tooltip={undefined} />
<ButtonContent
{...props}
tooltip={undefined}
hideShortcutKey={props.tooltip ? true : props.hideShortcutKey}
/>
</button>
);
@@ -362,13 +366,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={isDisabled ? "cursor-default" : undefined}>
<span className={cn("flex", isDisabled && "cursor-default")}>
{buttonElement}
</span>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
<TooltipContent className="flex items-center gap-1.5 py-1.5 pl-2.5 pr-2 text-xs text-text-bright">
{props.tooltip} {props.shortcut && !props.hideShortcutKey && (
<ShortcutKey shortcut={props.shortcut} variant="medium" className="ml-1" />
<ShortcutKey shortcut={props.shortcut} variant="medium" />
)}
</TooltipContent>
</Tooltip>
@@ -67,6 +67,7 @@ const variants = {
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
variant?: keyof typeof variants;
icon?: RenderIcon;
iconClassName?: string;
accessory?: React.ReactNode;
fullWidth?: boolean;
containerClassName?: string;
@@ -81,6 +82,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
fullWidth = true,
variant = "medium",
icon,
iconClassName,
containerClassName,
...props
},
@@ -91,7 +93,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const variantContainerClassName = variants[variant].container;
const inputClassName = variants[variant].input;
const iconClassName = variants[variant].iconSize;
const variantIconClassName = variants[variant].iconSize;
return (
<div
@@ -106,7 +108,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
>
{icon && (
<div className="pointer-events-none flex items-center">
<Icon icon={icon} className={cn(iconClassName, "text-text-dimmed")} />
<Icon
icon={icon}
className={cn(variantIconClassName, "text-text-dimmed", iconClassName)}
/>
</div>
)}
<input
@@ -1,13 +1,16 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Input } from "~/components/primitives/Input";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useSearchParams } from "~/hooks/useSearchParam";
import { cn } from "~/utils/cn";
export type SearchInputProps = {
placeholder?: string;
/** The URL search param name to read/write. Defaults to "search". */
paramName?: string;
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
resetParams?: string[];
autoFocus?: boolean;
@@ -15,6 +18,7 @@ export type SearchInputProps = {
export function SearchInput({
placeholder = "Search logs…",
paramName = "search",
resetParams = ["cursor", "direction"],
autoFocus,
}: SearchInputProps) {
@@ -22,36 +26,31 @@ export function SearchInput({
const { value, replace, del } = useSearchParams();
const initialSearch = value("search") ?? "";
const initialSearch = value(paramName) ?? "";
const [text, setText] = useState(initialSearch);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
const urlSearch = value("search") ?? "";
const urlSearch = value(paramName) ?? "";
if (urlSearch !== text && !isFocused) {
setText(urlSearch);
}
}, [value, text, isFocused]);
}, [value, text, isFocused, paramName]);
const handleSubmit = useCallback(() => {
const handleSubmit = () => {
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
if (text.trim()) {
replace({ search: text.trim(), ...resetValues });
replace({ [paramName]: text.trim(), ...resetValues });
} else {
del(["search", ...resetParams]);
del([paramName, ...resetParams]);
}
}, [text, replace, del, resetParams]);
};
const handleClear = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setText("");
del(["search", ...resetParams]);
},
[del, resetParams]
);
const handleClear = () => {
setText("");
del([paramName, ...resetParams]);
};
return (
<div className="flex items-center gap-1">
@@ -81,24 +80,42 @@ export function SearchInput({
handleSubmit();
}
if (e.key === "Escape") {
e.currentTarget.blur();
if (text.length > 0) {
e.stopPropagation();
handleClear();
} else {
e.currentTarget.blur();
}
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
icon={<MagnifyingGlassIcon className="size-4" />}
icon={<MagnifyingGlassIcon className="size-4 text-text-bright" />}
accessory={
text.length > 0 ? (
<div className="-mr-1 flex items-center gap-1.5">
<ShortcutKey shortcut={{ key: "enter" }} variant="medium" className="border-none" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
</button>
<SimpleTooltip
asChild
button={
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClear()}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
>
<XMarkIcon className="size-3" />
</button>
}
content={
<div className="flex items-center gap-1">
<span className="text-text-dimmed">Clear field</span>
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
}
className="px-2 py-1.5 text-xs"
disableHoverableContent
/>
</div>
) : undefined
}
@@ -104,6 +104,7 @@ export interface SelectProps<TValue extends string | string[], TItem>
open?: boolean;
setOpen?: (open: boolean) => void;
shortcut?: ShortcutDefinition;
tooltipTitle?: string;
allowItemShortcuts?: boolean;
clearSearchOnSelection?: boolean;
dropdownIcon?: boolean | React.ReactNode;
@@ -127,6 +128,7 @@ export function Select<TValue extends string | string[], TItem>({
open,
setOpen,
shortcut,
tooltipTitle,
allowItemShortcuts = true,
disabled,
clearSearchOnSelection = true,
@@ -206,6 +208,7 @@ export function Select<TValue extends string | string[], TItem>({
text={text}
placeholder={placeholder}
shortcut={shortcut}
tooltipTitle={tooltipTitle}
disabled={disabled}
dropdownIcon={dropdownIcon}
{...props}
@@ -354,7 +357,7 @@ export function SelectTrigger({
</Ariakit.TooltipAnchor>
{showTooltip && (
<Ariakit.Tooltip
disabled={shortcut === undefined}
disabled={!tooltipTitle && !shortcut}
className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs"
>
<div className="flex items-center gap-2">
@@ -65,6 +65,7 @@ type TableProps = {
children: ReactNode;
fullWidth?: boolean;
showTopBorder?: boolean;
stickyHeader?: boolean;
};
// Add TableContext
@@ -79,6 +80,7 @@ export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: Table
fullWidth,
variant = "dimmed",
showTopBorder = true,
stickyHeader = false,
},
ref
) => {
@@ -86,7 +88,8 @@ export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: Table
<TableContext.Provider value={{ variant }}>
<div
className={cn(
"overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
"whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
stickyHeader ? "overflow-visible" : "overflow-x-auto",
showTopBorder && "border-t",
containerClassName,
fullWidth && "w-full"
@@ -37,6 +37,7 @@ import {
type QueryWidgetData,
} from "~/components/metrics/QueryWidget";
import { SaveToDashboardDialog } from "~/components/metrics/SaveToDashboardDialog";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import {
@@ -89,12 +90,6 @@ function toISOString(value: Date | string): string {
return value.toISOString();
}
const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
{ value: "organization", label: "Organization" },
] as const;
// Type for the query action response
type QueryActionResponse = {
error: string | null;
@@ -277,7 +272,7 @@ const QueryEditorForm = forwardRef<
<input type="hidden" name="from" value={from ?? ""} />
<input type="hidden" name="to" value={to ?? ""} />
<QueryHistoryPopover history={history} onQuerySelected={handleHistorySelected} />
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5">
{isAdmin && (
<Button
type="submit"
@@ -289,24 +284,11 @@ const QueryEditorForm = forwardRef<
Explain
</Button>
)}
<Select
<ScopeFilter
value={scope}
setValue={(value) => setScope(value as QueryScope)}
variant="secondary/small"
dropdownIcon={true}
items={[...scopeOptions]}
text={(value) => {
return <ScopeItem scope={value as QueryScope} />;
}}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<ScopeItem scope={item.value as QueryScope} />
</SelectItem>
))
}
</Select>
onValueChange={setScope}
shortcut={{ key: "e" }}
/>
{queryHasTriggeredAt ? (
<SimpleTooltip
asChild
@@ -335,6 +317,7 @@ const QueryEditorForm = forwardRef<
period={period}
from={from}
to={to}
shortcut={{ key: "d" }}
applyShortcut={{ key: "enter", enabledOnInputElements: true }}
onValueChange={(values) => {
flushSync(() => {
@@ -1133,27 +1116,6 @@ function ExportResultsButton({
);
}
function ScopeItem({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
switch (scope) {
case "organization":
return <span className="text-text-bright">{`Org: ${organization.title}`}</span>;
case "project":
return <span className="text-text-bright">{`Project: ${project.name}`}</span>;
case "environment":
return (
<span className="text-text-bright">
Env: <EnvironmentLabel environment={environment} />
</span>
);
default:
return <span className="text-text-bright">{scope}</span>;
}
}
function QueryResultsCallouts({
hiddenColumns,
periodClipped,
@@ -1,3 +1,4 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useFetcher, useNavigate } from "@remix-run/react";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
@@ -6,6 +7,7 @@ import { Input } from "~/components/primitives/Input";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -107,6 +109,11 @@ export function AIFilterInput() {
form.requestSubmit();
}
}
if (e.key === "Escape") {
e.stopPropagation();
setText("");
e.currentTarget.blur();
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => {
@@ -125,11 +132,34 @@ export function AIFilterInput() {
className="size-4 opacity-80"
/>
) : text.length > 0 ? (
<ShortcutKey
shortcut={{ key: "enter" }}
variant="small"
className={cn("transition-opacity", text.length === 0 && "opacity-0")}
/>
<div className="-mr-1 flex items-center gap-1.5">
<ShortcutKey
shortcut={{ key: "enter" }}
variant="medium"
className="border-none"
/>
<SimpleTooltip
asChild
button={
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setText("")}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
>
<XMarkIcon className="size-3" />
</button>
}
content={
<div className="flex items-center gap-1">
<span className="text-text-dimmed">Clear field</span>
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
}
className="px-2 py-1.5 text-xs"
disableHoverableContent
/>
</div>
) : undefined
}
/>
@@ -8,25 +8,18 @@ import {
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { type ReactNode, useCallback, useRef, useState } from "react";
import { z } from "zod";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import {
Tooltip,
TooltipContent,
@@ -35,6 +28,7 @@ import {
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "../../primitives/Buttons";
import {
allBatchStatuses,
@@ -42,7 +36,13 @@ import {
batchStatusTitle,
descriptionForBatchStatus,
} from "./BatchStatus";
import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters";
import {
TimeFilter,
appliedSummary,
FilterMenuProvider,
IdFilterDropdown,
type IdFilterDropdownProps,
} from "./SharedFilters";
import { StatusIcon } from "~/assets/icons/StatusIcon";
export const BatchStatus = z.enum(allBatchStatuses);
@@ -69,133 +69,33 @@ type BatchFiltersProps = {
export function BatchFilters(props: BatchFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters = searchParams.has("statuses") || searchParams.has("id");
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("id") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<TimeFilter />
<AppliedFilters />
<div className="flex flex-row flex-wrap items-center gap-1.5">
<PermanentStatusFilter />
<PermanentBatchIdFilter />
<TimeFilter shortcut={{ key: "d" }} />
{hasFilters && (
<Form className="h-6">
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: (
<div className="flex size-4 items-center justify-center">
<div className="size-3 rounded-full border-2 border-text-dimmed" />
</div>
),
},
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu(props: BatchFiltersProps) {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter batches"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
{...props}
/>
)}
</FilterMenuProvider>
);
}
function AppliedFilters() {
return (
<>
<AppliedStatusFilter />
<AppliedBatchIdFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
} & BatchFiltersProps;
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "batch":
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = allBatchStatuses.map((status) => ({
title: batchStatusTitle(status),
value: status,
@@ -219,10 +119,6 @@ function StatusDropdown({
replace({ statuses: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
@@ -237,9 +133,8 @@ function StatusDropdown({
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
{statuses.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
@@ -265,30 +160,68 @@ function StatusDropdown({
);
}
function AppliedStatusFilter() {
const statusShortcut = { key: "s" };
function PermanentStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
const hasStatuses = statuses.length > 0;
const triggerRef = useRef<HTMLButtonElement>(null);
if (statuses.length === 0) {
return null;
}
useShortcutKeys({
shortcut: statusShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasStatuses ? (
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<div className="grid size-4 place-items-center">
<div className="size-[75%] rounded-full border-2 border-text-bright" />
</div>
<span>Status</span>
</div>
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by status</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={statusShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
@@ -298,117 +231,83 @@ function AppliedStatusFilter() {
);
}
function BatchIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const batchIdValue = value("id");
const [batchId, setBatchId] = useState(batchIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
id: batchId === "" ? undefined : batchId?.toString(),
});
setOpen(false);
}, [batchId, replace]);
let error: string | undefined = undefined;
if (batchId) {
if (!batchId.startsWith("batch_")) {
error = "Batch IDs start with 'batch_'";
} else if (batchId.length !== 27 && batchId.length !== 31) {
error = "Batch IDs are 27/32 characters long";
}
}
function validateBatchId(value: string): string | undefined {
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long";
}
function BatchIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="batch_"
value={batchId ?? ""}
onChange={(e) => setBatchId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !batchId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Batch ID"
placeholder="batch_"
paramKey="id"
validate={validateBatchId}
/>
);
}
function AppliedBatchIdFilter() {
const batchIdShortcut = { key: "b" };
function PermanentBatchIdFilter() {
const { value, del } = useSearchParams();
if (value("id") === undefined) {
return null;
}
const batchId = value("id");
const hasBatchId = batchId !== undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: batchIdShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BatchIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
icon={<Squares2X2Icon className="size-3.5" />}
value={batchId}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasBatchId ? (
<AppliedFilter
label="Batch ID"
icon={<Squares2X2Icon className="size-3.5" />}
value={batchId}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<Squares2X2Icon className="size-3.5" />
<span>Batch ID</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by batch ID</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={batchIdShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
+268 -425
View File
@@ -3,6 +3,7 @@ import {
CalendarIcon,
ClockIcon,
FingerPrintIcon,
PlusIcon,
RectangleStackIcon,
Squares2X2Icon,
TagIcon,
@@ -12,9 +13,8 @@ import { Form, useFetcher } from "@remix-run/react";
import { IconBugFilled, IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
import { MachinePresetName } from "@trigger.dev/core/v3";
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { MachineDefaultIcon } from "~/assets/icons/MachineIcon";
@@ -28,9 +28,6 @@ import {
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Badge } from "~/components/primitives/Badge";
import { DateTime } from "~/components/primitives/DateTime";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -59,13 +56,22 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionTypeCombo } from "./BulkAction";
import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters";
import { AIFilterInput } from "./AIFilterInput";
import { BulkActionTypeCombo } from "./BulkAction";
import {
IdFilterDropdown,
type IdFilterDropdownProps,
appliedSummary,
FilterMenuProvider,
TimeFilter,
timeFilters,
} from "./SharedFilters";
import {
allTaskRunStatuses,
descriptionForTaskRunStatus,
@@ -356,18 +362,23 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("errorId");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<div className="flex flex-row flex-wrap items-center gap-1.5">
{!props.hideSearch && <AIFilterInput />}
<PermanentStatusFilter />
<PermanentTasksFilter possibleTasks={props.possibleTasks} />
<TimeFilter defaultPeriod={props.defaultPeriod} shortcut={{ key: "d" }} />
<RootOnlyToggle defaultValue={props.rootOnlyDefault} />
<TimeFilter defaultPeriod={props.defaultPeriod} />
<AppliedFilters {...props} />
<FilterMenu {...props} />
{hasFilters && (
<Form className="h-6">
{searchParams.has("rootOnly") && (
<input type="hidden" name="rootOnly" value={searchParams.get("rootOnly") as string} />
)}
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
@@ -375,12 +386,6 @@ export function RunsFilters(props: RunFiltersProps) {
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: <StatusIcon className="size-4 border-text-bright" />,
},
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "versions", title: "Versions", icon: <IconRotateClockwise2 className="size-4" /> },
{ name: "queues", title: "Queues", icon: <RectangleStackIcon className="size-4" /> },
@@ -403,15 +408,15 @@ function FilterMenu(props: RunFiltersProps) {
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
<PlusIcon className="size-3.5" />
</div>
}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
className="pr-0.5"
tooltipTitle={"More filters"}
className="pl-1 pr-2"
>
<></>
More filters
</SelectTrigger>
);
@@ -431,11 +436,9 @@ function FilterMenu(props: RunFiltersProps) {
);
}
function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
function AppliedFilters({ bulkActions }: RunFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedVersionsFilter />
<AppliedQueuesFilter />
@@ -461,10 +464,6 @@ function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tasks":
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "bulk":
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
@@ -509,7 +508,7 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
<span className="text-text-bright">{type.title}</span>
</SelectButtonItem>
))}
</SelectList>
@@ -587,28 +586,67 @@ function StatusDropdown({
);
}
function AppliedStatusFilter() {
const statusShortcut = { key: "s" };
function PermanentStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
const hasStatuses = statuses.length > 0 && !statuses.every((v) => v === "");
const triggerRef = useRef<HTMLButtonElement>(null);
if (statuses.length === 0 || statuses.every((v) => v === "")) {
return null;
}
useShortcutKeys({
shortcut: statusShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={filterIcon("statuses")}
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasStatuses ? (
<AppliedFilter
label="Status"
icon={filterIcon("statuses")}
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<div className="grid size-4 place-items-center">
<div className="size-[75%] rounded-full border-2 border-text-bright" />
</div>
<span>Status</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by status</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={statusShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
@@ -633,9 +671,23 @@ function TasksDropdown({
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
const handleChange = (newValues: string[]) => {
clearSearchValue();
replace({ tasks: values, cursor: undefined, direction: undefined });
const previousTasks = values("tasks");
const wasEmpty = previousTasks.length === 0 || previousTasks.every((v) => v === "");
const isEmpty = newValues.length === 0 || newValues.every((v) => v === "");
// empty -> tasks: temporarily force rootOnly off so child runs of the selected
// task are visible. tasks -> empty: drop rootOnly so the toggle reverts to the
// user's saved session preference. Neither writes to the cookie (see loader).
const transitioningToTasks = wasEmpty && !isEmpty;
const transitioningToNoTasks = !wasEmpty && isEmpty;
replace({
tasks: newValues,
cursor: undefined,
direction: undefined,
...(transitioningToTasks ? { rootOnly: "false" } : {}),
...(transitioningToNoTasks ? { rootOnly: undefined } : {}),
});
};
const filtered = useMemo(() => {
@@ -664,11 +716,12 @@ function TasksDropdown({
.filter((item) => item.isInLatestDeployment)
.map((item) => (
<SelectItem
key={item.slug}
key={`${item.triggerSource}-${item.slug}`}
value={item.slug}
icon={
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
className="text-text-bright"
>
<MiddleTruncate text={item.slug} />
</SelectItem>
@@ -680,7 +733,7 @@ function TasksDropdown({
.filter((item) => !item.isInLatestDeployment)
.map((item) => (
<SelectItem
key={item.slug}
key={`${item.triggerSource}-${item.slug}`}
value={item.slug}
icon={
<span className="opacity-50">
@@ -690,6 +743,7 @@ function TasksDropdown({
/>
</span>
}
className="text-text-bright"
>
<MiddleTruncate text={item.slug} />
</SelectItem>
@@ -702,32 +756,70 @@ function TasksDropdown({
);
}
function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTasks">) {
const { values, del } = useSearchParams();
const tasksShortcut = { key: "t" };
if (values("tasks").length === 0 || values("tasks").every((v) => v === "")) {
return null;
}
function PermanentTasksFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTasks">) {
const { values, del } = useSearchParams();
const tasks = values("tasks");
const hasTasks = tasks.length > 0 && !tasks.every((v) => v === "");
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: tasksShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Task"
icon={filterIcon("tasks")}
value={appliedSummary(
values("tasks").map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
return task ? task.slug : v;
})
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasTasks ? (
<AppliedFilter
label="Task"
icon={filterIcon("tasks")}
value={appliedSummary(
tasks.map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
return task ? task.slug : v;
})
)}
onRemove={() => del(["tasks", "cursor", "direction", "rootOnly"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
{filterIcon("tasks")}
<span>Tasks</span>
</div>
)}
onRemove={() => del(["tasks", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by task</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={tasksShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
@@ -922,15 +1014,17 @@ function TagsDropdown({
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>
)}
/>
{(filtered.length > 0 || fetcher.state === "loading" || searchValue.length > 0) && (
<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) => (
@@ -1102,6 +1196,7 @@ function QueuesDropdown({
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
className="text-text-bright"
>
{queue.name}
</SelectItem>
@@ -1187,15 +1282,15 @@ function MachinesDropdown({
return true;
}}
>
<ComboBox placeholder={"Filter by machine..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item}
value={item}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
className="text-text-bright"
>
<MachineLabelCombo preset={item} />
<MachineLabelCombo preset={item} labelClassName="text-text-bright" />
</SelectItem>
))}
</SelectList>
@@ -1343,7 +1438,12 @@ export function VersionsDropdown({
<SelectList>
{filtered.length > 0
? filtered.map((version) => (
<SelectItem key={version.version} value={version.version}>
<SelectItem
key={version.version}
value={version.version}
icon={<IconRotateClockwise2 className="size-4 flex-none text-text-dimmed" />}
className="text-text-bright"
>
<span className="flex items-center gap-2">
<span className="grow truncate">{version.version}</span>
{version.isCurrent ? <Badge variant="extra-small">Current</Badge> : null}
@@ -1392,118 +1492,67 @@ function AppliedVersionsFilter() {
);
}
const rootOnlyShortcut = { key: "o" };
function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
const { value, values, replace } = useSearchParams();
const { value, replace } = useSearchParams();
const searchValue = value("rootOnly");
const rootOnly = searchValue !== undefined ? searchValue === "true" : defaultValue;
const batchId = value("batchId");
const runId = value("runId");
const scheduleId = value("scheduleId");
const tasks = values("tasks");
const disabled = !!batchId || !!runId || !!scheduleId || tasks.length > 0;
const disabled = !!batchId || !!runId || !!scheduleId;
return (
<Switch
disabled={disabled}
variant="secondary/small"
label="Root only"
checked={disabled ? false : rootOnly}
onCheckedChange={(checked) => {
replace({
rootOnly: checked ? "true" : "false",
});
}}
/>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor render={<div />}>
<Switch
disabled={disabled}
variant="secondary/small"
label="Root only"
checked={disabled ? false : rootOnly}
shortcut={rootOnlyShortcut}
onCheckedChange={(checked) => {
replace({
rootOnly: checked ? "true" : "false",
cursor: undefined,
direction: undefined,
});
}}
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Toggle root only</span>
<ShortcutKey className="size-4 flex-none" shortcut={rootOnlyShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
);
}
function RunIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const runIdValue = value("runId");
const [runId, setRunId] = useState(runIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
runId: runId === "" ? undefined : runId?.toString(),
});
setOpen(false);
}, [runId, replace]);
let error: string | undefined = undefined;
if (runId) {
if (!runId.startsWith("run_")) {
error = "Run IDs start with 'run_'";
} else if (runId.length !== 25 && runId.length !== 29) {
error = "Run IDs are 25/30 characters long";
}
}
function validateRunId(value: string): string | undefined {
if (!value.startsWith("run_")) return "Run IDs start with 'run_'";
if (value.length !== 25 && value.length !== 29) return "Run IDs are 25 or 29 characters long";
}
function RunIdDropdown(
props: Omit<
IdFilterDropdownProps,
"label" | "placeholder" | "paramKey" | "validate" | "inputWidth"
>
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Run ID</Label>
<Input
placeholder="run_"
value={runId ?? ""}
onChange={(e) => setRunId(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !runId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Run ID"
placeholder="run_"
paramKey="runId"
validate={validateRunId}
inputWidth="w-[27ch]"
/>
);
}
@@ -1539,91 +1588,22 @@ function AppliedRunIdFilter() {
);
}
function BatchIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const batchIdValue = value("batchId");
const [batchId, setBatchId] = useState(batchIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
batchId: batchId === "" ? undefined : batchId?.toString(),
});
setOpen(false);
}, [batchId, replace]);
let error: string | undefined = undefined;
if (batchId) {
if (!batchId.startsWith("batch_")) {
error = "Batch IDs start with 'batch_'";
} else if (batchId.length !== 27 && batchId.length !== 31) {
error = "Batch IDs are 27 or 31 characters long";
}
}
function validateBatchId(value: string): string | undefined {
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long";
}
function BatchIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="batch_"
value={batchId ?? ""}
onChange={(e) => setBatchId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !batchId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Batch ID"
placeholder="batch_"
paramKey="batchId"
validate={validateBatchId}
/>
);
}
@@ -1659,91 +1639,22 @@ function AppliedBatchIdFilter() {
);
}
function ScheduleIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const scheduleIdValue = value("scheduleId");
const [scheduleId, setScheduleId] = useState(scheduleIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(),
});
setOpen(false);
}, [scheduleId, replace]);
let error: string | undefined = undefined;
if (scheduleId) {
if (!scheduleId.startsWith("sched")) {
error = "Schedule IDs start with 'sched_'";
} else if (scheduleId.length !== 27) {
error = "Schedule IDs are 27 characters long";
}
}
function validateScheduleId(value: string): string | undefined {
if (!value.startsWith("sched_")) return "Schedule IDs start with 'sched_'";
if (value.length !== 27) return "Schedule IDs are 27 characters long";
}
function ScheduleIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Schedule ID</Label>
<Input
placeholder="sched_"
value={scheduleId ?? ""}
onChange={(e) => setScheduleId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !scheduleId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Schedule ID"
placeholder="sched_"
paramKey="scheduleId"
validate={validateScheduleId}
/>
);
}
@@ -1779,89 +1690,21 @@ function AppliedScheduleIdFilter() {
);
}
function ErrorIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const errorIdValue = value("errorId");
const [errorId, setErrorId] = useState(errorIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
errorId: errorId === "" ? undefined : errorId?.toString(),
});
setOpen(false);
}, [errorId, replace]);
let error: string | undefined = undefined;
if (errorId) {
if (!errorId.startsWith("error_")) {
error = "Error IDs start with 'error_'";
}
}
function validateErrorId(value: string): string | undefined {
if (!value.startsWith("error_")) return "Error IDs start with 'error_'";
}
function ErrorIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Error ID</Label>
<Input
placeholder="error_"
value={errorId ?? ""}
onChange={(e) => setErrorId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !errorId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Error ID"
placeholder="error_"
paramKey="errorId"
validate={validateErrorId}
/>
);
}
@@ -1,21 +1,22 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { ClockIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { z } from "zod";
import { Input } from "~/components/primitives/Input";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useThrottle } from "~/hooks/useThrottle";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { SearchInput } from "~/components/primitives/SearchInput";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../primitives/SimpleSelect";
import { ScheduleTypeCombo } from "./ScheduleType";
SelectList,
SelectPopover,
SelectProvider,
} from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "../../primitives/Buttons";
import { ScheduleTypeIcon, scheduleTypeName } from "./ScheduleType";
import { FilterMenuProvider } from "./SharedFilters";
export const ScheduleListFilters = z.object({
page: z.coerce.number().default(1),
@@ -29,120 +30,232 @@ export const ScheduleListFilters = z.object({
export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
const All = "ALL";
type ScheduleFiltersProps = {
possibleTasks: string[];
};
export function ScheduleFilters({ possibleTasks }: ScheduleFiltersProps) {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { tasks, page, search, type } = ScheduleListFilters.parse(
Object.fromEntries(searchParams.entries())
);
const hasFilters = searchParams.has("tasks") || searchParams.has("search");
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("page");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleTaskChange = useCallback((value: string | typeof All) => {
handleFilterChange("tasks", value === "ALL" ? undefined : value);
}, []);
const handleTypeChange = useCallback((value: string | typeof All) => {
handleFilterChange("type", value === "ALL" ? undefined : value);
}, []);
const handleSearchChange = useThrottle((value: string) => {
handleFilterChange("search", value.length === 0 ? undefined : value);
}, 300);
const clearFilters = useCallback(() => {
searchParams.delete("page");
searchParams.delete("enabled");
searchParams.delete("tasks");
searchParams.delete("search");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const hasFilters =
searchParams.has("tasks") || searchParams.has("search") || searchParams.has("type");
return (
<div className="flex w-full">
<Input
name="search"
placeholder="Search schedule id, external id, deduplication id or CRON pattern"
icon={MagnifyingGlassIcon}
variant="tertiary"
className="grow"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
<SelectGroup className="ml-2">
<Select name="type" value={type ?? "ALL"} onValueChange={handleTypeChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder={"Select type"} className="ml-2 whitespace-nowrap p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All types
</Paragraph>
</SelectItem>
<SelectItem value={"declarative"}>
<ScheduleTypeCombo type="DECLARATIVE" className="text-xs text-text-dimmed" />
</SelectItem>
<SelectItem value={"imperative"}>
<ScheduleTypeCombo type="IMPERATIVE" className="text-xs text-text-dimmed" />
</SelectItem>
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All tasks
</Paragraph>
</SelectItem>
{possibleTasks.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
{hasFilters && (
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon}>
Clear all
</Button>
)}
<div className="flex flex-row flex-wrap items-center gap-1.5">
<ScheduleSearchInput />
<PermanentTaskFilter possibleTasks={possibleTasks} />
<PermanentTypeFilter />
{hasFilters && <ClearFiltersButton />}
</div>
);
}
function ScheduleSearchInput() {
return <SearchInput placeholder="Search schedules…" resetParams={["page"]} />;
}
const typeShortcut = { key: "y" };
function PermanentTypeFilter() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const currentType = searchParams.get("type") ?? undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: typeShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
const handleChange = useCallback(
(value: string | string[]) => {
const selected = Array.isArray(value) ? value[0] : value;
const params = new URLSearchParams(location.search);
if (!selected || selected === "ALL") {
params.delete("type");
} else {
params.set("type", selected);
}
params.delete("page");
navigate(`${location.pathname}?${params.toString()}`);
},
[location, navigate]
);
const typeLabel = currentType
? scheduleTypeName(currentType.toUpperCase() as "IMPERATIVE" | "DECLARATIVE")
: "All types";
return (
<FilterMenuProvider>
{() => (
<SelectProvider value={currentType ?? "ALL"} setValue={handleChange} virtualFocus={true}>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
label="Type"
value={typeLabel}
removable={!!currentType}
onRemove={() => handleChange("ALL")}
variant="secondary/small"
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by type</span>
<ShortcutKey className="size-4 flex-none" shortcut={typeShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
<SelectList>
<SelectItem value="ALL" className="text-text-bright">
All types
</SelectItem>
<SelectItem value="declarative">
<div className="flex items-center gap-1">
<ScheduleTypeIcon type="DECLARATIVE" className="text-text-dimmed" />
<span className="text-text-bright">{scheduleTypeName("DECLARATIVE")}</span>
</div>
</SelectItem>
<SelectItem value="imperative">
<div className="flex items-center gap-1">
<ScheduleTypeIcon type="IMPERATIVE" className="text-text-dimmed" />
<span className="text-text-bright">{scheduleTypeName("IMPERATIVE")}</span>
</div>
</SelectItem>
</SelectList>
</SelectPopover>
</SelectProvider>
)}
</FilterMenuProvider>
);
}
const taskShortcut = { key: "t" };
function PermanentTaskFilter({ possibleTasks }: { possibleTasks: string[] }) {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const currentTask = searchParams.get("tasks") ?? undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: taskShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
const handleChange = useCallback(
(value: string | string[]) => {
const selected = Array.isArray(value) ? value[0] : value;
const params = new URLSearchParams(location.search);
if (!selected || selected === "ALL") {
params.delete("tasks");
} else {
params.set("tasks", selected);
}
params.delete("page");
navigate(`${location.pathname}?${params.toString()}`);
},
[location, navigate]
);
const taskLabel = currentTask ?? "All tasks";
return (
<FilterMenuProvider>
{() => (
<SelectProvider value={currentTask ?? "ALL"} setValue={handleChange} virtualFocus={true}>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
label="Task"
icon={<ClockIcon className="size-4" />}
value={taskLabel}
removable={!!currentTask}
onRemove={() => handleChange("ALL")}
variant="secondary/small"
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by task</span>
<ShortcutKey className="size-4 flex-none" shortcut={taskShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
<SelectPopover className="min-w-0 max-w-[min(360px,var(--popover-available-width))]">
<SelectList>
<SelectItem value="ALL" className="text-text-bright">
All tasks
</SelectItem>
{possibleTasks.map((task) => (
<SelectItem
key={task}
value={task}
icon={<ClockIcon className="size-4 text-schedules" />}
className="text-text-bright"
>
{task}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
)}
</FilterMenuProvider>
);
}
function ClearFiltersButton() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const clearFilters = useCallback(() => {
const params = new URLSearchParams(location.search);
params.delete("page");
params.delete("tasks");
params.delete("search");
params.delete("type");
navigate(`${location.pathname}?${params.toString()}`);
}, [location, navigate]);
return (
<div className="-ml-1 h-6">
<Button
variant="minimal/small"
onClick={clearFilters}
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</div>
);
}
@@ -1,5 +1,4 @@
import * as Ariakit from "@ariakit/react";
import type { RuntimeEnvironment } from "@trigger.dev/database";
import {
endOfDay,
endOfMonth,
@@ -11,20 +10,23 @@ import {
subWeeks,
} from "date-fns";
import parse from "parse-duration";
import { startTransition, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode, startTransition, useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import { DateTimePicker } from "~/components/primitives/DateTimePicker";
import { FormError } from "~/components/primitives/FormError";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { cn } from "~/utils/cn";
import { organizationBillingPath } from "~/utils/pathBuilder";
import { Button, LinkButton } from "../../primitives/Buttons";
@@ -422,11 +424,7 @@ export function TimeFilter({
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by time period</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={shortcut}
variant="small"
/>
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant="small" />
</div>
</Ariakit.Tooltip>
)}
@@ -1005,3 +1003,102 @@ function QuickDateButton({
</Button>
);
}
export type IdFilterDropdownProps = {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
label: string;
placeholder: string;
paramKey: string;
validate?: (value: string) => string | undefined;
inputWidth?: string;
};
export function IdFilterDropdown({
trigger,
clearSearchValue,
onClose,
label,
placeholder,
paramKey,
validate,
inputWidth = "w-[29ch]",
}: IdFilterDropdownProps) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const currentValue = value(paramKey);
const [inputValue, setInputValue] = useState(currentValue);
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setInputValue(currentValue);
}
const apply = () => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
[paramKey]: inputValue === "" ? undefined : inputValue?.toString(),
});
setOpen(false);
};
const error = inputValue ? validate?.(inputValue) : undefined;
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-3 p-3 pt-2">
<div className="flex flex-col gap-1">
<Paragraph variant="extra-small/bright" className="mb-0.5">
{label}
</Paragraph>
<Input
placeholder={placeholder}
value={inputValue ?? ""}
onChange={(e) => setInputValue(e.target.value)}
variant="small"
className={cn(inputWidth, "font-mono")}
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1">
<Button variant="secondary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !inputValue}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
@@ -31,7 +31,7 @@ import {
type NextRunListItem,
} from "~/presenters/v3/NextRunListPresenter.server";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } from "~/utils/pathBuilder";
import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
import { Spinner } from "../../primitives/Spinner";
@@ -102,7 +102,7 @@ export function TaskRunsTable({
}
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 tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search);
const showCompute = isManagedCloud;
@@ -162,6 +162,7 @@ export function TaskRunsTable({
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell
disableTooltipHoverableContent
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{filterableTaskRunStatuses.map((status) => (
@@ -185,6 +186,7 @@ export function TaskRunsTable({
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell
colSpan={3}
disableTooltipHoverableContent
tooltip={
<div className="flex max-w-xs flex-col gap-4 p-1">
<div>
@@ -319,9 +321,16 @@ export function TaskRunsTable({
if (tableStateParam) {
searchParams.set("tableState", tableStateParam);
}
const path = v3RunSpanPath(organization, project, run.environment, run, {
spanId: run.spanId,
}, searchParams);
const path = v3RunSpanPath(
organization,
project,
run.environment,
run,
{
spanId: run.spanId,
},
searchParams
);
return (
<TableRow key={run.id}>
{allowSelection && (
@@ -427,7 +436,11 @@ export function TaskRunsTable({
</span>
</TableCell>
<TableCell to={path}>
{run.isTest ? <CheckIcon className="size-4 text-charcoal-400" /> : ""}
{run.isTest ? (
<CheckIcon className="size-4 text-charcoal-400 group-hover/table-row:text-text-bright" />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
@@ -449,7 +462,7 @@ export function TaskRunsTable({
{isLoading && (
<TableBlankRow
colSpan={15}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
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>
@@ -592,7 +605,9 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
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);
const testPath = singleTaskFromFilters
? v3TestTaskPath(organization, project, environment, { taskIdentifier: singleTaskFromFilters })
: v3TestPath(organization, project, environment);
if (
filters.tasks.length === 1 &&
@@ -645,11 +660,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
Refresh
</Button>
<Paragraph>or</Paragraph>
<LinkButton
LeadingIcon={BeakerIcon}
variant="tertiary/medium"
to={testPath}
>
<LinkButton LeadingIcon={BeakerIcon} variant="tertiary/medium" to={testPath}>
Run a test
</LinkButton>
</div>
@@ -1,28 +1,24 @@
import * as Ariakit from "@ariakit/react";
import { CalendarIcon, FingerPrintIcon, TagIcon, TrashIcon } from "@heroicons/react/20/solid";
import { FingerPrintIcon, TagIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher } from "@remix-run/react";
import { WaitpointTokenStatus, waitpointTokenStatuses } from "@trigger.dev/core/v3";
import { ListChecks, ListFilterIcon } from "lucide-react";
import { ListChecks } from "lucide-react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { StatusIcon } from "~/assets/icons/StatusIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Button } from "~/components/primitives/Buttons";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import {
Tooltip,
@@ -35,8 +31,15 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags";
import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters";
import {
IdFilterDropdown,
type IdFilterDropdownProps,
appliedSummary,
FilterMenuProvider,
TimeFilter,
} from "./SharedFilters";
import { WaitpointStatusCombo, waitpointStatusTitle } from "./WaitpointStatus";
export const WaitpointSearchParamsSchema = z.object({
@@ -66,136 +69,33 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) {
searchParams.has("statuses") ||
searchParams.has("tags") ||
searchParams.has("id") ||
searchParams.has("idempotencyKey");
searchParams.has("idempotencyKey") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu />
<TimeFilter />
<AppliedFilters />
<div className="flex flex-row flex-wrap items-center gap-1.5">
<PermanentStatusFilter />
<PermanentTagsFilter />
<PermanentWaitpointIdFilter />
<PermanentIdempotencyKeyFilter />
<TimeFilter shortcut={{ key: "d" }} />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon} tooltip="Clear all filters" />
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: <StatusIcon className="size-4" />,
},
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "id", title: "Waitpoint ID", icon: <FingerPrintIcon className="size-4" /> },
{ name: "idempotencyKey", title: "Idempotency key", icon: <ListChecks className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu() {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
/>
)}
</FilterMenuProvider>
);
}
function AppliedFilters() {
return (
<>
<AppliedStatusFilter />
<AppliedTagsFilter />
<AppliedWaitpointIdFilter />
<AppliedIdempotencyKeyFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
};
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "id":
return <WaitpointIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "idempotencyKey":
return <IdempotencyKeyDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = waitpointTokenStatuses.map((status) => ({
title: waitpointStatusTitle(status),
value: status,
@@ -237,7 +137,6 @@ function StatusDropdown({
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => {
return (
@@ -249,7 +148,7 @@ function StatusDropdown({
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<WaitpointStatusCombo status={item.value} />
<WaitpointStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={50}>
<Paragraph variant="extra-small">
@@ -267,30 +166,68 @@ function StatusDropdown({
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
const statusShortcut = { key: "s" };
if (statuses.length === 0) {
return null;
}
function PermanentStatusFilter() {
const { values, del } = useSearchParams();
const selectedStatuses = values("statuses");
const hasStatuses = selectedStatuses.length > 0 && !selectedStatuses.every((v) => v === "");
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: statusShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus))
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasStatuses ? (
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
selectedStatuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<div className="grid size-4 place-items-center">
<div className="size-[75%] rounded-full border-2 border-text-bright" />
</div>
<span>Status</span>
</div>
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by status</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={statusShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
@@ -366,19 +303,21 @@ function TagsDropdown({
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>
)}
/>
{!(filtered.length === 0 && fetcher.state !== "loading" && searchValue === "") && (
<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}>
? filtered.map((tag) => (
<SelectItem key={tag} value={tag} className="text-text-bright">
{tag}
</SelectItem>
))
@@ -392,29 +331,64 @@ function TagsDropdown({
);
}
function AppliedTagsFilter() {
const tagsShortcut = { key: "g" };
function PermanentTagsFilter() {
const { values, del } = useSearchParams();
const tags = values("tags");
const hasTags = tags.length > 0 && !tags.every((v) => v === "");
const triggerRef = useRef<HTMLButtonElement>(null);
if (tags.length === 0) {
return null;
}
useShortcutKeys({
shortcut: tagsShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Tags"
icon={<TagIcon className="size-3.5" />}
value={appliedSummary(values("tags"))}
onRemove={() => del(["tags", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasTags ? (
<AppliedFilter
label="Tags"
icon={<TagIcon className="size-3.5" />}
value={appliedSummary(tags)}
onRemove={() => del(["tags", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<TagIcon className="size-4" />
<span>Tags</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by tags</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={tagsShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
@@ -424,117 +398,82 @@ function AppliedTagsFilter() {
);
}
function WaitpointIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const idValue = value("id");
const [id, setId] = useState(idValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
id: id === "" ? undefined : id?.toString(),
});
setOpen(false);
}, [id, replace]);
let error: string | undefined = undefined;
if (id) {
if (!id.startsWith("waitpoint_")) {
error = "Waitpoint IDs start with 'waitpoint_'";
} else if (id.length !== 35) {
error = "Waitpoint IDs are 35 characters long";
}
}
function WaitpointIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Waitpoint ID</Label>
<Input
placeholder="run_"
value={id ?? ""}
onChange={(e) => setId(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !id}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Waitpoint ID"
placeholder="waitpoint_"
paramKey="id"
validate={(v) => {
if (!v.startsWith("waitpoint_")) return "Waitpoint IDs start with 'waitpoint_'";
if (v.length !== 35) return "Waitpoint IDs are 35 characters long";
return undefined;
}}
/>
);
}
function AppliedWaitpointIdFilter() {
const waitpointIdShortcut = { key: "w" };
function PermanentWaitpointIdFilter() {
const { value, del } = useSearchParams();
if (value("id") === undefined) {
return null;
}
const id = value("id");
const hasId = id !== undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: waitpointIdShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<WaitpointIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="ID"
icon={<FingerPrintIcon className="size-3.5" />}
value={id}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasId ? (
<AppliedFilter
label="ID"
icon={<FingerPrintIcon className="size-3.5" />}
value={id}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<FingerPrintIcon className="size-4" />
<span>Waitpoint ID</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by waitpoint ID</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={waitpointIdShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
@@ -544,115 +483,81 @@ function AppliedWaitpointIdFilter() {
);
}
function IdempotencyKeyDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const idValue = value("idempotencyKey");
const [idempotencyKey, setIdempotencyKey] = useState(idValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
idempotencyKey: idempotencyKey === "" ? undefined : idempotencyKey?.toString(),
});
setOpen(false);
}, [idempotencyKey, replace]);
let error: string | undefined = undefined;
if (idempotencyKey) {
if (idempotencyKey.length === 0) {
error = "Idempotency keys need to be at least 1 character in length";
}
}
function IdempotencyKeyDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Idempotency key</Label>
<Input
placeholder=""
value={idempotencyKey ?? ""}
onChange={(e) => setIdempotencyKey(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !idempotencyKey}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
<IdFilterDropdown
{...props}
label="Idempotency key"
placeholder=""
paramKey="idempotencyKey"
validate={(v) => {
if (v.length === 0) return "Idempotency keys need to be at least 1 character in length";
return undefined;
}}
/>
);
}
function AppliedIdempotencyKeyFilter() {
const idempotencyKeyShortcut = { key: "i" };
function PermanentIdempotencyKeyFilter() {
const { value, del } = useSearchParams();
if (value("idempotencyKey") === undefined) {
return null;
}
const idempotencyKey = value("idempotencyKey");
const hasKey = idempotencyKey !== undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: idempotencyKeyShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<IdempotencyKeyDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Idempotency key"
icon={<ListChecks className="size-3.5" />}
value={idempotencyKey}
onRemove={() => del(["idempotencyKey", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasKey ? (
<AppliedFilter
label="Idempotency key"
icon={<ListChecks className="size-3.5" />}
value={idempotencyKey}
onRemove={() => del(["idempotencyKey", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-charcoal-600 bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-charcoal-550 group-hover:bg-charcoal-600">
<ListChecks className="size-4" />
<span>Idempotency key</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by idempotency key</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={idempotencyKeyShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
+9 -5
View File
@@ -10,8 +10,9 @@ import { matchSorter } from "match-sorter";
* @param params.items - Array of objects to filter
* @param params.keys - Array of object keys to perform the fuzzy search on (supports dot-notation for nested properties)
* @returns An object containing:
* - filterText: The current filter text
* - setFilterText: Function to update the filter text
* - filterText: The current filter text (the controlled value if provided, otherwise the internal state)
* - setFilterText: Updates the internal filter text. No-op when `filterText` is provided
* (controlled mode) — the parent owns the value in that case.
* - filteredItems: The filtered array of items based on the current filter text
*
* @example
@@ -26,11 +27,15 @@ import { matchSorter } from "match-sorter";
export function useFuzzyFilter<T extends Object>({
items,
keys,
filterText: controlledFilterText,
}: {
items: T[];
keys: (Extract<keyof T, string> | (string & {}))[];
/** Optional controlled filter text. If provided, internal state is ignored. */
filterText?: string;
}) {
const [filterText, setFilterText] = useState("");
const [internalFilterText, setInternalFilterText] = useState("");
const filterText = controlledFilterText ?? internalFilterText;
const filteredItems = useMemo<T[]>(() => {
const filterTerms = filterText
@@ -43,7 +48,6 @@ export function useFuzzyFilter<T extends Object>({
return items;
}
// sort by the score of the first term
return filterTerms.reduceRight(
(results, term) =>
matchSorter(results, term, {
@@ -55,7 +59,7 @@ export function useFuzzyFilter<T extends Object>({
return {
filterText,
setFilterText,
setFilterText: setInternalFilterText,
filteredItems,
};
}
@@ -216,7 +216,7 @@ const overviewDashboard: BuiltInDashboard = {
const llmDashboard: BuiltInDashboard = {
key: "llm",
title: "AI Metrics",
title: "AI metrics",
filters: ["tasks", "models", "prompts", "operations", "providers"],
layout: {
version: "1",
@@ -5,8 +5,6 @@ import {
ChevronUpIcon,
ExclamationTriangleIcon,
LightBulbIcon,
MagnifyingGlassIcon,
XMarkIcon,
UserPlusIcon,
VideoCameraIcon,
} from "@heroicons/react/20/solid";
@@ -38,7 +36,6 @@ import { Callout } from "~/components/primitives/Callout";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
@@ -50,6 +47,7 @@ import {
ResizablePanelGroup,
collapsibleHandleClassName,
} from "~/components/primitives/Resizable";
import { SearchInput } from "~/components/primitives/SearchInput";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import {
@@ -75,6 +73,7 @@ import { useEventSource } from "~/hooks/useEventSource";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
@@ -88,7 +87,6 @@ import {
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import { requireUserId } from "~/services/session.server";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
import {
docsPath,
@@ -176,9 +174,11 @@ export default function Page() {
const environment = useEnvironment();
const { tasks, activity, runningStats, durations, usefulLinksPreference } =
useTypedLoaderData<typeof loader>();
const { filterText, setFilterText, filteredItems } = useFuzzyFilter<TaskListItem>({
const { value } = useSearchParams();
const { filteredItems } = useFuzzyFilter<TaskListItem>({
items: tasks,
keys: ["slug", "filePath", "triggerSource"],
filterText: value("search") ?? "",
});
const hasTasks = tasks.length > 0;
@@ -244,16 +244,12 @@ export default function Page() {
{tasks.length === 0 ? <UserHasNoTasks /> : null}
<div className="max-h-full overflow-hidden">
<div className="flex items-center justify-between gap-1 p-2">
<AnimatedSearchField
value={filterText}
onChange={setFilterText}
placeholder="Search tasks…"
autoFocus
/>
{!showUsefulLinks && (
<SearchInput placeholder="Search tasks…" autoFocus />
{!showUsefulLinks && (
<Button
variant="secondary/small"
TrailingIcon={LightBulbIcon}
trailingIconClassName="text-text-dimmed"
onClick={() => toggleUsefulLinks(true)}
className="px-2.5"
/>
@@ -443,9 +439,7 @@ export default function Page() {
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 400 }}>
{hasTasks && (
<HelpfulInfoHasTasks onClose={() => toggleUsefulLinks(false)} />
)}
{hasTasks && <HelpfulInfoHasTasks onClose={() => toggleUsefulLinks(false)} />}
</div>
</ResizablePanel>
</ResizablePanelGroup>
@@ -868,53 +862,3 @@ function FailedToLoadStats() {
);
}
function AnimatedSearchField({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
autoFocus?: boolean;
}) {
const [isFocused, setIsFocused] = useState(false);
return (
<motion.div
initial={{ width: "auto" }}
animate={{ width: isFocused && value.length > 0 ? "24rem" : "auto" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="relative h-6 min-w-52"
>
<Input
type="text"
variant="secondary-small"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
fullWidth
autoFocus={autoFocus}
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(e) => {
if (e.key === "Escape") e.currentTarget.blur();
}}
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
value.length > 0 ? (
<button
type="button"
onClick={() => onChange("")}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
>
<XMarkIcon className="size-3" />
</button>
) : undefined
}
/>
</motion.div>
);
}
@@ -30,6 +30,7 @@ import {
TableHeader,
TableHeaderCell,
TableRow,
CopyableTableCell,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
@@ -234,9 +235,9 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
return (
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
<TableCell to={inspectorPath} isTabbableCell>
<CopyableTableCell value={batch.friendlyId} to={inspectorPath} isTabbableCell>
{batch.friendlyId}
</TableCell>
</CopyableTableCell>
<TableCell to={inspectorPath}>
{batch.batchVersion === "v1" ? (
@@ -286,7 +287,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
{isLoading && (
<TableBlankRow
colSpan={8}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
</TableBlankRow>
@@ -132,10 +132,7 @@ const PurchaseSchema = z.discriminatedUnion("action", [
}),
z.object({
action: z.literal("quota-increase"),
amount: z.coerce
.number()
.int("Must be a whole number")
.min(1, "Amount must be greater than 0"),
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
@@ -329,23 +326,16 @@ export default function Page() {
</MainCenteredContainer>
) : (
<>
<div className="flex items-center justify-between gap-x-2 p-2">
<div className="flex items-center justify-between gap-x-1.5 p-2">
<BranchFilters />
<div className="flex items-center justify-end gap-x-2">
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
<div
className={cn(
"grid max-h-full min-h-full overflow-x-auto",
totalPages > 1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]"
)}
>
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@@ -438,14 +428,6 @@ export default function Page() {
)}
</TableBody>
</Table>
<div
className={cn(
"flex min-h-full",
totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3"
)}
>
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
<div className="flex w-full items-start justify-between">
@@ -545,12 +527,12 @@ export function BranchFilters() {
return (
<div className="flex w-full items-center justify-between gap-2">
<SearchInput placeholder="Search branch name" resetParams={["page"]} />
<SearchInput placeholder="Search branch name" resetParams={["page"]} />
<Switch
checked={showArchived ?? false}
onCheckedChange={handleArchivedChange}
label="Show archived"
variant="small"
variant="secondary/small"
/>
</div>
);
@@ -673,7 +655,13 @@ function PurchaseBranchesModal({
const [open, setOpen] = useState(false);
useEffect(() => {
const data = fetcher.data;
if (fetcher.state === "idle" && data !== null && typeof data === "object" && "ok" in data && data.ok) {
if (
fetcher.state === "idle" &&
data !== null &&
typeof data === "object" &&
"ok" in data &&
data.ok
) {
setOpen(false);
}
}, [fetcher.state, fetcher.data]);
@@ -1,6 +1,8 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactGridLayout from "react-grid-layout";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -15,7 +17,8 @@ import { QueuesFilter } from "~/components/metrics/QueuesFilter";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { TitleWidget } from "~/components/metrics/TitleWidget";
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Button } from "~/components/primitives/Buttons";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -143,13 +146,6 @@ export default function Page() {
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>
<CreateDashboardPageButton
organization={organization}
project={project}
environment={environment}
/>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="h-full">
@@ -165,6 +161,14 @@ export default function Page() {
possiblePrompts={possiblePrompts}
possibleOperations={possibleOperations}
possibleProviders={possibleProviders}
filterAccessories={
<CreateDashboardPageButton
organization={organization}
project={project}
environment={environment}
shortcut={{ key: "n" }}
/>
}
/>
</div>
</PageBody>
@@ -188,6 +192,7 @@ export function MetricDashboard({
onRenameWidget,
onDeleteWidget,
onDuplicateWidget,
filterAccessories,
}: {
/** The layout items (positions/sizes) - fully controlled from parent */
layout: LayoutItem[];
@@ -212,6 +217,7 @@ export function MetricDashboard({
onRenameWidget?: (widgetId: string, newTitle: string) => void;
onDeleteWidget?: (widgetId: string) => void;
onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void;
filterAccessories?: ReactNode;
}) {
const { value, values } = useSearchParams();
const { width, containerRef, mounted } = useContainerWidth();
@@ -239,6 +245,13 @@ export function MetricDashboard({
const providers = values("providers").filter((v) => v !== "");
const activeFilters = filterConfig ?? ["tasks", "queues"];
const hasAppliedFilters =
tasks.length > 0 ||
queues.length > 0 ||
models.length > 0 ||
prompts.length > 0 ||
operations.length > 0 ||
providers.length > 0;
const handleLayoutChange = useCallback(
(newLayout: readonly LayoutItem[]) => {
@@ -263,31 +276,48 @@ export function MetricDashboard({
return (
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-center gap-1 border-b border-b-grid-bright py-2 pl-2 pr-3">
<ScopeFilter />
{activeFilters.includes("tasks") && (
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
<div className="flex items-center justify-between gap-x-2 border-b border-b-grid-bright py-2 pl-2 pr-3">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<ScopeFilter shortcut={{ key: "s" }} />
{activeFilters.includes("tasks") && (
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
)}
{activeFilters.includes("queues") && <QueuesFilter />}
{activeFilters.includes("models") && (
<ModelsFilter possibleModels={possibleModels ?? []} />
)}
{activeFilters.includes("prompts") && (
<PromptsFilter possiblePrompts={possiblePrompts ?? []} />
)}
{activeFilters.includes("operations") && (
<OperationsFilter possibleOperations={possibleOperations ?? []} />
)}
{activeFilters.includes("providers") && (
<ProvidersFilter possibleProviders={possibleProviders ?? []} />
)}
<TimeFilter
defaultPeriod={defaultPeriod}
labelName="Period"
hideLabel
maxPeriodDays={maxPeriodDays}
valueClassName="text-text-bright"
shortcut={{ key: "d" }}
/>
{hasAppliedFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
{filterAccessories && (
<div className="flex shrink-0 items-center">{filterAccessories}</div>
)}
{activeFilters.includes("queues") && <QueuesFilter />}
{activeFilters.includes("models") && (
<ModelsFilter possibleModels={possibleModels ?? []} />
)}
{activeFilters.includes("prompts") && (
<PromptsFilter possiblePrompts={possiblePrompts ?? []} />
)}
{activeFilters.includes("operations") && (
<OperationsFilter possibleOperations={possibleOperations ?? []} />
)}
{activeFilters.includes("providers") && (
<ProvidersFilter possibleProviders={possibleProviders ?? []} />
)}
<TimeFilter
defaultPeriod={defaultPeriod}
labelName="Period"
hideLabel
maxPeriodDays={maxPeriodDays}
valueClassName="text-text-bright"
/>
</div>
<div
ref={containerRef}
@@ -205,19 +205,22 @@ export default function Page() {
const toast = useToast();
const handleSyncError = useCallback((error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
update: "Failed to update widget",
delete: "Failed to delete widget",
duplicate: "Failed to duplicate widget",
layout: "Failed to save layout",
};
const handleSyncError = useCallback(
(error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
update: "Failed to update widget",
delete: "Failed to delete widget",
duplicate: "Failed to duplicate widget",
layout: "Failed to save layout",
};
const message = actionMessages[action] || "Failed to save changes";
const message = actionMessages[action] || "Failed to save changes";
toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" });
}, [toast]);
toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" });
},
[toast]
);
// Add title dialog state
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
@@ -349,88 +352,99 @@ export default function Page() {
})()
: null;
const dashboardMenu = (
<Popover>
<PopoverVerticalEllipseTrigger variant="secondary" />
<PopoverContent className="w-fit min-w-[10rem] p-1" align="end">
<div className="flex flex-col gap-1">
<RenameDashboardDialog title={title} />
<DeleteDashboardDialog title={title} />
</div>
</PopoverContent>
</Popover>
);
const filterAccessories = (
<div className="flex items-center gap-x-1.5">
{widgetIsAtLimit ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon} shortcut={{ key: "c" }}>
Add chart
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
tooltip="Add section title"
shortcut={{ key: "h" }}
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
) : (
<>
<Button
variant="primary/small"
LeadingIcon={IconChartHistogram}
className="pl-1.5"
iconSpacing="gap-x-1.5"
shortcut={{ key: "c" }}
onClick={actions.openAddEditor}
>
Add chart
</Button>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
tooltip="Add section title"
shortcut={{ key: "h" }}
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
)}
{dashboardMenu}
</div>
);
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>
{totalWidgetCount > 0 &&
(widgetIsAtLimit ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Add chart
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this
dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
) : (
<>
<Button
variant="primary/small"
LeadingIcon={IconChartHistogram}
className="pl-1.5"
iconSpacing="gap-x-1.5"
onClick={actions.openAddEditor}
>
Add chart
</Button>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
))}
<Popover>
<PopoverVerticalEllipseTrigger variant="secondary" />
<PopoverContent className="w-fit min-w-[10rem] p-1" align="end">
<div className="flex flex-col gap-1">
<RenameDashboardDialog title={title} />
<DeleteDashboardDialog title={title} />
</div>
</PopoverContent>
</Popover>
</PageAccessories>
<PageAccessories>{totalWidgetCount === 0 && dashboardMenu}</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="flex h-full flex-col">
@@ -471,6 +485,7 @@ export default function Page() {
onRenameWidget={actions.renameWidget}
onDeleteWidget={actions.deleteWidget}
onDuplicateWidget={actions.duplicateWidget}
filterAccessories={filterAccessories}
/>
)}
</div>
@@ -4,17 +4,20 @@ import {
BookOpenIcon,
InformationCircleIcon,
LockClosedIcon,
MagnifyingGlassIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation, useRevalidator } from "@remix-run/react";
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
json,
} from "@remix-run/server-runtime";
Form,
type MetaFunction,
Outlet,
useActionData,
useFetcher,
useNavigation,
useRevalidator,
} from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useEffect, useMemo, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -30,9 +33,9 @@ import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { SearchInput } from "~/components/primitives/SearchInput";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -50,6 +53,7 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -76,7 +80,11 @@ import { UserAvatar } from "~/components/UserProfilePhoto";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import { fromPromise } from "neverthrow";
import { logger } from "~/services/logger.server";
import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema";
import {
shouldSyncEnvVar,
isPullEnvVarsEnabledForEnvironment,
type TriggerEnvironmentType,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
export const meta: MetaFunction = () => {
return [
@@ -92,10 +100,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({
userId,
projectSlug: projectParam,
});
const { environmentVariables, environments, hasStaging, vercelIntegration } =
await presenter.call({
userId,
projectSlug: projectParam,
});
return typedjson({
environmentVariables,
@@ -123,7 +132,9 @@ const schema = z.discriminatedUnion("action", [
action: z.literal("update-vercel-sync"),
key: z.string(),
environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]),
syncEnabled: z.union([z.literal("true"), z.literal("false")]).transform((val) => val === "true"),
syncEnabled: z
.union([z.literal("true"), z.literal("false")])
.transform((val) => val === "true"),
}),
]);
@@ -249,15 +260,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, environments, vercelIntegration } = useTypedLoaderData<typeof loader>();
const { environmentVariables, environments, vercelIntegration } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { filterText, setFilterText, filteredItems } =
useFuzzyFilter<EnvironmentVariableWithSetValues>({
items: environmentVariables,
keys: ["key", "value", "environment.type", "environment.branchName"],
});
const { value } = useSearchParams();
const urlSearch = value("search") ?? "";
const { setFilterText, filteredItems } = useFuzzyFilter<EnvironmentVariableWithSetValues>({
items: environmentVariables,
keys: ["key", "value", "environment.type", "environment.branchName"],
});
useEffect(() => {
setFilterText(urlSearch);
}, [urlSearch, setFilterText]);
// Add isFirst and isLast to each environment variable
// They're set based on if they're the first or last time that `key` has been seen in the list
@@ -314,18 +331,10 @@ export default function Page() {
<div className={cn("flex h-full flex-col")}>
{environmentVariables.length > 0 && (
<div className="flex items-center justify-between gap-2 px-2 py-2">
<Input
placeholder="Search variables"
variant="tertiary"
icon={MagnifyingGlassIcon}
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
<div className="flex items-center justify-end gap-2">
<SearchInput placeholder="Search variables…" autoFocus />
<div className="flex items-center justify-end gap-1.5">
<Switch
variant="small"
variant="secondary/small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
@@ -351,7 +360,16 @@ export default function Page() {
Value
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
Environment
<SimpleTooltip
button={
<span className="flex items-center gap-1">
Environment
<InformationCircleIcon className="size-4 text-text-dimmed" />
</span>
}
content="Dev environment variables specified here will be overridden by ones in your .env file when running locally."
className="max-w-60"
/>
</TableHeaderCell>
{vercelIntegration?.enabled && (
<TableHeaderCell className="w-[8%]">
@@ -458,10 +476,11 @@ export default function Page() {
/>
<span className="text-sm">{variable.updatedByUser.name}</span>
</div>
) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? (
) : variable.lastUpdatedBy?.type === "integration" &&
variable.lastUpdatedBy?.integration === "vercel" ? (
<div className="flex items-center gap-2">
<VercelLogo className="size-4 text-text-dimmed group-hover/table-row:text-text-bright transition-colors" />
<span className="text-sm text-text-dimmed group-hover/table-row:text-text-bright capitalize transition-colors">
<VercelLogo className="size-4 text-text-dimmed transition-colors group-hover/table-row:text-text-bright" />
<span className="text-sm capitalize text-text-dimmed transition-colors group-hover/table-row:text-text-bright">
{variable.lastUpdatedBy.integration}
</span>
</div>
@@ -475,7 +494,7 @@ export default function Page() {
</TableCell>
<TableCellMenu
isSticky
className="w-0 [&:has(.group-hover/table-row:block)]:w-auto"
className="[&:has(.group-hover/table-row:block)]:w-auto w-0"
hiddenButtons={
<>
<EditEnvironmentVariablePanel
@@ -514,13 +533,6 @@ export default function Page() {
)}
</TableBody>
</Table>
<div className="-mt-px w-full border-t border-grid-dimmed">
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
Dev environment variables specified here will be overridden by ones in your .env file
when running locally.
</InfoPanel>
</div>
</div>
<Outlet />
</PageBody>
@@ -561,9 +573,12 @@ function EditEnvironmentVariablePanel({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={PencilSquareIcon} fullWidth textAlignLeft>
</Button>
<Button
variant="small-menu-item"
LeadingIcon={PencilSquareIcon}
fullWidth
textAlignLeft
></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Edit environment variable</DialogHeader>
@@ -715,14 +730,7 @@ function VercelSyncCheckbox({
if (!pullEnvVarsEnabledForEnv) {
return (
<SimpleTooltip
button={
<Switch
variant="small"
checked={false}
disabled
onCheckedChange={() => {}}
/>
}
button={<Switch variant="small" checked={false} disabled onCheckedChange={() => {}} />}
content="Enable 'Pull env vars before build' for this environment in Vercel settings."
/>
);
@@ -290,6 +290,8 @@ const errorStatusOptions = [
const statusIcon = <IconBugFilled className="size-4" />;
const statusShortcut = { key: "s" };
const timeShortcut = { key: "d" };
const alertsShortcut = { key: "c" };
function StatusFilter() {
const { values, del } = useSearchParams();
@@ -306,8 +308,9 @@ function StatusFilter() {
variant="secondary/small"
shortcut={statusShortcut}
tooltipTitle="Filter by status"
className="pl-1.5"
>
<span className="ml-0.5">Status</span>
<span className="ml-1">Status</span>
</SelectTrigger>
}
searchValue={search}
@@ -416,9 +419,10 @@ function FiltersBar({
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-2">
<div className="flex flex-row flex-wrap items-center gap-1.5">
{list ? (
<>
<SearchInput placeholder="Search errors…" />
<StatusFilter />
<LogsTaskFilter possibleTasks={list.filters.possibleTasks} />
<LogsVersionFilter />
@@ -426,43 +430,53 @@ function FiltersBar({
defaultPeriod={defaultPeriod}
maxPeriodDays={retentionLimitDays}
labelName="Occurred"
shortcut={timeShortcut}
/>
<SearchInput placeholder="Search errors…" />
{hasFilters && (
<Form className="h-6">
<Form className="-ml-1 h-6">
<Button
variant="secondary/small"
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</>
) : (
<>
<SearchInput placeholder="Search errors…" />
<StatusFilter />
<LogsTaskFilter possibleTasks={[]} />
<LogsVersionFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<SearchInput placeholder="Search errors…" />
<TimeFilter
defaultPeriod={defaultPeriod}
maxPeriodDays={retentionLimitDays}
shortcut={timeShortcut}
/>
{hasFilters && (
<Form className="h-6">
<Form className="-ml-1 h-6">
<Button
variant="secondary/small"
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-1.5">
<LinkButton
to={alertsHref}
variant="secondary/small"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-alerts"
shortcut={alertsShortcut}
tooltip="Configure alerts"
>
Configure alerts
</LinkButton>
@@ -260,20 +260,22 @@ function FiltersBar({
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
<div className="flex flex-row flex-wrap items-center gap-1.5">
{list ? (
<>
<SearchInput />
<LogsTaskFilter possibleTasks={list.possibleTasks} />
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<LogsLevelFilter />
<SearchInput />
{hasFilters && (
<Form className="h-6">
<Form className="-ml-1 h-6">
<Button
variant="secondary/small"
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
@@ -286,11 +288,13 @@ function FiltersBar({
<LogsLevelFilter />
<SearchInput />
{hasFilters && (
<Form className="h-6">
<Form className="-ml-1 h-6">
<Button
variant="secondary/small"
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
@@ -8,7 +8,7 @@ import * as Ariakit from "@ariakit/react";
import { Form, type MetaFunction, useFetcher } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import {
AnthropicIcon,
@@ -44,6 +44,7 @@ import {
useFrozenValue,
} from "~/components/primitives/Resizable";
import { SearchInput } from "~/components/primitives/SearchInput";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Switch } from "~/components/primitives/Switch";
import {
SelectProvider,
@@ -62,6 +63,7 @@ import {
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
@@ -90,6 +92,7 @@ import { MetricWidget } from "~/routes/resources.metric";
import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
import { type loader as compareLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.compare/route";
import { IconColumns3 } from "@tabler/icons-react";
export const meta: MetaFunction = () => {
return [{ title: "Models | Trigger.dev" }];
@@ -147,32 +150,59 @@ const providerIcons: Record<string, (props: { className?: string }) => JSX.Eleme
function providerIcon(slug: string) {
const Icon = providerIcons[slug] ?? CubeIcon;
return <Icon className="size-4" />;
return <Icon className="size-4 text-text-dimmed" />;
}
// --- Filter Components ---
const providerShortcut = { key: "p" };
function ProviderFilter({ providers }: { providers: string[] }) {
const { values, replace, del } = useSearchParams();
const selected = values("providers");
const hasFilter = selected.length > 0;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: providerShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<SelectProvider value={selected} setValue={(v) => replace({ providers: v })}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
icon={<CubeIcon className="size-4" />}
label={hasFilter ? "Provider" : undefined}
value={hasFilter ? appliedSummary(selected.map(formatProviderName))! : "Provider"}
valueClassName={hasFilter ? undefined : "text-text-bright"}
removable={hasFilter}
onRemove={() => del("providers")}
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
icon={<CubeIcon className="size-4" />}
label={hasFilter ? "Provider" : undefined}
value={hasFilter ? appliedSummary(selected.map(formatProviderName))! : "Provider"}
valueClassName={hasFilter ? undefined : "text-text-bright"}
removable={hasFilter}
onRemove={() => del("providers")}
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright py-1.5 pl-2.5 pr-3 text-xs text-text-dimmed">
<div className="flex items-center gap-3">
<span>Filter by provider</span>
<ShortcutKey className="size-4 flex-none" shortcut={providerShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
<SelectPopover>
<SelectList>
{providers.map((p) => (
<SelectItem key={p} value={p} icon={providerIcon(p)}>
<SelectItem key={p} value={p} icon={providerIcon(p)} className="text-text-bright">
{formatProviderName(p)}
</SelectItem>
))}
@@ -182,27 +212,54 @@ function ProviderFilter({ providers }: { providers: string[] }) {
);
}
const featuresShortcut = { key: "f" };
function FeaturesFilter({ features }: { features: string[] }) {
const { values, replace, del } = useSearchParams();
const selected = values("features");
const hasFilter = selected.length > 0;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: featuresShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<SelectProvider value={selected} setValue={(v) => replace({ features: v })}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
icon={<AdjustmentsHorizontalIcon className="size-4" />}
label={hasFilter ? "Features" : undefined}
value={hasFilter ? appliedSummary(selected.map(formatFeature))! : "Features"}
valueClassName={hasFilter ? undefined : "text-text-bright"}
removable={hasFilter}
onRemove={() => del("features")}
/>
</Ariakit.Select>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
icon={<AdjustmentsHorizontalIcon className="size-4" />}
label={hasFilter ? "Features" : undefined}
value={hasFilter ? appliedSummary(selected.map(formatFeature))! : "Features"}
valueClassName={hasFilter ? undefined : "text-text-bright"}
removable={hasFilter}
onRemove={() => del("features")}
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright py-1.5 pl-2.5 pr-3 text-xs text-text-dimmed">
<div className="flex items-center gap-3">
<span>Filter by features</span>
<ShortcutKey className="size-4 flex-none" shortcut={featuresShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
<SelectPopover>
<SelectList>
{features.map((f) => (
<SelectItem key={f} value={f}>
<SelectItem key={f} value={f} className="text-text-bright">
{formatFeature(f)}
</SelectItem>
))}
@@ -235,26 +292,59 @@ function FiltersBar({
searchParams.has("providers") || searchParams.has("features") || searchParams.has("search");
const compareDisabled = compareSet.size < 2;
const compareShortcut = { key: "c" };
const detailsShortcut = { key: "d" };
useShortcutKeys({
shortcut: compareShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
onCompare();
},
disabled: compareDisabled,
});
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-2">
<div className="flex flex-row flex-wrap items-center gap-1.5">
<SearchInput placeholder="Search models…" />
<ProviderFilter providers={allProviders} />
<FeaturesFilter features={allFeatures} />
<SearchInput placeholder="Search models…" />
{hasFilters && (
<Form className="-ml-1 h-6">
<Button variant="minimal/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
<Button
variant="secondary/small"
disabled={compareDisabled}
className="px-1.5"
tooltip={compareDisabled ? "Choose 24 models to compare" : "Compare selected models"}
className="pl-1 pr-1.5"
tooltip={
compareDisabled ? (
<span className="text-text-dimmed">Choose 24 models to compare</span>
) : (
<span className="flex items-center gap-3 text-text-dimmed">
Compare selected models
<ShortcutKey
className="size-4 flex-none"
shortcut={compareShortcut}
variant="small"
/>
</span>
)
}
onClick={compareDisabled ? undefined : onCompare}
LeadingIcon={IconColumns3}
leadingIconClassName="-mr-2"
>
<span className="flex items-center overflow-hidden">
<span className={!compareDisabled ? "text-text-bright" : undefined}>Compare</span>
@@ -274,12 +364,27 @@ function FiltersBar({
</AnimatePresence>
</span>
</Button>
<Switch
variant="secondary/small"
label="All details"
checked={showAllDetails}
onCheckedChange={onToggleAllDetails}
/>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor render={<div />}>
<Switch
variant="secondary/small"
label="All details"
checked={showAllDetails}
onCheckedChange={onToggleAllDetails}
shortcut={detailsShortcut}
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright py-1.5 pl-2.5 pr-3 text-xs text-text-dimmed">
<div className="flex items-center gap-3">
<span>Toggle all details</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={detailsShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
</div>
</div>
);
@@ -585,13 +690,13 @@ function CompareDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!w-fit !px-0 !pb-0 sm:!max-w-[90vw]">
<DialogHeader className="px-4">
<DialogContent className="!w-fit gap-[0.4375rem] !px-0 !pb-0 !pt-0 sm:!max-w-[90vw]">
<DialogHeader className="h-11 justify-center px-4">
<DialogTitle>Compare models</DialogTitle>
</DialogHeader>
{rows.length > 0 ? (
<div className="-mt-[0.375rem] max-h-[70vh] overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 [&_tbody_tr:last-child]:after:hidden">
<Table>
<Table stickyHeader showTopBorder={false}>
<TableHeader>
<TableRow>
<TableHeaderCell>Metric</TableHeaderCell>
@@ -1116,7 +1221,7 @@ export default function ModelsPage() {
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="models-main" min="100px">
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<FiltersBar
allProviders={allProviders}
allFeatures={allFeatures}
@@ -5,7 +5,6 @@ import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const SQL_KEYWORDS = [
@@ -97,8 +96,9 @@ export function QueryHistoryPopover({
variant="secondary/small"
LeadingIcon={ClockRotateLeftIcon}
leadingIconClassName="-mr-1.5"
TrailingIcon={ChevronUpDownIcon}
disabled={history.length === 0}
tooltip="Query history"
shortcut={{ key: "h" }}
>
History
</Button>
@@ -3,7 +3,6 @@ import {
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
MagnifyingGlassIcon,
PauseIcon,
PlayIcon,
RectangleStackIcon,
@@ -32,6 +31,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { SearchInput } from "~/components/primitives/SearchInput";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -59,7 +59,6 @@ import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useThrottle } from "~/hooks/useThrottle";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
@@ -436,13 +435,8 @@ export default function Page() {
</div>
{success ? (
<div
className={cn(
"grid max-h-full min-h-full grid-rows-[auto_1fr] overflow-x-auto",
pagination.totalPages > 1 && "grid-rows-[auto_1fr_auto]"
)}
>
<div className="flex items-center gap-2 border-t border-grid-dimmed px-1.5 py-1.5">
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr] overflow-x-auto">
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-1.5 py-1.5">
<QueueFilters />
<PaginationControls
currentPage={pagination.currentPage}
@@ -680,27 +674,6 @@ export default function Page() {
</TableBody>
</Table>
{pagination.totalPages > 1 && (
<div
className={cn(
"grid h-fit max-h-full min-h-full overflow-x-auto",
pagination.totalPages > 1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]"
)}
>
<div
className={cn(
"flex min-h-full",
pagination.totalPages > 1 &&
"justify-end border-t border-grid-dimmed px-2 py-3"
)}
>
<PaginationControls
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
/>
</div>
</div>
)}
</div>
) : (
<div className="grid place-items-center py-6 text-text-dimmed">
@@ -1074,39 +1047,7 @@ export function isEnvironmentPauseResumeFormSubmission(
}
export function QueueFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const handleSearchChange = useThrottle((value: string) => {
if (value) {
setSearchParams((prev) => {
prev.set("query", value);
prev.delete("page");
return prev;
});
} else {
setSearchParams((prev) => {
prev.delete("query");
prev.delete("page");
return prev;
});
}
}, 300);
const search = searchParams.get("query") ?? "";
return (
<div className="flex grow">
<Input
name="search"
placeholder="Search queue name"
icon={MagnifyingGlassIcon}
variant="tertiary"
className="grow"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>
);
return <SearchInput placeholder="Search queues…" paramName="query" resetParams={["page"]} />;
}
function BurstFactorTooltip({
@@ -94,8 +94,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
...filters,
});
const session = await setRootOnlyFilterPreference(filters.rootOnly, request);
const cookieValue = await uiPreferencesStorage.commitSession(session);
// Only persist rootOnly when no tasks are filtered. While a task filter is active,
// the toggle's URL value can be a temporary auto-flip (or a user override scoped to
// the current task filter), and we don't want either bleeding into the saved
// session preference. Clearing the task filter restores the saved preference.
const shouldPersistRootOnly = !filters.tasks || filters.tasks.length === 0;
const headers = shouldPersistRootOnly
? {
"Set-Cookie": await uiPreferencesStorage.commitSession(
await setRootOnlyFilterPreference(filters.rootOnly, request)
),
}
: undefined;
return typeddefer(
{
@@ -103,11 +113,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
rootOnlyDefault: filters.rootOnly,
filters,
},
{
headers: {
"Set-Cookie": cookieValue,
},
}
headers ? { headers } : undefined
);
};
@@ -151,50 +151,6 @@ export default function Page() {
>
Schedules docs
</LinkButton>
{limits.used >= limits.limit ? (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-background-dimmed"
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<DialogDescription>
You've used {limits.used}/{limits.limit} of your schedules.
</DialogDescription>
<DialogFooter>
{canUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<LinkButton
LeadingIcon={PlusIcon}
to={`${v3NewSchedulePath(organization, project, environment)}${location.search}`}
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</LinkButton>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
@@ -219,6 +175,54 @@ export default function Page() {
totalPages={totalPages}
showPageNumbers={false}
/>
{limits.used >= limits.limit ? (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-background-dimmed"
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<DialogDescription>
You've used {limits.used}/{limits.limit} of your schedules.
</DialogDescription>
<DialogFooter>
{canUpgrade ? (
<LinkButton
variant="primary/small"
to={v3BillingPath(organization)}
>
Upgrade
</LinkButton>
) : (
<Feedback
button={
<Button variant="primary/small">Request more</Button>
}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<LinkButton
LeadingIcon={PlusIcon}
to={`${v3NewSchedulePath(organization, project, environment)}${location.search}`}
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</LinkButton>
)}
</div>
</div>
@@ -416,58 +420,59 @@ function SchedulesTable({
}`;
const isSelected = scheduleParam === schedule.friendlyId;
const cellClass = schedule.active ? "" : "opacity-50";
const selectedActionClass = isSelected ? "text-text-bright" : undefined;
return (
<TableRow key={schedule.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
<TableCell to={path} isTabbableCell className={cellClass}>
<TableCell to={path} isTabbableCell className={cellClass} actionClassName={selectedActionClass}>
{schedule.friendlyId}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.taskIdentifier}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
<ScheduleTypeCombo type={schedule.type} />
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.type === "IMPERATIVE"
? schedule.externalId
? schedule.externalId
: ""
: "N/A"}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.cron}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.cronDescription}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.timezone}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
<DateTime date={schedule.nextRun} timeZone={schedule.timezone} />
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.lastRun ? (
<DateTime date={schedule.lastRun} timeZone={schedule.timezone} />
) : (
""
)}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
{schedule.type === "IMPERATIVE"
? schedule.userProvidedDeduplicationKey
? schedule.deduplicationKey
: ""
: "N/A"}
</TableCell>
<TableCell to={path} className={cellClass}>
<TableCell to={path} className={cellClass} actionClassName={selectedActionClass}>
<div className="flex items-center gap-3">
{schedule.environments.map((env) => (
<EnvironmentCombo key={env.id} environment={env} className="text-xs" />
))}
</div>
</TableCell>
<TableCell to={path}>
<TableCell to={path} actionClassName={selectedActionClass}>
{schedule.type === "IMPERATIVE" ? (
<EnabledStatus enabled={schedule.active} />
) : (
@@ -138,8 +138,9 @@ function TaskSelector({
<div className="p-2">
<Input
placeholder="Search tasks"
variant="small"
variant="secondary-small"
icon={MagnifyingGlassIcon}
iconClassName="text-text-bright"
fullWidth={true}
value={filterText}
autoFocus
@@ -234,11 +234,6 @@ export default function Page() {
</TableBody>
</Table>
{(pagination.next || pagination.previous) && (
<div className="flex justify-end border-t border-grid-dimmed px-2 py-3">
<ListPagination list={{ pagination }} />
</div>
)}
</div>
</div>
</ResizablePanel>
@@ -403,7 +403,7 @@ export function UpsertScheduleForm({
<div className="flex items-center gap-4">
<LinkButton
to={`${v3SchedulesPath(organization, project, environment)}${location.search}`}
variant="tertiary/medium"
variant="secondary/medium"
>
Cancel
</LinkButton>
@@ -295,8 +295,9 @@ export async function convertRunListInputOptionsToFilterRunsOptions(
convertedOptions.runId = options.runId.map((r) => RunId.toFriendlyId(r));
}
// Show all runs if we are filtering by batchId or runId
if (options.batchId || options.runId?.length || options.scheduleId || options.tasks?.length) {
// batchId/runId/scheduleId target specific runs, so rootOnly is meaningless and forced off.
// tasks is intentionally excluded so rootOnly can narrow a task filter to root runs only.
if (options.batchId || options.runId?.length || options.scheduleId) {
convertedOptions.rootOnly = false;
}