343 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|