import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; import { useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { ClockIcon } from "~/assets/icons/ClockIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { InlineCode } from "~/components/code/InlineCode"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; import { statusColor } from "~/components/primitives/charts/statusColors"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; import { Header2 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; import { Spinner } from "~/components/primitives/Spinner"; import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, type TableVariant, } from "~/components/primitives/Table"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { useToast } from "~/components/primitives/Toast"; import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { ScheduleInspector } from "~/components/schedules/ScheduleInspector"; import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions"; import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar"; import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server"; import { TaskDetailPresenter, type TaskActivity, type TaskDetail, } from "~/presenters/v3/TaskDetailPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3CreateBulkActionPath, v3EditSchedulePath, v3EnvironmentPath, v3NewSchedulePath, v3RunsPath, v3SchedulePath, v3SchedulesAddOnPath, v3TestTaskPath, } from "~/utils/pathBuilder"; import { parseFiniteInt } from "~/utils/searchParams"; import type { loader as scheduleDetailLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route"; import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route"; import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; return [ { title: slug ? `${slug} | Scheduled tasks | Trigger.dev` : "Scheduled task | Trigger.dev" }, ]; }; const ParamsSchema = EnvironmentParamSchema.extend({ taskParam: z.string(), }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const userId = user.id; const { organizationSlug, projectParam, envParam, taskParam } = ParamsSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) throw new Response("Project not found", { status: 404 }); const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) throw new Response("Environment not found", { status: 404 }); const url = new URL(request.url); const period = url.searchParams.get("period") ?? undefined; const from = parseFiniteInt(url.searchParams.get("from")); const to = parseFiniteInt(url.searchParams.get("to")); const cursor = url.searchParams.get("cursor") ?? undefined; const directionRaw = url.searchParams.get("direction") ?? undefined; const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined; const clickhouse = await clickhouseFactory.getClickhouseForOrganization( project.organizationId, "standard" ); const taskPresenter = new TaskDetailPresenter($replica, clickhouse); const task = await taskPresenter.findTask({ environmentId: environment.id, environmentType: environment.type, taskSlug: taskParam, expectedTriggerSource: "SCHEDULED", }); if (!task) throw new Response("Scheduled task not found", { status: 404 }); const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" }); const activity = taskPresenter .getActivity({ organizationId: project.organizationId, projectId: project.id, environmentId: environment.id, taskSlug: task.slug, from: time.from, to: time.to, }) .catch(() => ({ data: [], statuses: [] }) satisfies TaskActivity); const pageRaw = parseFiniteInt(url.searchParams.get("page")); const schedulesPage = pageRaw !== undefined && pageRaw > 0 ? pageRaw : 1; // Resolved synchronously — the bottom usage bar reads `limits` and // `canPurchaseSchedules` directly from it, and the limit-exceeded // intercept on the "Create schedule" button needs the same. const scheduleList = await new ScheduleListPresenter() .call({ userId, projectId: project.id, environmentId: environment.id, tasks: [task.slug], page: schedulesPage, pageSize: 25, }) .catch(() => null); const runList = new NextRunListPresenter($replica, clickhouse) .call(project.organizationId, environment.id, { userId, projectId: project.id, tasks: [task.slug], period, from, to, cursor, direction, }) .catch(() => null); return typeddefer({ task, activity, scheduleList, runList, }); }; export default function Page() { const { task, activity, scheduleList, runList } = useTypedLoaderData(); const zoomToTimeFilter = useZoomToTimeFilter(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const scheduledTasksListingPath = v3EnvironmentPath(organization, project, environment); const testPath = v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug, }); const filters: TaskRunListSearchFilters = useMemo(() => ({ tasks: [task.slug] }), [task.slug]); const search = useSearchParams(); const openScheduleId = search.value("schedule"); const openSchedule = useCallback( (friendlyId: string) => search.replace({ schedule: friendlyId }), [search] ); const closeSchedule = useCallback(() => search.del("schedule"), [search]); const isCreatingSchedule = search.has("createSchedule"); const openCreateSchedule = useCallback(() => search.replace({ createSchedule: "1" }), [search]); const closeCreateSchedule = useCallback(() => search.del("createSchedule"), [search]); // Schedules add-on / quota state — drives the bottom usage bar and the // "Create schedule" button's limit-exceeded intercept. const plan = useCurrentPlan(); const limits = scheduleList?.limits; const requiresUpgrade = !!plan?.v3Subscription?.plan && !!limits && limits.used >= plan.v3Subscription.plan.limits.schedules.number && !plan.v3Subscription.plan.limits.schedules.canExceed; const canUpgrade = !!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed; const isAtLimit = !!limits && limits.used >= limits.limit; return ( {task.slug} } />
{/* Top bar — title on the left; actions + TimeFilter + pagination on the right. h-10 matches the right-hand sidebar header height. */}
Runs
View all runs Bulk replay… {(list) => (list ? : null)}
{/* Activity chart */}
}> }> {(result) => }
{/* Runs table */}
}> }> {(list) => list ? (
) : ( ) }
{/* Schedules usage bar — pinned to the bottom of the main panel via the grid-rows-[auto_1fr_auto] above. */} {scheduleList ? ( ) : null}
); } /** * "Create schedule" button with a limit-exceeded intercept. When the project * is already at its schedules limit, clicking opens a dialog explaining the * limit and offering Purchase / Upgrade / Request, mirroring the behavior * that lived on the (now-removed) standalone Schedules listing page. */ function CreateScheduleButton({ isAtLimit, limits, canUpgrade, canPurchaseSchedules, extraSchedules, maxScheduleQuota, planScheduleLimit, schedulePricing, onCreate, disabled, }: { isAtLimit: boolean; limits: { used: number; limit: number } | undefined; canUpgrade: boolean; canPurchaseSchedules: boolean; extraSchedules: number; maxScheduleQuota: number; planScheduleLimit: number; schedulePricing: { stepSize: number; centsPerStep: number } | null; onCreate: () => void; disabled?: boolean; }) { const organization = useOrganization(); const addOnPath = v3SchedulesAddOnPath(organization); if (isAtLimit && limits) { return ( You've exceeded your limit You've used {limits.used}/{limits.limit} of your schedules. ); } return ( ); } function CreateScheduleSheet({ open, organization, project, environment, defaultTaskIdentifier, onClose, }: { open: boolean; organization: ReturnType; project: ReturnType; environment: ReturnType; defaultTaskIdentifier: string; onClose: () => void; }) { const fetcher = useTypedFetcher(); // Embedded create — stays on this page via `_format=json`. const createFetcher = useFetcher<{ ok: boolean; message?: string }>(); const toast = useToast(); const revalidator = useRevalidator(); // `useRevalidator()` and `onClose` change identity every render — guard // against the dep churn so we only handle each response once. const handledCreateRef = useRef(null); const newPath = v3NewSchedulePath(organization, project, environment); useEffect(() => { if (open) fetcher.load(newPath); }, [open, newPath]); // Toast + close + revalidate so the new schedule appears. useEffect(() => { const data = createFetcher.data; if (createFetcher.state !== "idle" || !data) return; if (handledCreateRef.current === data) return; handledCreateRef.current = data; if (data.ok) { toast.success(data.message ?? "Schedule created"); revalidator.revalidate(); onClose(); } else if (data.message) { toast.error(data.message); } }, [createFetcher.state, createFetcher.data, toast, revalidator, onClose]); const data = fetcher.data; const isLoading = fetcher.state === "loading" || (open && !data); return ( !o && onClose()}> e.preventDefault()} > {isLoading || !data ? ( ) : ( )} ); } function ScheduleSheet({ openScheduleId, organization, project, environment, onClose, }: { openScheduleId: string | undefined; organization: ReturnType; project: ReturnType; environment: ReturnType; onClose: () => void; }) { const detailFetcher = useTypedFetcher(); const editFetcher = useTypedFetcher(); // Embedded enable/disable — stays in the sheet via `_format=json`. const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean; message?: string }>(); // Embedded update submission — same idea. const updateFetcher = useFetcher<{ ok: boolean; message?: string }>(); // Embedded delete submission — same idea. const deleteFetcher = useFetcher<{ ok: boolean; message?: string }>(); const toast = useToast(); const revalidator = useRevalidator(); // Dedupe response handling against unstable deps (revalidator/onClose). const handledToggleRef = useRef(null); const handledUpdateRef = useRef(null); const handledDeleteRef = useRef(null); const [mode, setMode] = useState<"inspect" | "edit">("inspect"); const detailPath = openScheduleId ? v3SchedulePath(organization, project, environment, { friendlyId: openScheduleId }) : undefined; const editPath = openScheduleId ? v3EditSchedulePath(organization, project, environment, { friendlyId: openScheduleId }) : undefined; // Always reopen in inspect mode. useEffect(() => { setMode("inspect"); }, [openScheduleId]); useEffect(() => { if (detailPath) detailFetcher.load(detailPath); }, [detailPath]); useEffect(() => { if (mode === "edit" && editPath) editFetcher.load(editPath); }, [mode, editPath]); // Reload inspector data so Enable/Disable label flips; revalidate the // route loader so the sidebar's list/Overview stay in sync; toast on error. useEffect(() => { const data = activeToggleFetcher.data; if (activeToggleFetcher.state !== "idle" || !data) return; if (handledToggleRef.current === data) return; handledToggleRef.current = data; if (data.ok) { if (detailPath) detailFetcher.load(detailPath); revalidator.revalidate(); } else if (data.message) { toast.error(data.message); } }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast, revalidator]); // Toast + back to inspect + reload + revalidate so both the inspector // and the sidebar reflect the update. useEffect(() => { const data = updateFetcher.data; if (updateFetcher.state !== "idle" || !data) return; if (handledUpdateRef.current === data) return; handledUpdateRef.current = data; if (data.ok) { toast.success(data.message ?? "Schedule updated"); setMode("inspect"); if (detailPath) detailFetcher.load(detailPath); revalidator.revalidate(); } else if (data.message) { toast.error(data.message); } }, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator]); // Toast + close + revalidate so the deleted row disappears. useEffect(() => { const data = deleteFetcher.data; if (deleteFetcher.state !== "idle" || !data) return; if (handledDeleteRef.current === data) return; handledDeleteRef.current = data; if (data.ok) { toast.success(data.message ?? "Schedule deleted"); revalidator.revalidate(); onClose(); } else if (data.message) { toast.error(data.message); } }, [deleteFetcher.state, deleteFetcher.data, toast, revalidator, onClose]); const schedule = detailFetcher.data?.schedule; // Treat stale data (previous schedule still in fetcher cache after the // user clicked a different row) as loading — otherwise we briefly flash // the previous schedule's content while the new fetch is in flight. const isStaleSchedule = !!schedule && !!openScheduleId && schedule.friendlyId !== openScheduleId; // Only show the loading spinner when we actually lack good data — // background reloads (e.g. after enable/disable) keep the inspector // visible with its current values until the fresh data arrives. const isDetailLoading = isStaleSchedule || (!!openScheduleId && detailFetcher.data === undefined); // Distinct from loading: the loader has resolved and the schedule is // genuinely gone (returned `null`, e.g. deleted externally). const isScheduleMissing = !!openScheduleId && !isDetailLoading && detailFetcher.data?.schedule === null; const editData = editFetcher.data; // Mirror the detail-fetcher staleness check so the edit form doesn't // briefly flash a previously-edited schedule's data on the first render // after switching schedules. const isStaleEditData = !!editData?.schedule && !!openScheduleId && editData.schedule.friendlyId !== openScheduleId; const isEditLoading = mode === "edit" && (editFetcher.state === "loading" || !editData || isStaleEditData); return ( !open && onClose()}> e.preventDefault()} > {mode === "edit" ? ( isEditLoading || !editData ? ( ) : ( setMode("inspect")} submitFetcher={updateFetcher} /> ) ) : isDetailLoading ? ( ) : isScheduleMissing ? ( ) : schedule ? ( setMode("edit")} activeToggleFetcher={activeToggleFetcher} deleteFetcher={deleteFetcher} /> ) : ( )} ); } type LoaderData = ReturnType>; function ScheduledTaskDetailSidebar({ task, testPath, scheduleList, onSelectSchedule, }: { task: TaskDetail; testPath: string; onSelectSchedule: (friendlyId: string) => void } & Pick< LoaderData, "scheduleList" >) { const sortedSchedules = useMemo(() => { if (!scheduleList) return []; // DECLARATIVE first; createdAt-desc within each type (stable sort). return [...scheduleList.schedules].sort((a, b) => { if (a.type === b.type) return 0; return a.type === "DECLARATIVE" ? -1 : 1; }); }, [scheduleList?.schedules]); const firstSchedule = sortedSchedules[0]; const [activeTab, setActiveTab] = useState<"overview" | "schedules">("overview"); return (
{task.slug} Test schedule
setActiveTab("overview")} shortcut={{ key: "o" }} > Overview setActiveTab("schedules")} shortcut={{ key: "s" }} > Schedules {activeTab === "schedules" && scheduleList && scheduleList.totalPages > 1 ? (
) : null}
{activeTab === "overview" ? (
Identifier File path Schedule ID {firstSchedule ? ( ) : ( )} CRON {firstSchedule ? (
{firstSchedule.cron} {firstSchedule.cronDescription}
) : ( )}
Created Next run {firstSchedule ? ( ) : ( )} Last run {firstSchedule?.lastRun ? ( ) : ( Never )} Status {firstSchedule ? ( ) : ( )}
{scheduleList && sortedSchedules.length === 0 ? (
) : null}
) : (
{scheduleList ? ( sortedSchedules.length === 0 ? (
) : ( ) ) : ( )}
)}
); } type ScheduleRow = { id: string; friendlyId: string; type: "DECLARATIVE" | "IMPERATIVE"; cron: string; cronDescription: string; externalId: string | null; nextRun: Date; lastRun: Date | undefined; active: boolean; }; function SchedulesMiniTable({ schedules, variant, onSelectSchedule, showTopBorder = true, }: { schedules: ScheduleRow[]; variant?: TableVariant; onSelectSchedule: (friendlyId: string) => void; showTopBorder?: boolean; }) { if (schedules.length === 0) { return ( No schedules attached to this task yet.
); } return ( Schedule ID Type Cron External ID Next run Last run Status {schedules.map((schedule) => { const open = () => onSelectSchedule(schedule.friendlyId); return ( {schedule.friendlyId} {scheduleTypeName(schedule.type)} {schedule.cron} {schedule.externalId ? ( {schedule.externalId} ) : ( )} {schedule.lastRun ? ( ) : ( Never )} ); })}
); } function ActivityChart({ activity }: { activity: TaskActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), color: statusColor(status), }; } return cfg; }, [activity.statuses]); const { tickFormatter, tooltipLabelFormatter } = useMemo( () => buildActivityTimeAxis(activity.data), [activity.data] ); return ( ); } function ActivityChartSkeleton() { return (
{Array.from({ length: 42 }).map((_, i) => (
))}
); } function NoSchedulesAttachedPanel() { return ( Read the docs } > Scheduled tasks only run automatically when a schedule is attached. There are two types: Declarative — defined directly on your{" "} schedules.task and synced when you run dev or deploy. Imperative — created dynamically from the dashboard or via the SDK with schedules.create(). ); } function TableLoading() { return (
); } function ScheduleMissingPanel({ onClose }: { onClose: () => void }) { return (
This schedule no longer exists.
); }