Files
2026-07-13 13:32:57 +08:00

343 lines
12 KiB
TypeScript

import { BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense, useMemo } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Card } from "~/components/primitives/charts/Card";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Header1, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
tasksDashboardPresenter,
type DailyRunPoint,
} from "~/presenters/v3/TasksDashboardPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
v3EnvironmentPath,
v3RunsPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [{ title: "Tasks | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
const result = await tasksDashboardPresenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
});
return typeddefer(result);
};
const isoDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "utc",
});
function formatDay(value: string) {
// value is a YYYY-MM-DD date string
const d = new Date(value + "T00:00:00Z");
return isoDateFormatter.format(d);
}
const STANDARD_EXAMPLE = `import { task } from "@trigger.dev/sdk";
export const helloWorld = task({
id: "hello-world",
run: async (payload: { name: string }) => {
return { greeting: \`Hello, \${payload.name}!\` };
},
});
`;
const SCHEDULED_EXAMPLE = `import { schedules } from "@trigger.dev/sdk";
export const dailyReport = schedules.task({
id: "daily-report",
cron: "0 9 * * *",
run: async (payload) => {
// Runs every day at 9am UTC
return { ranAt: payload.timestamp };
},
});
`;
const AGENT_EXAMPLE = `import { agent } from "@trigger.dev/sdk/ai";
export const supportAgent = agent({
id: "support-agent",
model: "anthropic/claude-sonnet-4-6",
instructions: "You are a helpful customer support agent.",
});
`;
export default function TasksDashboardPage() {
const { counts, series } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<PageContainer>
<NavBar>
<PageTitle title="Tasks" />
</NavBar>
<PageBody scrollable={true}>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-6">
<Header1 className="text-text-bright">Tasks overview</Header1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Suspense fallback={<PanelSkeleton title="Agent tasks" />}>
<TypedAwait resolve={series}>
{(s) => (
<TaskTypePanel
title="Agent tasks"
count={counts.agents}
description="AI agents are tasks that can call LLMs, use tools, and run multi-step conversations. Use them to power chat experiences, intelligent automations, or autonomous workflows."
example="A support agent that drafts replies using your docs as context."
data={s.agents}
seriesColor="hsl(280 80% 65%)"
listingPath={v3EnvironmentPath(organization, project, environment)}
docsHref={docsPath("v3/agents")}
emptyTitle="You don't have any agents yet"
emptyDescription="Create an AI agent to call LLMs, use tools, and run multi-step conversations from a Trigger.dev task."
emptyCta="Create your first agent"
exampleCode={AGENT_EXAMPLE}
/>
)}
</TypedAwait>
</Suspense>
<Suspense fallback={<PanelSkeleton title="Standard tasks" />}>
<TypedAwait resolve={series}>
{(s) => (
<TaskTypePanel
title="Standard tasks"
count={counts.standard}
description="Standard tasks are durable background functions you trigger from your code. Use them for any async work that needs retries, observability, and reliable execution."
example="Process an uploaded video, send a transactional email, or sync data from a third-party API."
data={s.standard}
seriesColor="hsl(200 80% 60%)"
listingPath={v3RunsPath(organization, project, environment)}
docsHref={docsPath("v3/tasks-overview")}
emptyTitle="You don't have any standard tasks yet"
emptyDescription="Standard tasks are the building block of Trigger.dev. Define one in your codebase and trigger it from anywhere."
emptyCta="Create your first task"
exampleCode={STANDARD_EXAMPLE}
/>
)}
</TypedAwait>
</Suspense>
<Suspense fallback={<PanelSkeleton title="Scheduled tasks" />}>
<TypedAwait resolve={series}>
{(s) => (
<TaskTypePanel
title="Scheduled tasks"
count={counts.scheduled}
description="Scheduled tasks run automatically on a cron schedule. Attach as many schedules to a task as you need (e.g. one per customer)."
example="Generate a nightly report, refresh a cache every 5 minutes, or send a weekly digest email."
data={s.scheduled}
seriesColor="hsl(30 90% 60%)"
listingPath={v3EnvironmentPath(organization, project, environment)}
docsHref={docsPath("v3/tasks-scheduled")}
emptyTitle="You don't have any scheduled tasks yet"
emptyDescription="Schedule a task to run on a cron expression. Once you've defined a `schedules.task` you can attach one or many schedules to it."
emptyCta="Create your first scheduled task"
exampleCode={SCHEDULED_EXAMPLE}
/>
)}
</TypedAwait>
</Suspense>
</div>
</div>
</PageBody>
</PageContainer>
);
}
function PanelSkeleton({ title }: { title: string }) {
return (
<Card>
<Card.Header>{title}</Card.Header>
<Card.Content className="flex h-72 items-center justify-center">
<Spinner className="size-6" />
</Card.Content>
</Card>
);
}
type TaskTypePanelProps = {
title: string;
count: number;
description: string;
example: string;
data: DailyRunPoint[];
seriesColor: string;
listingPath: string;
docsHref: string;
emptyTitle: string;
emptyDescription: string;
emptyCta: string;
exampleCode: string;
};
function TaskTypePanel(props: TaskTypePanelProps) {
const {
title,
count,
description,
example,
data,
seriesColor,
listingPath,
docsHref,
emptyTitle,
emptyDescription,
emptyCta,
exampleCode,
} = props;
const hasData = count > 0;
const chartConfig = useMemo<ChartConfig>(
() => ({
count: {
label: "Runs",
color: seriesColor,
},
}),
[seriesColor]
);
return (
<Card>
<Card.Header>
<span className="truncate">{title}</span>
<Card.Accessory>
<span className="shrink-0 whitespace-nowrap text-base font-medium tabular-nums leading-none text-text-bright">
{count.toLocaleString()}
</span>
</Card.Accessory>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
<div className="h-40">
<Chart.Root
config={chartConfig}
data={data}
dataKey="day"
labelFormatter={(value) => formatDay(value as string)}
fillContainer
>
<Chart.Bar
tooltipLabelFormatter={(label) => formatDay(label)}
xAxisProps={{ tickFormatter: (value) => formatDay(value) }}
/>
</Chart.Root>
</div>
{hasData ? (
<>
<Paragraph variant="small" className="px-2 text-text-dimmed">
{description}
</Paragraph>
<div className="flex flex-wrap items-center gap-2 px-2 pb-2 pt-1">
<LinkButton to={listingPath} variant="secondary/small">
View all
</LinkButton>
<LinkButton to={docsHref} variant="docs/small" LeadingIcon={BookOpenIcon}>
Read docs
</LinkButton>
</div>
</>
) : (
<EmptyContent
title={emptyTitle}
description={emptyDescription}
example={example}
listingPath={listingPath}
docsHref={docsHref}
cta={emptyCta}
exampleCode={exampleCode}
/>
)}
</Card.Content>
</Card>
);
}
function EmptyContent({
title,
description,
example,
listingPath,
docsHref,
cta,
exampleCode,
}: {
title: string;
description: string;
example: string;
listingPath: string;
docsHref: string;
cta: string;
exampleCode: string;
}) {
const copyExample = () => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(exampleCode).catch(() => {
/* swallow */
});
}
};
return (
<>
<Header3 className="px-2 text-text-bright">{title}</Header3>
<Paragraph variant="small" className="px-2 text-text-dimmed">
{description}
</Paragraph>
<div className="flex flex-wrap items-center gap-2 px-2 pb-2 pt-1">
<LinkButton to={listingPath} variant="primary/small">
{cta}
</LinkButton>
<button
type="button"
onClick={copyExample}
className="inline-flex items-center rounded border border-grid-bright bg-background-bright px-2 py-1 text-xs text-text-dimmed transition-colors hover:text-text-bright"
>
Copy example code
</button>
<LinkButton to={docsHref} variant="docs/small" LeadingIcon={BookOpenIcon}>
Read docs
</LinkButton>
</div>
</>
);
}