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