diff --git a/apps/webapp/app/components/MachineLabelCombo.tsx b/apps/webapp/app/components/MachineLabelCombo.tsx index 3d22ca527d0..485f6094cf0 100644 --- a/apps/webapp/app/components/MachineLabelCombo.tsx +++ b/apps/webapp/app/components/MachineLabelCombo.tsx @@ -31,7 +31,9 @@ export function MachineLabel({ className?: string; }) { return ( - {formatMachinePresetName(preset)} + + {formatMachinePresetName(preset)} + ); } diff --git a/apps/webapp/app/components/logs/LogsLevelFilter.tsx b/apps/webapp/app/components/logs/LogsLevelFilter.tsx index 947bef88fcc..c61da4d3084 100644 --- a/apps/webapp/app/components/logs/LogsLevelFilter.tsx +++ b/apps/webapp/app/components/logs/LogsLevelFilter.tsx @@ -53,7 +53,7 @@ export function LogsLevelFilter() { const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== ""); if (hasLevels) { - return ; + return ; } return ( @@ -64,19 +64,16 @@ export function LogsLevelFilter() { variant="secondary/small" shortcut={shortcut} tooltipTitle="Filter by level" + className="pl-1.5" > - Level + Level } /> ); } -function LevelDropdown({ - trigger, -}: { - trigger: ReactNode; -}) { +function LevelDropdown({ trigger }: { trigger: ReactNode }) { const { values, replace } = useSearchParams(); const handleChange = (values: string[]) => { diff --git a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx index 857e623d7c9..e23c39534a6 100644 --- a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx +++ b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx @@ -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 + Run ID } clearSearchValue={() => setSearch("")} diff --git a/apps/webapp/app/components/logs/LogsTaskFilter.tsx b/apps/webapp/app/components/logs/LogsTaskFilter.tsx index 3e2afdcf798..6c15464cc49 100644 --- a/apps/webapp/app/components/logs/LogsTaskFilter.tsx +++ b/apps/webapp/app/components/logs/LogsTaskFilter.tsx @@ -45,8 +45,9 @@ export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) { variant="secondary/small" shortcut={shortcut} tooltipTitle="Filter by task" + className="pl-1.5" > - Tasks + Tasks } searchValue={search} @@ -133,8 +134,9 @@ function TasksDropdown({ .filter((item) => item.isInLatestDeployment) .map((item) => ( } @@ -149,8 +151,9 @@ function TasksDropdown({ .filter((item) => !item.isInLatestDeployment) .map((item) => ( - Versions + Versions } searchValue={search} diff --git a/apps/webapp/app/components/metrics/ModelsFilter.tsx b/apps/webapp/app/components/metrics/ModelsFilter.tsx index e641f826ae3..9b330834c84 100644 --- a/apps/webapp/app/components/metrics/ModelsFilter.tsx +++ b/apps/webapp/app/components/metrics/ModelsFilter.tsx @@ -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 ; + return ; } const iconName = `tabler-brand-${provider}`; if (tablerIcons.has(iconName)) { return ( - + ); } - return ; + return ; } 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" > - Models + Models } searchValue={search} @@ -147,7 +148,7 @@ function ModelsDropdown({ {filtered.map((m) => ( - + {m.model} ))} diff --git a/apps/webapp/app/components/metrics/OperationsFilter.tsx b/apps/webapp/app/components/metrics/OperationsFilter.tsx index 679332fc3c4..679e73ccb7f 100644 --- a/apps/webapp/app/components/metrics/OperationsFilter.tsx +++ b/apps/webapp/app/components/metrics/OperationsFilter.tsx @@ -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" > - Operations + Operations } searchValue={search} @@ -125,7 +126,7 @@ function OperationsDropdown({ {filtered.map((op) => ( - }> + }> {formatOperation(op)} ))} diff --git a/apps/webapp/app/components/metrics/PromptsFilter.tsx b/apps/webapp/app/components/metrics/PromptsFilter.tsx index a4ad8a00045..09a91f4f1fd 100644 --- a/apps/webapp/app/components/metrics/PromptsFilter.tsx +++ b/apps/webapp/app/components/metrics/PromptsFilter.tsx @@ -34,8 +34,9 @@ export function PromptsFilter({ possiblePrompts }: PromptsFilterProps) { variant="secondary/small" shortcut={shortcut} tooltipTitle="Filter by prompt" + className="pl-1.5" > - Prompts + Prompts } searchValue={search} @@ -113,7 +114,7 @@ function PromptsDropdown({ {filtered.map((slug) => ( - }> + }> {slug} ))} diff --git a/apps/webapp/app/components/metrics/ProvidersFilter.tsx b/apps/webapp/app/components/metrics/ProvidersFilter.tsx index fe018eefb98..d22bec8f70b 100644 --- a/apps/webapp/app/components/metrics/ProvidersFilter.tsx +++ b/apps/webapp/app/components/metrics/ProvidersFilter.tsx @@ -34,8 +34,9 @@ export function ProvidersFilter({ possibleProviders }: ProvidersFilterProps) { variant="secondary/small" shortcut={shortcut} tooltipTitle="Filter by provider" + className="pl-1.5" > - Providers + Providers } searchValue={search} @@ -111,7 +112,7 @@ function ProvidersDropdown({ {filtered.map((provider) => ( - }> + }> {provider} ))} diff --git a/apps/webapp/app/components/metrics/QueuesFilter.tsx b/apps/webapp/app/components/metrics/QueuesFilter.tsx index 87d7a612547..3da71e0c7d0 100644 --- a/apps/webapp/app/components/metrics/QueuesFilter.tsx +++ b/apps/webapp/app/components/metrics/QueuesFilter.tsx @@ -39,6 +39,7 @@ export function QueuesFilter() { variant="secondary/small" shortcut={shortcut} tooltipTitle="Filter by queue" + className="pl-1.5" > Queues @@ -190,6 +191,7 @@ function QueuesDropdown({ diff --git a/apps/webapp/app/components/metrics/ScopeFilter.tsx b/apps/webapp/app/components/metrics/ScopeFilter.tsx index 1bf6b685676..0cdaa4adb32 100644 --- a/apps/webapp/app/components/metrics/ScopeFilter.tsx +++ b/apps/webapp/app/components/metrics/ScopeFilter.tsx @@ -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(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 ( - }> - } - value={} - removable={false} - variant="secondary/small" - /> - + + } + /> + } + > + } + removable={false} + variant="secondary/small" + /> + + {shortcut && ( + +
+ Change scope + +
+
+ )} +
{scopeOptions.map((option) => ( - - + } + > + ))} @@ -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 ; + case "project": + return ; + case "environment": + return ; + 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 {organization.title}; case "project": - return `Project: ${project.name}`; + return {project.name}; case "environment": - return ; + return ; default: return scope; } } + +function ScopeItem({ scope }: { scope: QueryScope }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/DashboardDialogs.tsx b/apps/webapp/app/components/navigation/DashboardDialogs.tsx index f0cdd0406e0..6466038400c 100644 --- a/apps/webapp/app/components/navigation/DashboardDialogs.tsx +++ b/apps/webapp/app/components/navigation/DashboardDialogs.tsx @@ -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 ( - @@ -162,7 +165,6 @@ function CreateDashboardUpgradeDialog({ isFreePlan: boolean; organization: { slug: string }; }) { - if (isFreePlan) { return ( diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index b9065dfdc2d..bee2ccb3d1e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -489,7 +489,7 @@ export function SideMenu({ /> )} {buttonContent} - + {tooltip} {shortcut && renderShortcutKey()} @@ -353,7 +353,11 @@ export const Button = forwardRef( form={props.form} autoFocus={autoFocus} > - + ); @@ -362,13 +366,13 @@ export const Button = forwardRef( - + {buttonElement} - + {props.tooltip} {props.shortcut && !props.hideShortcutKey && ( - + )} diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 3364e48bed2..15a7592c32f 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -67,6 +67,7 @@ const variants = { export type InputProps = React.InputHTMLAttributes & { variant?: keyof typeof variants; icon?: RenderIcon; + iconClassName?: string; accessory?: React.ReactNode; fullWidth?: boolean; containerClassName?: string; @@ -81,6 +82,7 @@ const Input = React.forwardRef( fullWidth = true, variant = "medium", icon, + iconClassName, containerClassName, ...props }, @@ -91,7 +93,7 @@ const Input = React.forwardRef( const variantContainerClassName = variants[variant].container; const inputClassName = variants[variant].input; - const iconClassName = variants[variant].iconSize; + const variantIconClassName = variants[variant].iconSize; return (
( > {icon && (
- +
)} { - 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 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]); + }, [text, replace, del, resetParams, paramName]); - const handleClear = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setText(""); - del(["search", ...resetParams]); - }, - [del, resetParams] - ); + const handleClear = useCallback(() => { + setText(""); + del([paramName, ...resetParams]); + }, [del, resetParams, paramName]); return (
@@ -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={} + icon={} accessory={ text.length > 0 ? (
- + 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" + > + + + } + content={ +
+ Clear field + +
+ } + className="px-2 py-1.5 text-xs" + disableHoverableContent + />
) : undefined } diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index d3e4c866891..d0142b363e4 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -104,6 +104,7 @@ export interface SelectProps 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({ open, setOpen, shortcut, + tooltipTitle, allowItemShortcuts = true, disabled, clearSearchOnSelection = true, @@ -206,6 +208,7 @@ export function Select({ text={text} placeholder={placeholder} shortcut={shortcut} + tooltipTitle={tooltipTitle} disabled={disabled} dropdownIcon={dropdownIcon} {...props} @@ -354,7 +357,7 @@ export function SelectTrigger({ {showTooltip && (
diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 1a30bc82b8a..d69e1201035 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -65,6 +65,7 @@ type TableProps = { children: ReactNode; fullWidth?: boolean; showTopBorder?: boolean; + stickyHeader?: boolean; }; // Add TableContext @@ -79,6 +80,7 @@ export const Table = forwardRef { @@ -86,7 +88,8 @@ export const Table = forwardRef
-
+
{isAdmin && ( + } + content={ +
+ Clear field + +
+ } + className="px-2 py-1.5 text-xs" + disableHoverableContent + /> +
) : undefined } /> diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index e0d417ddec4..9a48b78dcf0 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -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); @@ -72,130 +72,25 @@ export function BatchFilters(props: BatchFiltersProps) { const hasFilters = searchParams.has("statuses") || searchParams.has("id"); return ( -
- - - +
+ + + {hasFilters && ( -
-
); } -const filterTypes = [ - { - name: "statuses", - title: "Status", - icon: ( -
-
-
- ), - }, - { name: "batch", title: "Batch ID", icon: }, -] as const; - -type FilterType = (typeof filterTypes)[number]["name"]; - -const shortcut = { key: "f" }; - -function FilterMenu(props: BatchFiltersProps) { - const [filterType, setFilterType] = useState(); - - const filterTrigger = ( - - -
- } - variant={"secondary/small"} - shortcut={shortcut} - tooltipTitle={"Filter batches"} - > - Filter - - ); - - return ( - setFilterType(undefined)}> - {(search, setSearch) => ( - setSearch("")} - trigger={filterTrigger} - filterType={filterType} - setFilterType={setFilterType} - {...props} - /> - )} - - ); -} - -function AppliedFilters() { - return ( - <> - - - - ); -} - -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 ; - case "statuses": - return props.setFilterType(undefined)} {...props} />; - case "batch": - return 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 ( - - {trigger} - - - - {filtered.map((type, index) => ( - { - clearSearchValue(); - setFilterType(type.name); - }} - icon={type.icon} - shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} - > - {type.title} - - ))} - - - - ); -} - const statuses = allBatchStatuses.map((status) => ({ title: batchStatusTitle(status), value: status, @@ -219,10 +114,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 ( {trigger} @@ -237,9 +128,8 @@ function StatusDropdown({ return true; }} > - - {filtered.map((item, index) => ( + {statuses.map((item, index) => ( 0; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={appliedSummary( - statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus)) + + } + /> + } + > + {hasStatuses ? ( + } + value={appliedSummary( + statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus)) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
)} - onRemove={() => del(["statuses", "cursor", "direction"])} - variant="secondary/small" - /> - + + +
+ Filter by status + +
+
+ } searchValue={search} clearSearchValue={() => setSearch("")} @@ -298,117 +226,83 @@ function AppliedStatusFilter() { ); } -function BatchIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - 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 +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setBatchId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } -function AppliedBatchIdFilter() { - const { value, del } = useSearchParams(); - - if (value("id") === undefined) { - return null; - } +const batchIdShortcut = { key: "b" }; +function PermanentBatchIdFilter() { + const { value, del } = useSearchParams(); const batchId = value("id"); + const hasBatchId = batchId !== undefined; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: batchIdShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={batchId} - onRemove={() => del(["id", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasBatchId ? ( + } + value={batchId} + onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Batch ID +
+ )} +
+ +
+ Filter by batch ID + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 83ebaa0d51b..c02e93a5c6e 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -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 ( -
- +
{!props.hideSearch && } + + + - + {hasFilters && ( -
- {searchParams.has("rootOnly") && ( - - )} -
@@ -375,12 +386,6 @@ export function RunsFilters(props: RunFiltersProps) { } const filterTypes = [ - { - name: "statuses", - title: "Status", - icon: , - }, - { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, { name: "versions", title: "Versions", icon: }, { name: "queues", title: "Queues", icon: }, @@ -403,15 +408,15 @@ function FilterMenu(props: RunFiltersProps) { - +
} variant={"secondary/small"} shortcut={shortcut} - tooltipTitle={"Filter runs"} - className="pr-0.5" + tooltipTitle={"More filters"} + className="pl-1 pr-2" > - <> + More filters ); @@ -431,11 +436,9 @@ function FilterMenu(props: RunFiltersProps) { ); } -function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) { +function AppliedFilters({ bulkActions }: RunFiltersProps) { return ( <> - - @@ -461,10 +464,6 @@ function Menu(props: MenuProps) { switch (props.filterType) { case undefined: return ; - case "statuses": - return props.setFilterType(undefined)} {...props} />; - case "tasks": - return props.setFilterType(undefined)} {...props} />; case "bulk": return 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} + {type.title} ))} @@ -587,28 +586,67 @@ function StatusDropdown({ ); } -function AppliedStatusFilter() { +const statusShortcut = { key: "s" }; + +function PermanentStatusFilter() { const { values, del } = useSearchParams(); const statuses = values("statuses"); - - if (statuses.length === 0 || statuses.every((v) => v === "")) { - return null; - } + const hasStatuses = statuses.length > 0 && !statuses.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - runStatusTitle(v as TaskRunStatus)))} - onRemove={() => del(["statuses", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasStatuses ? ( + runStatusTitle(v as TaskRunStatus)))} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
+ )} + + +
+ Filter by status + +
+
+ } 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) => ( } + className="text-text-bright" > @@ -680,7 +733,7 @@ function TasksDropdown({ .filter((item) => !item.isInLatestDeployment) .map((item) => ( @@ -690,6 +743,7 @@ function TasksDropdown({ /> } + className="text-text-bright" > @@ -702,32 +756,70 @@ function TasksDropdown({ ); } -function AppliedTaskFilter({ possibleTasks }: Pick) { - const { values, del } = useSearchParams(); +const tasksShortcut = { key: "t" }; - if (values("tasks").length === 0 || values("tasks").every((v) => v === "")) { - return null; - } +function PermanentTasksFilter({ possibleTasks }: Pick) { + const { values, del } = useSearchParams(); + const tasks = values("tasks"); + const hasTasks = tasks.length > 0 && !tasks.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: tasksShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - { - const task = possibleTasks.find((task) => task.slug === v); - return task ? task.slug : v; - }) + + } + /> + } + > + {hasTasks ? ( + { + 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" + /> + ) : ( +
+ {filterIcon("tasks")} + Tasks +
)} - onRemove={() => del(["tasks", "cursor", "direction"])} - variant="secondary/small" - /> - +
+ +
+ Filter by task + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} @@ -922,15 +1014,17 @@ function TagsDropdown({ return true; }} > - ( -
- - {fetcher.state === "loading" && } -
- )} - /> + {(filtered.length > 0 || fetcher.state === "loading" || searchValue.length > 0) && ( + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + )} {filtered.length > 0 ? filtered.map((tag, index) => ( @@ -1102,6 +1196,7 @@ function QueuesDropdown({ ) } + className="text-text-bright" > {queue.name} @@ -1187,15 +1282,15 @@ function MachinesDropdown({ return true; }} > - {filtered.map((item, index) => ( - + ))} @@ -1343,7 +1438,12 @@ export function VersionsDropdown({ {filtered.length > 0 ? filtered.map((version) => ( - + } + className="text-text-bright" + > {version.version} {version.isCurrent ? Current : 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 ( - { - replace({ - rootOnly: checked ? "true" : "false", - }); - }} - /> + + }> + { + replace({ + rootOnly: checked ? "true" : "false", + cursor: undefined, + direction: undefined, + }); + }} + /> + + +
+ Toggle root only + +
+
+
); } -function RunIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - 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 ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setRunId(e.target.value)} - variant="small" - className="w-[27ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } @@ -1539,91 +1588,22 @@ function AppliedRunIdFilter() { ); } -function BatchIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - 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 +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setBatchId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } @@ -1659,91 +1639,22 @@ function AppliedBatchIdFilter() { ); } -function ScheduleIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - 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 +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setScheduleId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } @@ -1779,89 +1690,21 @@ function AppliedScheduleIdFilter() { ); } -function ErrorIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - 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 +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setErrorId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } diff --git a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx index 3a30e0cb37e..e0fb819c8d1 100644 --- a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx +++ b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx @@ -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; -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") || searchParams.has("type"); + + return ( +
+ + + + {hasFilters && } +
); +} - const hasFilters = searchParams.has("tasks") || searchParams.has("search"); +function ScheduleSearchInput() { + return ; +} - 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 typeShortcut = { key: "y" }; - const handleTaskChange = useCallback((value: string | typeof All) => { - handleFilterChange("tasks", value === "ALL" ? undefined : value); - }, []); +function PermanentTypeFilter() { + const navigate = useNavigate(); + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const currentType = searchParams.get("type") ?? undefined; + const triggerRef = useRef(null); - const handleTypeChange = useCallback((value: string | typeof All) => { - handleFilterChange("type", value === "ALL" ? undefined : value); - }, []); + useShortcutKeys({ + shortcut: typeShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); - const handleSearchChange = useThrottle((value: string) => { - handleFilterChange("search", value.length === 0 ? undefined : value); - }, 300); + 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 clearFilters = useCallback(() => { - searchParams.delete("page"); - searchParams.delete("enabled"); - searchParams.delete("tasks"); - searchParams.delete("search"); - navigate(`${location.pathname}?${searchParams.toString()}`); - }, []); + const typeLabel = currentType + ? scheduleTypeName(currentType.toUpperCase() as "IMPERATIVE" | "DECLARATIVE") + : "All types"; return ( -
- handleSearchChange(e.target.value)} - /> - - - - - - - - - {hasFilters && ( - + + ))} + + + )} + + ); +} + +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 ( +
+
); } diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 3e24f601f2a..0bdd7c4ac5f 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -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({
Filter by time period - +
)} @@ -1005,3 +1003,102 @@ function QuickDateButton({ ); } + +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(); + 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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + {label} + + setInputValue(e.target.value)} + variant="small" + className={cn(inputWidth, "font-mono")} + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index fbede0e7cec..346fd25eee2 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -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({ Task Version {filterableTaskRunStatuses.map((status) => ( @@ -185,6 +186,7 @@ export function TaskRunsTable({ Started
@@ -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 ( {allowSelection && ( @@ -427,7 +436,11 @@ export function TaskRunsTable({ - {run.isTest ? : "–"} + {run.isTest ? ( + + ) : ( + "–" + )} {run.createdAt ? : "–"} @@ -449,7 +462,7 @@ export function TaskRunsTable({ {isLoading && ( Loading… @@ -592,7 +605,9 @@ function BlankState({ isLoading, filters }: Pick or - + Run a test
diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index ae416394147..3868a496d79 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -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 ( -
- - - +
+ + + + + {hasFilters && ( -
-
); } -const filterTypes = [ - { - name: "statuses", - title: "Status", - icon: , - }, - { name: "tags", title: "Tags", icon: }, - { name: "id", title: "Waitpoint ID", icon: }, - { name: "idempotencyKey", title: "Idempotency key", icon: }, -] as const; - -type FilterType = (typeof filterTypes)[number]["name"]; - -const shortcut = { key: "f" }; - -function FilterMenu() { - const [filterType, setFilterType] = useState(); - - const filterTrigger = ( - - -
- } - variant={"secondary/small"} - shortcut={shortcut} - tooltipTitle={"Filter runs"} - > - Filter - - ); - - return ( - setFilterType(undefined)}> - {(search, setSearch) => ( - setSearch("")} - trigger={filterTrigger} - filterType={filterType} - setFilterType={setFilterType} - /> - )} - - ); -} - -function AppliedFilters() { - return ( - <> - - - - - - ); -} - -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 ; - case "statuses": - return props.setFilterType(undefined)} {...props} />; - case "tags": - return props.setFilterType(undefined)} {...props} />; - case "id": - return props.setFilterType(undefined)} {...props} />; - case "idempotencyKey": - return 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 ( - - {trigger} - - - - {filtered.map((type, index) => ( - { - clearSearchValue(); - setFilterType(type.name); - }} - icon={type.icon} - shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} - > - {type.title} - - ))} - - - - ); -} - const statuses = waitpointTokenStatuses.map((status) => ({ title: waitpointStatusTitle(status), value: status, @@ -237,7 +137,6 @@ function StatusDropdown({ return true; }} > - {filtered.map((item, index) => { return ( @@ -249,7 +148,7 @@ function StatusDropdown({ - + @@ -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(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={appliedSummary( - statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus)) + + } + /> + } + > + {hasStatuses ? ( + } + value={appliedSummary( + selectedStatuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus)) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
)} - onRemove={() => del(["statuses", "cursor", "direction"])} - variant="secondary/small" - /> - + + +
+ Filter by status + +
+
+ } searchValue={search} clearSearchValue={() => setSearch("")} @@ -366,19 +303,21 @@ function TagsDropdown({ return true; }} > - ( -
- - {fetcher.state === "loading" && } -
- )} - /> + {!(filtered.length === 0 && fetcher.state !== "loading" && searchValue === "") && ( + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + )} {filtered.length > 0 - ? filtered.map((tag, index) => ( - + ? filtered.map((tag) => ( + {tag} )) @@ -392,29 +331,64 @@ function TagsDropdown({ ); } -function AppliedTagsFilter() { - const { values, del } = useSearchParams(); +const tagsShortcut = { key: "g" }; +function PermanentTagsFilter() { + const { values, del } = useSearchParams(); const tags = values("tags"); - - if (tags.length === 0) { - return null; - } + const hasTags = tags.length > 0 && !tags.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: tagsShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={appliedSummary(values("tags"))} - onRemove={() => del(["tags", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasTags ? ( + } + value={appliedSummary(tags)} + onRemove={() => del(["tags", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Tags +
+ )} +
+ +
+ Filter by tags + +
+
+
} 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(); - 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 +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setId(e.target.value)} - variant="small" - className="w-[27ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ { + 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 { value, del } = useSearchParams(); - - if (value("id") === undefined) { - return null; - } +const waitpointIdShortcut = { key: "w" }; +function PermanentWaitpointIdFilter() { + const { value, del } = useSearchParams(); const id = value("id"); + const hasId = id !== undefined; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: waitpointIdShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={id} - onRemove={() => del(["id", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasId ? ( + } + value={id} + onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Waitpoint ID +
+ )} +
+ +
+ Filter by waitpoint ID + +
+
+
} 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(); - 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 +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setIdempotencyKey(e.target.value)} - variant="small" - className="w-[27ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ { + if (v.length === 0) return "Idempotency keys need to be at least 1 character in length"; + return undefined; + }} + /> ); } -function AppliedIdempotencyKeyFilter() { - const { value, del } = useSearchParams(); - - if (value("idempotencyKey") === undefined) { - return null; - } +const idempotencyKeyShortcut = { key: "i" }; +function PermanentIdempotencyKeyFilter() { + const { value, del } = useSearchParams(); const idempotencyKey = value("idempotencyKey"); + const hasKey = idempotencyKey !== undefined; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: idempotencyKeyShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={idempotencyKey} - onRemove={() => del(["idempotencyKey", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasKey ? ( + } + value={idempotencyKey} + onRemove={() => del(["idempotencyKey", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Idempotency key +
+ )} +
+ +
+ Filter by idempotency key + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} diff --git a/apps/webapp/app/hooks/useFuzzyFilter.ts b/apps/webapp/app/hooks/useFuzzyFilter.ts index 3f0797179f2..ff4504ce8c2 100644 --- a/apps/webapp/app/hooks/useFuzzyFilter.ts +++ b/apps/webapp/app/hooks/useFuzzyFilter.ts @@ -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({ items, keys, + filterText: controlledFilterText, }: { items: T[]; keys: (Extract | (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(() => { const filterTerms = filterText @@ -43,7 +48,6 @@ export function useFuzzyFilter({ 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({ return { filterText, - setFilterText, + setFilterText: setInternalFilterText, filteredItems, }; } diff --git a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts index 2ec34614533..a6374c60be2 100644 --- a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts +++ b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts @@ -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", diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 2cf8b844a9e..d61c357e02e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -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(); - const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ + const { value } = useSearchParams(); + const { filteredItems } = useFuzzyFilter({ 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 ? : null}
- - {!showUsefulLinks && ( + + {!showUsefulLinks && ( - ) : undefined - } - /> - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index eaa040c4081..17dcfbc4619 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -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 ( - + {batch.friendlyId} - + {batch.batchVersion === "v1" ? ( @@ -286,7 +287,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { {isLoading && ( Loading… diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index c7d54d1842e..39db1d96f3a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -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() { ) : ( <> -
+
-
- -
+
-
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" - )} - > +
@@ -438,14 +428,6 @@ export default function Page() { )}
-
1 && "justify-end border-t border-grid-dimmed px-2 py-3" - )} - > - -
@@ -545,12 +527,12 @@ export function BranchFilters() { return (
- +
); @@ -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]); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx index cd358b7e67d..283be35e50b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx @@ -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() { - - -
@@ -165,6 +161,14 @@ export default function Page() { possiblePrompts={possiblePrompts} possibleOperations={possibleOperations} possibleProviders={possibleProviders} + filterAccessories={ + + } />
@@ -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 (
-
- - {activeFilters.includes("tasks") && ( - - )} - {activeFilters.includes("queues") && } - {activeFilters.includes("models") && ( - - )} - {activeFilters.includes("prompts") && ( - - )} - {activeFilters.includes("operations") && ( - - )} - {activeFilters.includes("providers") && ( - +
+
+ + {activeFilters.includes("tasks") && ( + + )} + {activeFilters.includes("queues") && } + {activeFilters.includes("models") && ( + + )} + {activeFilters.includes("prompts") && ( + + )} + {activeFilters.includes("operations") && ( + + )} + {activeFilters.includes("providers") && ( + + )} + + {hasAppliedFilters && ( +
+
+ {filterAccessories && ( +
{filterAccessories}
)} -
{ - const actionMessages: Record = { - 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"; - - toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" }); - }, [toast]); + const handleSyncError = useCallback( + (error: Error, action: string) => { + const actionMessages: Record = { + 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"; + + 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 = ( + + + +
+ + +
+
+
+ ); + + const filterAccessories = ( +
+ {widgetIsAtLimit ? ( + <> + + + + + + You've exceeded your widget limit + + You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. + + + {widgetCanUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + + + + ) : ( + <> + + + + )} + {dashboardMenu} +
+ ); + return ( - - {totalWidgetCount > 0 && - (widgetIsAtLimit ? ( - <> - - - - - - You've exceeded your widget limit - - You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this - dashboard. - - - {widgetCanUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - - - - - - ) : ( - <> - - - - ))} - - - -
- - -
-
-
-
+ {totalWidgetCount === 0 && dashboardMenu}
@@ -471,6 +485,7 @@ export default function Page() { onRenameWidget={actions.renameWidget} onDeleteWidget={actions.deleteWidget} onDuplicateWidget={actions.duplicateWidget} + filterAccessories={filterAccessories} /> )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index f7f91f33274..9ab76ed49b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -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(); + const { environmentVariables, environments, vercelIntegration } = + useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { filterText, setFilterText, filteredItems } = - useFuzzyFilter({ - items: environmentVariables, - keys: ["key", "value", "environment.type", "environment.branchName"], - }); + const { value } = useSearchParams(); + const urlSearch = value("search") ?? ""; + const { setFilterText, filteredItems } = useFuzzyFilter({ + 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() {
{environmentVariables.length > 0 && (
- setFilterText(e.target.value)} - autoFocus - /> -
+ +
setRevealAll(e.valueOf())} @@ -351,7 +360,16 @@ export default function Page() { Value - Environment + + Environment + + + } + content="Dev environment variables specified here will be overridden by ones in your .env file when running locally." + className="max-w-60" + /> {vercelIntegration?.enabled && ( @@ -458,10 +476,11 @@ export default function Page() { /> {variable.updatedByUser.name}
- ) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? ( + ) : variable.lastUpdatedBy?.type === "integration" && + variable.lastUpdatedBy?.integration === "vercel" ? (
- - + + {variable.lastUpdatedBy.integration}
@@ -475,7 +494,7 @@ export default function Page() { - -
- - Dev environment variables specified here will be overridden by ones in your .env file - when running locally. - -
@@ -561,9 +573,12 @@ function EditEnvironmentVariablePanel({ return ( - + Edit environment variable @@ -715,14 +730,7 @@ function VercelSyncCheckbox({ if (!pullEnvVarsEnabledForEnv) { return ( {}} - /> - } + button={ {}} />} content="Enable 'Pull env vars before build' for this environment in Vercel settings." /> ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index 964775bd88c..80392208886 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -290,6 +290,8 @@ const errorStatusOptions = [ const statusIcon = ; 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" > - Status + Status } searchValue={search} @@ -416,9 +419,10 @@ function FiltersBar({ return (
-
+
{list ? ( <> + @@ -426,43 +430,53 @@ function FiltersBar({ defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} labelName="Occurred" + shortcut={timeShortcut} /> - {hasFilters && ( -
+
-
+
Configure alerts… diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index af3cc30a246..28e49a014ff 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -260,20 +260,22 @@ function FiltersBar({ return (
-
+
{list ? ( <> + - {hasFilters && ( -
+
-
+
- + + }> + + + +
+ Toggle all details + +
+
+
); @@ -585,13 +690,13 @@ function CompareDialog({ return ( - - + + Compare models {rows.length > 0 ? (
- +
Metric @@ -1116,7 +1221,7 @@ export default function ModelsPage() { -
+
History diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index fdc0c505aec..debab4683ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -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() {
{success ? ( -
1 && "grid-rows-[auto_1fr_auto]" - )} - > -
+
+
- {pagination.totalPages > 1 && ( -
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" - )} - > -
1 && - "justify-end border-t border-grid-dimmed px-2 py-3" - )} - > - -
-
- )}
) : (
@@ -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 ( -
- handleSearchChange(e.target.value)} - /> -
- ); + return ; } function BurstFactorTooltip({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index ca7e8b7b0c1..f555f98171e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -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 ); }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index 769aa10cd75..b3181eefa36 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx @@ -151,50 +151,6 @@ export default function Page() { > Schedules docs - - {limits.used >= limits.limit ? ( - - - - - - You've exceeded your limit - - You've used {limits.used}/{limits.limit} of your schedules. - - - {canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - - - - ) : ( - - New schedule - - )} @@ -219,6 +175,54 @@ export default function Page() { totalPages={totalPages} showPageNumbers={false} /> + {limits.used >= limits.limit ? ( + + + + + + You've exceeded your limit + + You've used {limits.used}/{limits.limit} of your schedules. + + + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more + } + defaultValue="help" + /> + )} + + + + ) : ( + + New schedule + + )}
@@ -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 ( - + {schedule.friendlyId} - + {schedule.taskIdentifier} - + - + {schedule.type === "IMPERATIVE" ? schedule.externalId ? schedule.externalId : "–" : "N/A"} - + {schedule.cron} - + {schedule.cronDescription} - + {schedule.timezone} - + - + {schedule.lastRun ? ( ) : ( "–" )} - + {schedule.type === "IMPERATIVE" ? schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–" : "N/A"} - +
{schedule.environments.map((env) => ( ))}
- + {schedule.type === "IMPERATIVE" ? ( ) : ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx index 9bd9443e95a..ea802850c35 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx @@ -138,8 +138,9 @@ function TaskSelector({
- {(pagination.next || pagination.previous) && ( -
- -
- )}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 1e44dbdc00b..90a81a5c2c3 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -403,7 +403,7 @@ export function UpsertScheduleForm({
Cancel diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 4f097f61ce2..c8bb6264b4e 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -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; }