diff --git a/apps/webapp/app/assets/icons/ConnectionIcons.tsx b/apps/webapp/app/assets/icons/ConnectionIcons.tsx index 5ca22f1ac0..dc47208cab 100644 --- a/apps/webapp/app/assets/icons/ConnectionIcons.tsx +++ b/apps/webapp/app/assets/icons/ConnectionIcons.tsx @@ -46,3 +46,25 @@ export function DisconnectedIcon({ className }: { className?: string }) { ); } + +export function CheckingConnectionIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/DevPresence.tsx b/apps/webapp/app/components/DevPresence.tsx index bd69bbe21e..396c6ea9f6 100644 --- a/apps/webapp/app/components/DevPresence.tsx +++ b/apps/webapp/app/components/DevPresence.tsx @@ -1,5 +1,4 @@ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; -import { useDebounce } from "~/hooks/useDebounce"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -7,14 +6,12 @@ import { useProject } from "~/hooks/useProject"; // Define Context types type DevPresenceContextType = { - lastSeen: Date | null; - isConnected: boolean; + isConnected: boolean | undefined; }; // Create Context with default values const DevPresenceContext = createContext({ - lastSeen: null, - isConnected: false, + isConnected: undefined, }); // Provider component with enabled prop @@ -30,50 +27,44 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro // Only subscribe to event source if enabled is true const streamedEvents = useEventSource( - `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dev/presence`, + `/resources/orgs/${organization.slug}/projects/${project.slug}/dev/presence`, { event: "presence", disabled: !enabled, } ); - const [lastSeen, setLastSeen] = useState(null); - - const debouncer = useDebounce((seen: Date | null) => { - setLastSeen(seen); - }, 3_000); + const [isConnected, setIsConnected] = useState(undefined); useEffect(() => { - // If disabled or no events, set lastSeen to null + // If disabled or no events if (!enabled || streamedEvents === null) { - debouncer(null); + setIsConnected(undefined); return; } try { const data = JSON.parse(streamedEvents) as any; - if ("lastSeen" in data && data.lastSeen) { + if ("isConnected" in data && data.isConnected) { try { - const lastSeenDate = new Date(data.lastSeen); - debouncer(lastSeenDate); + setIsConnected(true); } catch (error) { console.log("DevPresence: Failed to parse lastSeen timestamp", { error }); - debouncer(null); + setIsConnected(false); } } else { - debouncer(null); + setIsConnected(false); } } catch (error) { console.log("DevPresence: Failed to parse presence message", { error }); - debouncer(null); + setIsConnected(false); } }, [streamedEvents, enabled]); // Calculate isConnected and memoize the context value const contextValue = useMemo(() => { - const isConnected = enabled && lastSeen !== null && lastSeen > new Date(Date.now() - 120_000); - return { lastSeen, isConnected }; - }, [lastSeen, enabled]); + return { isConnected }; + }, [isConnected, enabled]); return {children}; } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index aa7ec6a1f3..18b9b7d5df 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -20,7 +20,11 @@ import { import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; -import { ConnectedIcon, DisconnectedIcon } from "~/assets/icons/ConnectionIcons"; +import { + CheckingConnectionIcon, + ConnectedIcon, + DisconnectedIcon, +} from "~/assets/icons/ConnectionIcons"; import { RunsIconExtraSmall, RunsIconSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { Avatar } from "~/components/primitives/Avatar"; @@ -82,6 +86,7 @@ import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { Spinner } from "../primitives/Spinner"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -532,7 +537,9 @@ export function DevConnection() { variant="minimal/small" className="aspect-square h-7 p-1" LeadingIcon={ - isConnected ? ( + isConnected === undefined ? ( + + ) : isConnected ? ( ) : ( @@ -543,24 +550,34 @@ export function DevConnection() { - {isConnected ? "Your dev server is connected" : "Your dev server is not connected"} + {isConnected === undefined + ? "Checking connection..." + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} - {isConnected ? "Your dev server is connected" : "Your dev server is not connected"} + {isConnected === undefined + ? "Checking connection..." + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"}
{isConnected - {isConnected + {isConnected === undefined + ? "Checking connection..." + : isConnected ? "Your local dev server is connected to Trigger.dev" : "Your local dev server is not connected to Trigger.dev"} diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index a86a54ebe9..c67cc82a9e 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -34,7 +34,14 @@ export function AppliedFilter({ }: AppliedFilterProps) { const variantClassName = variants[variant]; return ( -
+
{label}: diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index 707f672abe..dd53eb5d63 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -10,12 +10,12 @@ import { Button } from "./Buttons"; const variants = { small: { - fieldStyles: "h-5 text-sm rounded-sm px-0.5", + fieldStyles: "h-5 text-xs rounded-sm px-0.5", nowButtonVariant: "tertiary/small" as const, clearButtonVariant: "tertiary/small" as const, }, medium: { - fieldStyles: "h-7 text-base rounded px-1", + fieldStyles: "h-7 text-sm rounded px-1", nowButtonVariant: "tertiary/medium" as const, clearButtonVariant: "minimal/medium" as const, }, diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 51ff076d6d..2853f0b59c 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -1,14 +1,14 @@ import * as Ariakit from "@ariakit/react"; -import { SelectProps as AriaSelectProps } from "@ariakit/react"; +import { type SelectProps as AriaSelectProps } from "@ariakit/react"; import { SelectValue } from "@ariakit/react-core/select/select-value"; import { Link } from "@remix-run/react"; import * as React from "react"; import { Fragment, useMemo, useState } from "react"; -import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { ShortcutKey } from "./ShortcutKey"; import { ChevronDown } from "lucide-react"; -import { MatchSorterOptions, matchSorter } from "match-sorter"; +import { type MatchSorterOptions, matchSorter } from "match-sorter"; const sizes = { small: { diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index e5d89f20dd..7c26c9df33 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -3,7 +3,15 @@ import * as React from "react"; import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "~/utils/cn"; -import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; + +const small = { + container: + "flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer", + root: "h-3 w-6", + thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0", + text: "text-xs text-text-dimmed", +}; const variations = { large: { @@ -12,12 +20,15 @@ const variations = { thumb: "size-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", text: "text-sm text-text-dimmed", }, - small: { - container: - "flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer", - root: "h-3 w-6", - thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0", - text: "text-xs text-text-dimmed", + small, + "tertiary/small": { + container: small.container, + root: cn( + small.root, + "group-data-[state=unchecked]:bg-charcoal-600 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50" + ), + thumb: small.thumb, + text: cn(small.text, "transition group-hover:text-text-bright"), }, }; diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index e7a9ba5223..95fadc9ed1 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -36,14 +36,7 @@ import { batchStatusTitle, descriptionForBatchStatus, } from "./BatchStatus"; -import { - AppliedCustomDateRangeFilter, - AppliedPeriodFilter, - appliedSummary, - CreatedAtDropdown, - CustomDateRangeDropdown, - FilterMenuProvider, -} from "./SharedFilters"; +import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters"; export const BatchStatus = z.enum(allBatchStatuses); @@ -54,8 +47,8 @@ export const BatchListFilters = z.object({ (value) => (typeof value === "string" ? [value] : value), BatchStatus.array().optional() ), - period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), id: z.string().optional(), + period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), from: z.coerce.number().optional(), to: z.coerce.number().optional(), }); @@ -69,16 +62,12 @@ type BatchFiltersProps = { export function BatchFilters(props: BatchFiltersProps) { const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); - const hasFilters = - searchParams.has("statuses") || - searchParams.has("id") || - searchParams.has("period") || - searchParams.has("from") || - searchParams.has("to"); + const hasFilters = searchParams.has("statuses") || searchParams.has("id"); return (
+ {hasFilters && (
@@ -101,8 +90,6 @@ const filterTypes = [
), }, - { name: "created", title: "Created", icon: }, - { name: "daterange", title: "Custom date range", icon: }, { name: "batch", title: "Batch ID", icon: }, ] as const; @@ -148,8 +135,6 @@ function AppliedFilters() { return ( <> - - ); @@ -169,10 +154,6 @@ function Menu(props: MenuProps) { return ; case "statuses": return props.setFilterType(undefined)} {...props} />; - case "created": - return props.setFilterType(undefined)} {...props} />; - case "daterange": - return props.setFilterType(undefined)} {...props} />; case "batch": return props.setFilterType(undefined)} {...props} />; } @@ -181,7 +162,6 @@ function Menu(props: MenuProps) { function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { const filtered = useMemo(() => { return filterTypes.filter((item) => { - if (item.name === "daterange") return false; return item.title.toLowerCase().includes(searchValue.toLowerCase()); }); }, [searchValue]); diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 591b917bd8..5529033b21 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1,8 +1,6 @@ import * as Ariakit from "@ariakit/react"; import { - CalendarIcon, ClockIcon, - CpuChipIcon, FingerPrintIcon, Squares2X2Icon, TagIcon, @@ -14,6 +12,7 @@ import { ListChecks, ListFilterIcon } from "lucide-react"; import { matchSorter } from "match-sorter"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { z } from "zod"; +import { StatusIcon } from "~/assets/icons/StatusIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { DateTime } from "~/components/primitives/DateTime"; @@ -45,14 +44,7 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; import { Button } from "../../primitives/Buttons"; import { BulkActionStatusCombo } from "./BulkAction"; -import { - AppliedCustomDateRangeFilter, - AppliedPeriodFilter, - appliedSummary, - CreatedAtDropdown, - CustomDateRangeDropdown, - FilterMenuProvider, -} from "./SharedFilters"; +import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters"; import { allTaskRunStatuses, descriptionForTaskRunStatus, @@ -61,7 +53,6 @@ import { TaskRunStatusCombo, } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; -import { StatusIcon } from "~/assets/icons/StatusIcon"; export const TaskAttemptStatus = z.enum(allTaskRunStatuses); @@ -88,8 +79,8 @@ export const TaskRunListSearchFilters = z.object({ (value) => (typeof value === "string" ? [value] : value), z.string().array().optional() ), - period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), bulkId: z.string().optional(), + period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), from: z.coerce.number().optional(), to: z.coerce.number().optional(), rootOnly: z.coerce.boolean().optional(), @@ -117,11 +108,8 @@ export function RunsFilters(props: RunFiltersProps) { const hasFilters = searchParams.has("statuses") || searchParams.has("tasks") || - searchParams.has("period") || searchParams.has("bulkId") || searchParams.has("tags") || - searchParams.has("from") || - searchParams.has("to") || searchParams.has("batchId") || searchParams.has("runId") || searchParams.has("scheduleId"); @@ -130,6 +118,7 @@ export function RunsFilters(props: RunFiltersProps) {
+ {hasFilters && ( @@ -153,8 +142,6 @@ const filterTypes = [ }, { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, - { name: "created", title: "Created", icon: }, - { name: "daterange", title: "Custom date range", icon: }, { name: "run", title: "Run ID", icon: }, { name: "batch", title: "Batch ID", icon: }, { name: "schedule", title: "Schedule ID", icon: }, @@ -175,7 +162,7 @@ function FilterMenu(props: RunFiltersProps) {
} - variant={"minimal/small"} + variant={"tertiary/small"} shortcut={shortcut} tooltipTitle={"Filter runs"} > @@ -205,8 +192,6 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) { - - @@ -229,13 +214,8 @@ function Menu(props: MenuProps) { return ; case "statuses": return props.setFilterType(undefined)} {...props} />; - case "tasks": return props.setFilterType(undefined)} {...props} />; - case "created": - return props.setFilterType(undefined)} {...props} />; - case "daterange": - return props.setFilterType(undefined)} {...props} />; case "bulk": return props.setFilterType(undefined)} {...props} />; case "tags": @@ -252,7 +232,6 @@ function Menu(props: MenuProps) { function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { const filtered = useMemo(() => { return filterTypes.filter((item) => { - if (item.name === "daterange") return false; return item.title.toLowerCase().includes(searchValue.toLowerCase()); }); }, [searchValue]); @@ -699,9 +678,10 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) { return ( { replace({ rootOnly: checked ? "true" : "false", diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index dc417473e9..5b7478d6a1 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -1,19 +1,13 @@ import * as Ariakit from "@ariakit/react"; import type { RuntimeEnvironment } from "@trigger.dev/database"; +import parse from "parse-duration"; import type { ReactNode } from "react"; -import { startTransition, useCallback, useMemo, useState } from "react"; +import { startTransition, useCallback, useState } from "react"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { DateField } from "~/components/primitives/DateField"; import { DateTime } from "~/components/primitives/DateTime"; import { Label } from "~/components/primitives/Label"; -import { - ComboBox, - ComboboxProvider, - SelectItem, - SelectList, - SelectPopover, - SelectProvider, -} from "~/components/primitives/Select"; +import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select"; import { useSearchParams } from "~/hooks/useSearchParam"; import { Button } from "../../primitives/Buttons"; @@ -51,200 +45,204 @@ export function FilterMenuProvider({ const timePeriods = [ { - label: "Last 5 mins", + label: "1 min", + value: "1m", + }, + { + label: "5 mins", value: "5m", }, { - label: "Last 30 mins", + label: "30 mins", value: "30m", }, { - label: "Last 1 hour", + label: "1 hr", value: "1h", }, { - label: "Last 6 hours", + label: "6 hrs", value: "6h", }, { - label: "Last 1 day", + label: "12 hrs", + value: "12h", + }, + { + label: "1 day", value: "1d", }, { - label: "Last 3 days", + label: "3 days", value: "3d", }, { - label: "Last 7 days", + label: "7 days", value: "7d", }, { - label: "Last 14 days", + label: "14 days", value: "14d", }, { - label: "Last 30 days", + label: "30 days", value: "30d", }, { - label: "All periods", - value: "all", + label: "1 year", + value: "365d", }, ]; -export function CreatedAtDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, - setFilterType, - hideCustomRange, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; - setFilterType?: (type: "daterange" | undefined) => void; - hideCustomRange?: boolean; -}) { - const { value, replace } = useSearchParams(); - - const from = value("from"); - const to = value("to"); - const period = value("period"); +const defaultPeriod = "7d"; +const defaultPeriodMs = parse(defaultPeriod); +if (!defaultPeriodMs) { + throw new Error("Invalid default period"); +} - const handleChange = (newValue: string) => { - clearSearchValue(); - if (newValue === "all") { - if (!period && !from && !to) return; +export const timeFilters = ({ + period, + from, + to, +}: { + period?: string; + from?: string | number; + to?: string | number; +}): { period?: string; from?: Date; to?: Date; isDefault: boolean } => { + if (period) { + return { period, isDefault: period === defaultPeriod }; + } - replace({ - period: undefined, - from: undefined, - to: undefined, - cursor: undefined, - direction: undefined, - }); - return; - } + if (from && to) { + return { + from: typeof from === "string" ? dateFromString(from) : new Date(from), + to: typeof to === "string" ? dateFromString(to) : new Date(to), + isDefault: false, + }; + } - if (newValue === "custom") { - setFilterType?.("daterange"); - return; - } + if (from) { + const fromDate = typeof from === "string" ? dateFromString(from) : new Date(from); - replace({ - period: newValue, - from: undefined, - to: undefined, - cursor: undefined, - direction: undefined, - }); - }; + return { + from: fromDate, + isDefault: false, + }; + } - const filtered = useMemo(() => { - return timePeriods.filter((item) => - item.label.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); + if (to) { + const toDate = typeof to === "string" ? dateFromString(to) : new Date(to); - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } + return { + to: toDate, + isDefault: false, + }; + } - return true; - }} - > - - - {filtered.map((item) => ( - - {item.label} - - ))} - {!hideCustomRange ? ( - - Custom date range - - ) : null} - - - - ); -} + return { + period: defaultPeriod, + isDefault: true, + }; +}; -export function AppliedPeriodFilter() { +export function TimeFilter() { const { value, del } = useSearchParams(); - if (value("period") === undefined || value("period") === "all") { - return null; + const { period, from, to } = timeFilters({ + period: value("period"), + from: value("from"), + to: value("to"), + }); + + const rangeType = from && to ? "range" : from ? "from" : to ? "to" : "period"; + let valueLabel: ReactNode; + switch (rangeType) { + case "period": + valueLabel = timePeriods.find((t) => t.value === period)?.label ?? period ?? defaultPeriod; + break; + case "range": + valueLabel = ( + + –{" "} + + + ); + break; + case "from": + valueLabel = ; + break; + case "to": + valueLabel = ; + break; } + let label = + rangeType === "range" || rangeType === "period" + ? "Created" + : rangeType === "from" + ? "Created after" + : "Created before"; + return ( - {(search, setSearch) => ( - ( + }> - t.value === value("period"))?.label ?? value("period") - } - onRemove={() => del(["period", "cursor", "direction"])} - /> + } - searchValue={search} - clearSearchValue={() => setSearch("")} - hideCustomRange + period={period} + from={from} + to={to} /> )} ); } -export function CustomDateRangeDropdown({ +export function TimeDropdown({ trigger, - clearSearchValue, - searchValue, - onClose, + period, + from, + to, }: { trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; + period?: string; + from?: Date; + to?: Date; }) { const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const fromSearch = dateFromString(value("from")); - const toSearch = dateFromString(value("to")); - const [from, setFrom] = useState(fromSearch); - const [to, setTo] = useState(toSearch); + const { replace } = useSearchParams(); + const [fromValue, setFromValue] = useState(from); + const [toValue, setToValue] = useState(to); const apply = useCallback(() => { - clearSearchValue(); replace({ period: undefined, cursor: undefined, direction: undefined, - from: from?.getTime().toString(), - to: to?.getTime().toString(), + from: fromValue?.getTime().toString(), + to: toValue?.getTime().toString(), }); setOpen(false); - }, [from, to, replace]); + }, [fromValue, toValue, replace]); + + const handlePeriodClick = useCallback((period: string) => { + setFromValue(undefined); + setToValue(undefined); + + replace({ + period: period, + cursor: undefined, + direction: undefined, + from: undefined, + to: undefined, + }); + + setOpen(false); + }, []); return ( @@ -252,54 +250,84 @@ export function CustomDateRangeDropdown({ { - if (onClose) { - onClose(); - return false; - } - return true; }} > -
+
- - + +
+ {timePeriods.map((p) => ( + + ))} +
-
- - -
-
- - + +
+
+ + +
+
+ + +
+
+ + +
@@ -307,58 +335,6 @@ export function CustomDateRangeDropdown({ ); } -export function AppliedCustomDateRangeFilter() { - const { value, del } = useSearchParams(); - - if (value("from") === undefined && value("to") === undefined) { - return null; - } - - const fromDate = dateFromString(value("from")); - const toDate = dateFromString(value("to")); - - const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to"; - - return ( - - {(search, setSearch) => ( - }> - - {rangeType === "range" ? ( - - –{" "} - - - ) : rangeType === "from" ? ( - - ) : ( - - )} - - } - onRemove={() => del(["period", "from", "to", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - /> - )} - - ); -} - export function appliedSummary(values: string[], maxValues = 3) { if (values.length === 0) { return null; diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 33fb88b2e0..92c773f567 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -288,7 +288,7 @@ export function TaskRunsTable({ ) : ( runs.map((run, index) => { - const path = v3RunSpanPath(organization, project, environment, run, { + const path = v3RunSpanPath(organization, project, run.environment, run, { spanId: run.spanId, }); return ( diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index 73ff03e5da..7c64647628 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -36,14 +36,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags"; -import { - AppliedCustomDateRangeFilter, - AppliedPeriodFilter, - appliedSummary, - CreatedAtDropdown, - CustomDateRangeDropdown, - FilterMenuProvider, -} from "./SharedFilters"; +import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters"; import { WaitpointStatusCombo, waitpointStatusTitle } from "./WaitpointStatus"; export const WaitpointSearchParamsSchema = z.object({ @@ -71,16 +64,14 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) { const searchParams = new URLSearchParams(location.search); const hasFilters = searchParams.has("statuses") || - searchParams.has("period") || searchParams.has("tags") || - searchParams.has("from") || - searchParams.has("to") || searchParams.has("id") || searchParams.has("idempotencyKey"); return (
+ {hasFilters && ( @@ -100,8 +91,6 @@ const filterTypes = [ icon: , }, { name: "tags", title: "Tags", icon: }, - { name: "created", title: "Created", icon: }, - { name: "daterange", title: "Custom date range", icon: }, { name: "id", title: "Waitpoint ID", icon: }, { name: "idempotencyKey", title: "Idempotency key", icon: }, ] as const; @@ -148,8 +137,6 @@ function AppliedFilters() { <> - - @@ -170,10 +157,6 @@ function Menu(props: MenuProps) { return ; case "statuses": return props.setFilterType(undefined)} {...props} />; - case "created": - return props.setFilterType(undefined)} {...props} />; - case "daterange": - return props.setFilterType(undefined)} {...props} />; case "tags": return props.setFilterType(undefined)} {...props} />; case "id": @@ -186,7 +169,6 @@ function Menu(props: MenuProps) { function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { const filtered = useMemo(() => { return filterTypes.filter((item) => { - if (item.name === "daterange") return false; return item.title.toLowerCase().includes(searchValue.toLowerCase()); }); }, [searchValue]); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 88738c3c03..a7868ee5bf 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -574,8 +574,9 @@ const EnvironmentSchema = z.object({ RUN_ENGINE_RELEASE_CONCURRENCY_BATCH_SIZE: z.coerce.number().int().default(10), /** How long should the presence ttl last */ - DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(30_000), - DEV_PRESENCE_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), + DEV_PRESENCE_SSE_TIMEOUT: z.coerce.number().int().default(30_000), + DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(5_000), + DEV_PRESENCE_POLL_MS: z.coerce.number().int().default(1_000), /** How many ms to wait until dequeuing again, if there was a run last time */ DEV_DEQUEUE_INTERVAL_WITH_RUN: z.coerce.number().int().default(250), /** How many ms to wait until dequeuing again, if there was no run last time */ diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index 29b3106b0a..c223b2de80 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -4,6 +4,7 @@ import { sqlDatabaseSchema } from "~/db.server"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { BasePresenter } from "./basePresenter.server"; import { type Direction } from "~/components/ListPagination"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; export type BatchListOptions = { userId?: string; @@ -41,14 +42,16 @@ export class BatchListPresenter extends BasePresenter { cursor, pageSize = DEFAULT_PAGE_SIZE, }: BatchListOptions) { + //get the time values from the raw values (including a default period) + const time = timeFilters({ + period, + from, + to, + }); + const hasStatusFilters = statuses && statuses.length > 0; - const hasFilters = - hasStatusFilters || - (period !== undefined && period !== "all") || - friendlyId !== undefined || - from !== undefined || - to !== undefined; + const hasFilters = hasStatusFilters || friendlyId !== undefined || !time.isDefault; // Find the project scoped to the organization const project = await this._replica.project.findFirstOrThrow({ @@ -88,7 +91,7 @@ export class BatchListPresenter extends BasePresenter { throw new Error("No matching environments found for the project"); } - const periodMs = period ? parse(period) : undefined; + const periodMs = time.period ? parse(time.period) : undefined; //get the batches const batches = await this._replica.$queryRaw< @@ -142,11 +145,11 @@ WHERE : Prisma.empty } ${ - from - ? Prisma.sql`AND b."createdAt" >= ${new Date(from).toISOString()}::timestamp` + time.from + ? Prisma.sql`AND b."createdAt" >= ${time.from.toISOString()}::timestamp` : Prisma.empty } - ${to ? Prisma.sql`AND b."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty} + ${time.to ? Prisma.sql`AND b."createdAt" <= ${time.to.toISOString()}::timestamp` : Prisma.empty} ORDER BY ${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`} LIMIT ${pageSize + 1}`; @@ -179,6 +182,21 @@ WHERE ? batches.slice(1, pageSize + 1) : batches.slice(0, pageSize); + let hasAnyBatches = batchesToReturn.length > 0; + if (!hasAnyBatches) { + const firstBatch = await this._replica.batchTaskRun.findFirst({ + where: { + runtimeEnvironmentId: { + in: environmentIds, + }, + }, + }); + + if (firstBatch) { + hasAnyBatches = true; + } + } + return { batches: batchesToReturn.map((batch) => { const environment = project.environments.find( @@ -216,10 +234,9 @@ WHERE friendlyId, statuses: statuses || [], environments: environments || [], - from, - to, }, hasFilters, + hasAnyBatches, }; } } diff --git a/apps/webapp/app/presenters/v3/DevPresence.server.ts b/apps/webapp/app/presenters/v3/DevPresence.server.ts new file mode 100644 index 0000000000..fa606cf9f1 --- /dev/null +++ b/apps/webapp/app/presenters/v3/DevPresence.server.ts @@ -0,0 +1,36 @@ +import Redis, { type RedisOptions } from "ioredis"; +import { env } from "~/env.server"; + +const PRESENCE_KEY_PREFIX = "dev-presence:connection:"; + +export class DevPresence { + private redis: Redis; + + constructor(options: RedisOptions) { + this.redis = new Redis(options); + } + + async isConnected(environmentId: string) { + const presenceKey = this.getPresenceKey(environmentId); + const presenceValue = await this.redis.get(presenceKey); + return !!presenceValue; + } + + async setConnected(environmentId: string, ttl: number) { + const presenceKey = this.getPresenceKey(environmentId); + await this.redis.setex(presenceKey, ttl, new Date().toISOString()); + } + + private getPresenceKey(environmentId: string) { + return `${PRESENCE_KEY_PREFIX}${environmentId}`; + } +} + +export const devPresence = new DevPresence({ + port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined, + host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined, + username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined, + password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined, + enableAutoPipelining: true, + ...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), +}); diff --git a/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts b/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts deleted file mode 100644 index ed4d3ee5f9..0000000000 --- a/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts +++ /dev/null @@ -1,12 +0,0 @@ -const PRESENCE_KEY_PREFIX = "dev-presence:connection:"; -const PRESENCE_CHANNEL_PREFIX = "dev-presence:updates:"; - -export class DevPresenceStream { - static getPresenceKey(environmentId: string) { - return `${PRESENCE_KEY_PREFIX}${environmentId}`; - } - - static getPresenceChannel(environmentId: string) { - return `${PRESENCE_CHANNEL_PREFIX}${environmentId}`; - } -} diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index ffe6d6ce07..a1f580fe8f 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -6,6 +6,7 @@ import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/ import { BasePresenter } from "./basePresenter.server"; import { getAllTaskIdentifiers } from "~/models/task.server"; import { type Direction } from "~/components/ListPagination"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; export type RunListOptions = { userId?: string; @@ -59,22 +60,27 @@ export class RunListPresenter extends BasePresenter { cursor, pageSize = DEFAULT_PAGE_SIZE, }: RunListOptions) { + //get the time values from the raw values (including a default period) + const time = timeFilters({ + period, + from, + to, + }); + const hasStatusFilters = statuses && statuses.length > 0; const hasFilters = (tasks !== undefined && tasks.length > 0) || (versions !== undefined && versions.length > 0) || hasStatusFilters || - (period !== undefined && period !== "all") || (bulkId !== undefined && bulkId !== "") || - from !== undefined || - to !== undefined || (scheduleId !== undefined && scheduleId !== "") || (tags !== undefined && tags.length > 0) || batchId !== undefined || (runIds !== undefined && runIds.length > 0) || typeof isTest === "boolean" || - rootOnly === true; + rootOnly === true || + !time.isDefault; // Find the project scoped to the organization const project = await this._replica.project.findFirstOrThrow({ @@ -186,7 +192,7 @@ export class RunListPresenter extends BasePresenter { rootOnly = false; } - const periodMs = period ? parse(period) : undefined; + const periodMs = time.period ? parse(time.period) : undefined; //get the runs const runs = await this._replica.$queryRaw< @@ -293,12 +299,12 @@ WHERE : Prisma.empty } ${ - from - ? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp` + time.from + ? Prisma.sql`AND tr."createdAt" >= ${time.from.toISOString()}::timestamp` : Prisma.empty } ${ - to ? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty + time.to ? Prisma.sql`AND tr."createdAt" <= ${time.to.toISOString()}::timestamp` : Prisma.empty } ${ tags && tags.length > 0 @@ -338,6 +344,24 @@ WHERE const runsToReturn = direction === "backward" && hasMore ? runs.slice(1, pageSize + 1) : runs.slice(0, pageSize); + let hasAnyRuns = runsToReturn.length > 0; + if (!hasAnyRuns) { + const firstRun = await this._replica.taskRun.findFirst({ + where: { + projectId: project.id, + runtimeEnvironmentId: environments + ? { + in: environments, + } + : undefined, + }, + }); + + if (firstRun) { + hasAnyRuns = true; + } + } + return { runs: runsToReturn.map((run) => { const environment = project.environments.find((env) => env.id === run.runtimeEnvironmentId); @@ -401,10 +425,11 @@ WHERE versions: versions || [], statuses: statuses || [], environments: environments || [], - from, - to, + from: time.from, + to: time.to, }, hasFilters, + hasAnyRuns, }; } } diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 5fe820c973..adef9c12f9 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -2,6 +2,7 @@ import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView"; import { createTimelineSpanEventsFromSpanEvents } from "~/components/run/RunTimeline"; import { prisma, PrismaClient } from "~/db.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; import { getUsername } from "~/utils/username"; import { eventRepository } from "~/v3/eventRepository.server"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; @@ -11,6 +12,13 @@ type Result = Awaited>; export type Run = Result["run"]; export type RunEvent = NonNullable["events"][0]; +export class RunEnvironmentMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = "RunEnvironmentMismatchError"; + } +} + export class RunPresenter { #prismaClient: PrismaClient; @@ -22,12 +30,14 @@ export class RunPresenter { userId, projectSlug, organizationSlug, + environmentSlug, runFriendlyId, showDeletedLogs, }: { userId: string; projectSlug: string; organizationSlug: string; + environmentSlug: string; runFriendlyId: string; showDeletedLogs: boolean; }) { @@ -79,6 +89,12 @@ export class RunPresenter { }, }); + if (environmentSlug !== run.runtimeEnvironment.slug) { + throw new RunEnvironmentMismatchError( + `Run ${runFriendlyId} is not in environment ${environmentSlug}` + ); + } + const showLogs = showDeletedLogs || !run.logsDeletedAt; const runData = { diff --git a/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts index 35235b96a8..f159d3928e 100644 --- a/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts @@ -1,12 +1,10 @@ -import { logger } from "~/services/logger.server"; import { BasePresenter } from "./basePresenter.server"; export type TagListOptions = { userId?: string; projectId: string; //filters - names?: string[]; - environments?: string[]; + name?: string; //pagination page?: number; pageSize?: number; @@ -21,31 +19,19 @@ export class RunTagListPresenter extends BasePresenter { public async call({ userId, projectId, - names, - environments, + name, page = 1, pageSize = DEFAULT_PAGE_SIZE, }: TagListOptions) { - const hasFilters = - (names !== undefined && names.length > 0) || - (environments !== undefined && environments.length > 0); + const hasFilters = Boolean(name?.trim()); const tags = await this._replica.taskRunTag.findMany({ where: { projectId, - OR: - names && names.length > 0 - ? names.map((name) => ({ name: { contains: name, mode: "insensitive" } })) - : undefined, - project: environments + name: name ? { - environments: { - some: { - id: { - in: environments, - }, - }, - }, + startsWith: name, + mode: "insensitive", } : undefined, }, @@ -56,13 +42,6 @@ export class RunTagListPresenter extends BasePresenter { skip: (page - 1) * pageSize, }); - logger.log("tags", { - tags, - projectId, - names, - environments, - }); - return { tags: tags .map((tag) => ({ diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 517e611ac8..2ff5ea4581 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -162,6 +162,7 @@ export class ScheduleListPresenter extends BasePresenter { }, }, active: true, + lastRunTriggeredAt: true, }, where: { projectId: project.id, @@ -209,23 +210,7 @@ export class ScheduleListPresenter extends BasePresenter { skip: (page - 1) * pageSize, }); - const latestRuns = - rawSchedules.length > 0 - ? await this._replica.$queryRaw<{ scheduleId: string; createdAt: Date }[]>` - SELECT t."scheduleId", t."createdAt" - FROM ( - SELECT "scheduleId", MAX("createdAt") as "LatestRun" - FROM ${sqlDatabaseSchema}."TaskRun" - WHERE "scheduleId" IN (${Prisma.join(rawSchedules.map((s) => s.id))}) - GROUP BY "scheduleId" - ) r - JOIN ${sqlDatabaseSchema}."TaskRun" t - ON t."scheduleId" = r."scheduleId" AND t."createdAt" = r."LatestRun";` - : []; - const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => { - const latestRun = latestRuns.find((r) => r.scheduleId === schedule.id); - return { id: schedule.id, type: schedule.type, @@ -238,7 +223,7 @@ export class ScheduleListPresenter extends BasePresenter { timezone: schedule.timezone, active: schedule.active, externalId: schedule.externalId, - lastRun: latestRun?.createdAt, + lastRun: schedule.lastRunTriggeredAt ?? undefined, nextRun: calculateNextScheduledTimestamp(schedule.generatorExpression, schedule.timezone), environments: schedule.instances.map((instance) => { const environment = project.environments.find((env) => env.id === instance.environmentId); diff --git a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts index ef209f2c6c..d2853a10ca 100644 --- a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts @@ -1,9 +1,8 @@ -import { logger } from "~/services/logger.server"; import { BasePresenter } from "./basePresenter.server"; export type TagListOptions = { environmentId: string; - names?: string[]; + name?: string; //pagination page?: number; pageSize?: number; @@ -17,19 +16,21 @@ export type TagListItem = TagList["tags"][number]; export class WaitpointTagListPresenter extends BasePresenter { public async call({ environmentId, - names, + name, page = 1, pageSize = DEFAULT_PAGE_SIZE, }: TagListOptions) { - const hasFilters = names !== undefined && names.length > 0; + const hasFilters = Boolean(name?.trim()); const tags = await this._replica.waitpointTag.findMany({ where: { environmentId, - OR: - names && names.length > 0 - ? names.map((name) => ({ name: { contains: name, mode: "insensitive" } })) - : undefined, + name: name + ? { + startsWith: name, + mode: "insensitive", + } + : undefined, }, orderBy: { id: "desc", diff --git a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts index 5b2d0b822a..ff2578e07d 100644 --- a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts @@ -46,6 +46,7 @@ type Result = previous: string | undefined; }; hasFilters: boolean; + hasAnyTokens: boolean; filters: WaitpointSearchParams; } | { @@ -58,6 +59,7 @@ type Result = previous: undefined; }; hasFilters: false; + hasAnyTokens: false; filters: undefined; }; @@ -87,6 +89,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { previous: undefined, }, hasFilters: false, + hasAnyTokens: false, filters: undefined, }; } @@ -242,6 +245,20 @@ export class WaitpointTokenListPresenter extends BasePresenter { ? tokens.slice(1, pageSize + 1) : tokens.slice(0, pageSize); + let hasAnyTokens = tokensToReturn.length > 0; + if (!hasAnyTokens) { + const firstToken = await this._replica.waitpoint.findFirst({ + where: { + environmentId: environment.id, + type: "MANUAL", + }, + }); + + if (firstToken) { + hasAnyTokens = true; + } + } + return { success: true, tokens: tokensToReturn.map((token) => ({ @@ -262,6 +279,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { previous, }, hasFilters, + hasAnyTokens, filters: { id, statuses: statuses?.length ? statuses : undefined, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index 8fd0fe8861..50c7b9a33d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -99,7 +99,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { batches, hasFilters, filters, pagination } = useTypedLoaderData(); + const { batches, hasFilters, hasAnyBatches, filters, pagination } = + useTypedLoaderData(); return ( @@ -117,7 +118,7 @@ export default function Page() { - {!hasFilters && batches.length === 0 ? ( + {!hasAnyBatches ? ( @@ -135,6 +136,7 @@ export default function Page() { filters={filters} hasFilters={hasFilters} pagination={pagination} + hasAnyBatches={hasAnyBatches} />
)} @@ -186,15 +188,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { - {batches.length === 0 && !hasFilters ? ( - - {!isLoading && ( -
- No batches -
- )} -
- ) : batches.length === 0 ? ( + {batches.length === 0 ? (
No batches match these filters diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index cbd2875206..8fd725ee3d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -18,6 +18,7 @@ import { formatDurationMilliseconds, millisecondsToNanoseconds, nanosecondsToMilliseconds, + tryCatch, } from "@trigger.dev/core/v3"; import { type RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; @@ -77,7 +78,7 @@ import { useProject } from "~/hooks/useProject"; import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams"; import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { useHasAdminAccess } from "~/hooks/useUser"; -import { RunPresenter } from "~/presenters/v3/RunPresenter.server"; +import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server"; import { getImpersonationId } from "~/services/impersonation.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUserId } from "~/services/session.server"; @@ -88,12 +89,15 @@ import { v3BillingPath, v3RunParamsSchema, v3RunPath, + v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, v3RunsPath, } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { redirect } from "remix-typedjson"; const resizableSettings = { parent: { @@ -133,13 +137,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params); const presenter = new RunPresenter(); - const result = await presenter.call({ - userId, - organizationSlug, - showDeletedLogs: !!impersonationId, - projectSlug: projectParam, - runFriendlyId: runParam, - }); + const [error, result] = await tryCatch( + presenter.call({ + userId, + organizationSlug, + showDeletedLogs: !!impersonationId, + projectSlug: projectParam, + runFriendlyId: runParam, + environmentSlug: envParam, + }) + ); + + if (error) { + if (error instanceof RunEnvironmentMismatchError) { + throw redirect( + v3RunRedirectPath( + { slug: organizationSlug }, + { slug: projectParam }, + { friendlyId: runParam } + ) + ); + } + + throw error; + } //resizable settings const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 9158a2f084..2bf0ef79af 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -201,7 +201,7 @@ export default function Page() { {(list) => ( <> - {list.runs.length === 0 && !list.hasFilters ? ( + {list.runs.length === 0 && !list.hasAnyRuns ? ( list.possibleTasks.length === 0 ? ( ) : ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index ad3cccab3f..5ab537f568 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -1,4 +1,3 @@ -import upgradeForWaitpointsPath from "~/assets/images/waitpoints-dashboard.png"; import { BookOpenIcon } from "@heroicons/react/20/solid"; import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; @@ -8,6 +7,7 @@ import { NoWaitpointTokens } from "~/components/BlankStatePanels"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -39,8 +39,6 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { WaitpointTokenListPresenter } from "~/presenters/v3/WaitpointTokenListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder"; -import { determineEngineVersion } from "~/v3/engineVersion.server"; -import { CopyableText } from "~/components/primitives/CopyableText"; export const meta: MetaFunction = () => { return [ @@ -103,7 +101,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { success, tokens, pagination, hasFilters, filters } = useTypedLoaderData(); + const { success, tokens, pagination, hasFilters, hasAnyTokens, filters } = + useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -124,7 +123,7 @@ export default function Page() { - {!hasFilters && tokens.length === 0 ? ( + {!hasAnyTokens ? ( diff --git a/apps/webapp/app/routes/engine.v1.dev.presence.ts b/apps/webapp/app/routes/engine.v1.dev.presence.ts index 8ddfdf2575..bb38a88f25 100644 --- a/apps/webapp/app/routes/engine.v1.dev.presence.ts +++ b/apps/webapp/app/routes/engine.v1.dev.presence.ts @@ -1,23 +1,13 @@ import { json } from "@remix-run/server-runtime"; -import { Redis } from "ioredis"; import { env } from "~/env.server"; -import { DevPresenceStream } from "~/presenters/v3/DevPresenceStream.server"; +import { devPresence } from "~/presenters/v3/DevPresence.server"; import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createSSELoader } from "~/utils/sse"; -const redis = new Redis({ - port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined, - host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined, - username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined, - password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined, - enableAutoPipelining: true, - ...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), -}); - export const loader = createSSELoader({ - timeout: env.DEV_PRESENCE_TTL_MS, - interval: env.DEV_PRESENCE_POLL_INTERVAL_MS, + timeout: env.DEV_PRESENCE_SSE_TIMEOUT, + interval: env.DEV_PRESENCE_TTL_MS * 0.8, debug: true, handler: async ({ id, controller, debug, request }) => { const authentication = await authenticateApiRequestWithFailure(request); @@ -27,52 +17,24 @@ export const loader = createSSELoader({ } const environmentId = authentication.environment.id; - - const presenceKey = DevPresenceStream.getPresenceKey(environmentId); - const presenceChannel = DevPresenceStream.getPresenceChannel(environmentId); + const ttl = env.DEV_PRESENCE_TTL_MS / 1000; return { beforeStream: async () => { logger.debug("Start dev presence SSE session", { environmentId, - presenceKey, - presenceChannel, }); }, initStream: async ({ send }) => { // Set initial presence with more context - await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, new Date().toISOString()); - - // Publish presence update - await redis.publish( - presenceChannel, - JSON.stringify({ - type: "connected", - environmentId, - timestamp: Date.now(), - }) - ); - + await devPresence.setConnected(environmentId, ttl); send({ event: "start", data: `Started ${id}` }); }, iterator: async ({ send, date }) => { - await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, date.toISOString()); - + await devPresence.setConnected(environmentId, ttl); send({ event: "time", data: new Date().toISOString() }); }, - cleanup: async () => { - await redis.del(presenceKey); - - // Publish disconnect event - await redis.publish( - presenceChannel, - JSON.stringify({ - type: "disconnected", - environmentId, - timestamp: Date.now(), - }) - ); - }, + cleanup: async () => {}, }; }, }); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.dev.presence.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.dev.presence.tsx new file mode 100644 index 0000000000..12ee82b8e9 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.dev.presence.tsx @@ -0,0 +1,74 @@ +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { devPresence } from "~/presenters/v3/DevPresence.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { ProjectParamSchema } from "~/utils/pathBuilder"; +import { createSSELoader, type SendFunction } from "~/utils/sse"; + +export const loader = createSSELoader({ + timeout: env.DEV_PRESENCE_SSE_TIMEOUT, + interval: env.DEV_PRESENCE_POLL_MS, + debug: true, + handler: async ({ id, controller, debug, request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + type: "DEVELOPMENT", + orgMember: { + userId, + }, + project: { + slug: projectParam, + }, + }, + }); + + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const checkAndSendPresence = async (send: SendFunction) => { + try { + // Use the command client for the GET operation + const isConnected = await devPresence.isConnected(environment.id); + + send({ + event: "presence", + data: JSON.stringify({ + isConnected, + environmentId: environment.id, + timestamp: new Date().toISOString(), + }), + }); + + return isConnected; + } catch (error) { + // Handle the case where the controller is closed + logger.debug("Failed to send presence data, stream might be closed", { error }); + return false; + } + }; + + return { + beforeStream: async () => { + logger.debug("Start dev presence listening SSE session", { + environmentId: environment.id, + }); + }, + initStream: async ({ send }) => { + await checkAndSendPresence(send); + + send({ event: "time", data: new Date().toISOString() }); + }, + iterator: async ({ send, date }) => { + await checkAndSendPresence(send); + }, + cleanup: async ({ send }) => { + await checkAndSendPresence(send); + }, + }; + }, +}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx deleted file mode 100644 index 33ec558d2d..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { $replica } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { env } from "~/env.server"; -import { DevPresenceStream } from "~/presenters/v3/DevPresenceStream.server"; -import { logger } from "~/services/logger.server"; -import { createSSELoader, type SendFunction } from "~/utils/sse"; -import Redis from "ioredis"; - -export const loader = createSSELoader({ - timeout: env.DEV_PRESENCE_TTL_MS, - interval: env.DEV_PRESENCE_POLL_INTERVAL_MS, - debug: true, - handler: async ({ id, controller, debug, request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const environment = await $replica.runtimeEnvironment.findFirst({ - where: { - slug: envParam, - type: "DEVELOPMENT", - orgMember: { - userId, - }, - project: { - slug: projectParam, - }, - }, - }); - - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } - - const presenceKey = DevPresenceStream.getPresenceKey(environment.id); - const presenceChannel = DevPresenceStream.getPresenceChannel(environment.id); - - // Create two Redis clients - one for subscribing and one for regular commands - const redisConfig = { - port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined, - host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined, - username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined, - password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined, - enableAutoPipelining: true, - ...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), - }; - - // Subscriber client for pubsub - const subRedis = new Redis(redisConfig); - - // Command client for regular Redis commands - const cmdRedis = new Redis(redisConfig); - - const checkAndSendPresence = async (send: SendFunction) => { - try { - // Use the command client for the GET operation - const currentPresenceValue = await cmdRedis.get(presenceKey); - const isConnected = !!currentPresenceValue; - - // Format lastSeen as ISO string if it exists - let lastSeen = null; - if (currentPresenceValue) { - try { - lastSeen = new Date(currentPresenceValue).toISOString(); - } catch (e) { - // If parsing fails, use current time as fallback - lastSeen = new Date().toISOString(); - logger.warn("Failed to parse lastSeen value, using current time", { - originalValue: currentPresenceValue, - }); - } - } - - send({ - event: "presence", - data: JSON.stringify({ - type: isConnected ? "connected" : "disconnected", - environmentId: environment.id, - timestamp: new Date().toISOString(), // Also standardize this to ISO - lastSeen: lastSeen, - }), - }); - - return isConnected; - } catch (error) { - // Handle the case where the controller is closed - logger.debug("Failed to send presence data, stream might be closed", { error }); - return false; - } - }; - - return { - beforeStream: async () => { - logger.debug("Start dev presence listening SSE session", { - environmentId: environment.id, - presenceChannel, - }); - }, - initStream: async ({ send }) => { - await checkAndSendPresence(send); - - //start subscribing with the subscriber client - await subRedis.subscribe(presenceChannel); - - subRedis.on("message", async (channel, message) => { - if (channel === presenceChannel) { - try { - await checkAndSendPresence(send); - } catch (error) { - logger.error("Failed to parse presence message", { error, message }); - } - } - }); - - send({ event: "time", data: new Date().toISOString() }); - }, - iterator: async ({ send, date }) => { - await checkAndSendPresence(send); - }, - cleanup: async ({ send }) => { - await checkAndSendPresence(send); - - await subRedis.unsubscribe(presenceChannel); - await subRedis.quit(); - await cmdRedis.quit(); - }, - }; - }, -}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts index 765c711404..e8e5eea364 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts @@ -31,7 +31,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const presenter = new WaitpointTagListPresenter(); const result = await presenter.call({ environmentId: environment.id, - names: name ? [decodeURIComponent(name)] : undefined, + name: name ? decodeURIComponent(name) : undefined, }); return result; } diff --git a/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx b/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx index 2f3df1d140..449142f53c 100644 --- a/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx +++ b/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx @@ -26,7 +26,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const presenter = new RunTagListPresenter(); const result = await presenter.call({ projectId: project.id, - names: name ? [decodeURIComponent(name)] : undefined, + name: name ? decodeURIComponent(name) : undefined, }); return result; } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 76c440c823..dc6b392dcf 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -242,6 +242,14 @@ export function v3RunPath( return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`; } +export function v3RunRedirectPath( + organization: OrgForPath, + project: ProjectForPath, + run: v3RunForPath +) { + return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`; +} + export function v3RunDownloadLogsPath(run: v3RunForPath) { return `/resources/runs/${run.friendlyId}/logs/download`; } diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts index af63a15033..b2a56f78cc 100644 --- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts +++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts @@ -1,12 +1,13 @@ -import { PrismaClientOrTransaction } from "~/db.server"; -import { BaseService } from "./baseService.server"; +import { stringifyIO } from "@trigger.dev/core/v3"; +import { type PrismaClientOrTransaction } from "~/db.server"; +import { devPresence } from "~/presenters/v3/DevPresence.server"; +import { logger } from "~/services/logger.server"; import { workerQueue } from "~/services/worker.server"; +import { findCurrentWorkerDeployment } from "../models/workerDeployment.server"; +import { nextScheduledTimestamps } from "../utils/calculateNextSchedule.server"; +import { BaseService } from "./baseService.server"; import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server"; import { TriggerTaskService } from "./triggerTask.server"; -import { stringifyIO } from "@trigger.dev/core/v3"; -import { nextScheduledTimestamps } from "../utils/calculateNextSchedule.server"; -import { findCurrentWorkerDeployment } from "../models/workerDeployment.server"; -import { logger } from "~/services/logger.server"; export class TriggerScheduledTaskService extends BaseService { public async call(instanceId: string, finalAttempt: boolean) { @@ -57,11 +58,17 @@ export class TriggerScheduledTaskService extends BaseService { shouldTrigger = false; } - if ( - instance.environment.type === "DEVELOPMENT" && - (!instance.environment.currentSession || instance.environment.currentSession.disconnectedAt) - ) { - shouldTrigger = false; + if (instance.environment.type === "DEVELOPMENT") { + //v3 + const v3Disconnected = + !instance.environment.currentSession || + instance.environment.currentSession.disconnectedAt; + //v4 + const v4Connected = await devPresence.isConnected(instance.environment.id); + + if (v3Disconnected && !v4Connected) { + shouldTrigger = false; + } } if (instance.environment.type !== "DEVELOPMENT") { @@ -147,6 +154,15 @@ export class TriggerScheduledTaskService extends BaseService { scheduleInstanceId: instance.id, }, }); + + await this._prisma.taskSchedule.update({ + where: { + id: instance.taskSchedule.id, + }, + data: { + lastRunTriggeredAt: new Date(), + }, + }); } } diff --git a/internal-packages/database/prisma/migrations/20250327181650_add_last_run_triggered_at_to_task_schedule/migration.sql b/internal-packages/database/prisma/migrations/20250327181650_add_last_run_triggered_at_to_task_schedule/migration.sql new file mode 100644 index 0000000000..413f8a85e4 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250327181650_add_last_run_triggered_at_to_task_schedule/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "TaskSchedule" +ADD COLUMN "lastRunTriggeredAt" TIMESTAMP(3); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index afe7256fb5..216ed36851 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2918,6 +2918,8 @@ model TaskSchedule { ///Instances of the schedule that are active instances TaskScheduleInstance[] + lastRunTriggeredAt DateTime? + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 20d9c61081..6e97cfed0f 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -494,11 +494,15 @@ export class CliApiClient { }); } - private async devPresenceConnection(): Promise { + private devPresenceConnection(): EventSource { if (!this.accessToken) { throw new Error("connectToPresence: No access token"); } + let retryCount = 0; + const maxRetries = 5; + const retryDelay = 1000; // Start with 1 second delay + const eventSource = new EventSource(`${this.apiURL}/engine/v1/dev/presence`, { fetch: (input, init) => fetch(input, { @@ -510,26 +514,40 @@ export class CliApiClient { }), }); - return new Promise((resolve, reject) => { - eventSource.onopen = () => { - logger.debug("Presence connection established"); - resolve(eventSource); - }; - - eventSource.onerror = (error: any) => { - // The connection will automatically try to reconnect - logger.debug("Presence connection error, will automatically attempt to reconnect", { - error, - readyState: eventSource.readyState, // 0 = connecting, 1 = open, 2 = closed - }); - - // If you want to detect when it's permanently failed and not reconnecting - if (eventSource.readyState === EventSource.CLOSED) { - logger.debug("Presence connection permanently closed", { error }); - reject(new Error(`Failed to connect to ${this.apiURL}`)); + eventSource.onopen = () => { + logger.debug("Presence connection established"); + retryCount = 0; // Reset retry count on successful connection + }; + + eventSource.onerror = (error: any) => { + // The connection will automatically try to reconnect + logger.debug("Presence connection error, will automatically attempt to reconnect", { + error, + readyState: eventSource.readyState, + }); + + if (eventSource.readyState === EventSource.CLOSED) { + logger.debug("Presence connection permanently closed", { error, retryCount }); + + if (retryCount < maxRetries) { + retryCount++; + const backoffDelay = retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff + + logger.debug( + `Attempting reconnection in ${backoffDelay}ms (attempt ${retryCount}/${maxRetries})` + ); + eventSource.close(); + + setTimeout(() => { + this.devPresenceConnection(); + }, backoffDelay); + } else { + logger.debug("Max retry attempts reached, giving up"); } - }; - }); + } + }; + + return eventSource; } private async devDequeue( diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 81d7c77f9f..e1445b4600 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -333,7 +333,7 @@ class DevSupervisor implements WorkerRuntime { async #startPresenceConnection() { try { - const eventSource = await this.options.client.dev.presenceConnection(); + const eventSource = this.options.client.dev.presenceConnection(); // Regular "ping" messages eventSource.addEventListener("presence", (event: any) => {