diff --git a/apps/webapp/app/components/event/EventDetail.tsx b/apps/webapp/app/components/event/EventDetail.tsx
new file mode 100644
index 0000000000..7b5c30a4d6
--- /dev/null
+++ b/apps/webapp/app/components/event/EventDetail.tsx
@@ -0,0 +1,45 @@
+import { CodeBlock } from "../code/CodeBlock";
+import { DateTime } from "../primitives/DateTime";
+import { Header3 } from "../primitives/Headers";
+import {
+ RunPanel,
+ RunPanelBody,
+ RunPanelDivider,
+ RunPanelIconProperty,
+ RunPanelIconSection,
+} from "~/components/run/RunCard";
+import { Event } from "~/presenters/EventPresenter.server";
+
+export function EventDetail({ event }: { event: Event }) {
+ const { id, name, payload, context, timestamp, deliveredAt } = event;
+
+ return (
+
+
+
+ }
+ />
+ {deliveredAt && (
+ }
+ />
+ )}
+
+
+
+
+
+ Payload
+
+ Context
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/events/EventStatuses.tsx b/apps/webapp/app/components/events/EventStatuses.tsx
new file mode 100644
index 0000000000..a9240077f6
--- /dev/null
+++ b/apps/webapp/app/components/events/EventStatuses.tsx
@@ -0,0 +1,8 @@
+import { z } from "zod";
+import { DirectionSchema, FilterableEnvironment } from "~/components/runs/RunStatuses";
+
+export const EventListSearchSchema = z.object({
+ cursor: z.string().optional(),
+ direction: DirectionSchema.optional(),
+ environment: FilterableEnvironment.optional(),
+});
diff --git a/apps/webapp/app/components/events/EventsFilters.tsx b/apps/webapp/app/components/events/EventsFilters.tsx
new file mode 100644
index 0000000000..7ee8b9f29e
--- /dev/null
+++ b/apps/webapp/app/components/events/EventsFilters.tsx
@@ -0,0 +1,67 @@
+import { useNavigate } from "@remix-run/react";
+import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
+import { EnvironmentLabel } from "../environments/EnvironmentLabel";
+import { Paragraph } from "../primitives/Paragraph";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../primitives/Select";
+import { EventListSearchSchema } from "./EventStatuses";
+import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
+
+export function EventsFilters() {
+ const navigate = useNavigate();
+ const location = useOptimisticLocation();
+ const searchParams = new URLSearchParams(location.search);
+ const { environment } = EventListSearchSchema.parse(Object.fromEntries(searchParams.entries()));
+
+ const handleFilterChange = (filterType: string, value: string | undefined) => {
+ if (value) {
+ searchParams.set(filterType, value);
+ } else {
+ searchParams.delete(filterType);
+ }
+ searchParams.delete("cursor");
+ searchParams.delete("direction");
+ navigate(`${location.pathname}?${searchParams.toString()}`);
+ };
+
+ const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
+ handleFilterChange("environment", value === "ALL" ? undefined : value);
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/events/EventsTable.tsx b/apps/webapp/app/components/events/EventsTable.tsx
new file mode 100644
index 0000000000..f0f4c1e046
--- /dev/null
+++ b/apps/webapp/app/components/events/EventsTable.tsx
@@ -0,0 +1,129 @@
+import { StopIcon } from "@heroicons/react/24/outline";
+import { CheckIcon } from "@heroicons/react/24/solid";
+import { RuntimeEnvironmentType, User } from "@trigger.dev/database";
+import { EnvironmentLabel } from "../environments/EnvironmentLabel";
+import { DateTime } from "../primitives/DateTime";
+import { Paragraph } from "../primitives/Paragraph";
+import { Spinner } from "../primitives/Spinner";
+import {
+ Table,
+ TableBlankRow,
+ TableBody,
+ TableCell,
+ TableCellChevron,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+} from "../primitives/Table";
+
+type EventTableItem = {
+ id: string;
+ name: string | null;
+ environment: {
+ type: RuntimeEnvironmentType;
+ userId?: string;
+ userName?: string;
+ };
+ createdAt: Date | null;
+ isTest: boolean;
+ deliverAt: Date | null;
+ deliveredAt: Date | null;
+ runs: number;
+};
+
+type EventsTableProps = {
+ total: number;
+ hasFilters: boolean;
+ events: EventTableItem[];
+ isLoading?: boolean;
+ eventsParentPath: string;
+ currentUser: User;
+};
+
+export function EventsTable({
+ total,
+ hasFilters,
+ events,
+ isLoading = false,
+ eventsParentPath,
+ currentUser,
+}: EventsTableProps) {
+ return (
+
+
+
+ Event
+ Env
+ Received Time
+ Delivery Time
+ Delivered
+ Test
+ Runs
+
+ Go to page
+
+
+
+
+ {total === 0 && !hasFilters ? (
+
+
+
+ ) : events.length === 0 ? (
+
+
+
+ ) : (
+ events.map((event) => {
+ const path = `${eventsParentPath}/events/${event.id}`;
+ const usernameForEnv =
+ currentUser.id !== event.environment.userId ? event.environment.userName : undefined;
+
+ return (
+
+ {typeof event.name === "string" ? event.name : "-"}
+
+
+
+
+ {event.createdAt ? : "–"}
+
+
+ {event.deliverAt ? : "–"}
+
+
+ {event.deliveredAt ? : "–"}
+
+
+ {event.isTest ? (
+
+ ) : (
+
+ )}
+
+ {event.runs}
+
+
+ );
+ })
+ )}
+ {isLoading && (
+
+ Loading…
+
+ )}
+
+
+ );
+}
+
+function NoEvents({ title }: { title: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 49c44c5961..20929438fb 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -3,6 +3,7 @@ import {
ArrowRightIcon,
ArrowRightOnRectangleIcon,
ChartBarIcon,
+ CursorArrowRaysIcon,
EllipsisHorizontalIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
@@ -31,6 +32,7 @@ import {
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
+ projectEventsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
@@ -144,6 +146,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
data-action="triggers"
hasWarning={project.hasInactiveExternalTriggers}
/>
+
>;
+
+export class EventListPresenter {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ userId,
+ organizationSlug,
+ projectSlug,
+ filterEnvironment,
+ direction = "forward",
+ cursor,
+ pageSize = DEFAULT_PAGE_SIZE,
+ }: EventListOptions) {
+ const directionMultiplier = direction === "forward" ? 1 : -1;
+
+ // Find the organization that the user is a member of
+ const organization = await this.#prismaClient.organization.findFirstOrThrow({
+ where: {
+ slug: organizationSlug,
+ members: { some: { userId } },
+ },
+ });
+
+ // Find the project scoped to the organization
+ const project = await this.#prismaClient.project.findFirstOrThrow({
+ where: {
+ slug: projectSlug,
+ organizationId: organization.id,
+ },
+ });
+
+ // Find all runtimeEnvironments that the user has access to
+ const environments = await this.#prismaClient.runtimeEnvironment.findMany({
+ where: {
+ projectId: project.id,
+ },
+ });
+
+ const events = await this.#prismaClient.eventRecord.findMany({
+ select: {
+ id: true,
+ name: true,
+ deliverAt: true,
+ deliveredAt: true,
+ isTest: true,
+ createdAt: true,
+ environment: {
+ select: {
+ type: true,
+ slug: true,
+ orgMember: {
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ displayName: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ runs: {
+ select: {
+ id: true,
+ },
+ },
+ },
+ where: {
+ projectId: project.id,
+ organizationId: organization.id,
+ environmentId: {
+ in: environments.map((environment) => environment.id),
+ },
+ environment: filterEnvironment ? { type: filterEnvironment } : undefined,
+ },
+ orderBy: [{ id: "desc" }],
+ //take an extra record to tell if there are more
+ take: directionMultiplier * (pageSize + 1),
+ //skip the cursor if there is one
+ skip: cursor ? 1 : 0,
+ cursor: cursor
+ ? {
+ id: cursor,
+ }
+ : undefined,
+ });
+
+ const hasMore = events.length > pageSize;
+
+ //get cursors for next and previous pages
+ let next: string | undefined;
+ let previous: string | undefined;
+ switch (direction) {
+ case "forward":
+ previous = cursor ? events.at(0)?.id : undefined;
+ if (hasMore) {
+ next = events[pageSize - 1]?.id;
+ }
+ break;
+ case "backward":
+ if (hasMore) {
+ previous = events[1]?.id;
+ next = events[pageSize]?.id;
+ } else {
+ next = events[pageSize - 1]?.id;
+ }
+ break;
+ }
+
+ const eventsToReturn =
+ direction === "backward" && hasMore
+ ? events.slice(1, pageSize + 1)
+ : events.slice(0, pageSize);
+
+ return {
+ events: eventsToReturn.map((event) => ({
+ id: event.id,
+ name: event.name,
+ deliverAt: event.deliverAt,
+ deliveredAt: event.deliveredAt,
+ createdAt: event.createdAt,
+ isTest: event.isTest,
+ environment: {
+ type: event.environment.type,
+ slug: event.environment.slug,
+ userId: event.environment.orgMember?.user.id,
+ userName: getUsername(event.environment.orgMember?.user),
+ },
+ runs: event.runs.length,
+ })),
+ pagination: {
+ next,
+ previous,
+ },
+ };
+ }
+}
diff --git a/apps/webapp/app/presenters/EventPresenter.server.ts b/apps/webapp/app/presenters/EventPresenter.server.ts
new file mode 100644
index 0000000000..8c569ceca0
--- /dev/null
+++ b/apps/webapp/app/presenters/EventPresenter.server.ts
@@ -0,0 +1,68 @@
+import { PrismaClient, prisma } from "~/db.server";
+
+export type Event = NonNullable>>;
+
+export class EventPresenter {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ userId,
+ projectSlug,
+ organizationSlug,
+ eventId,
+ }: {
+ userId: string;
+ projectSlug: string;
+ organizationSlug: string;
+ eventId: string;
+ }) {
+ // Find the organization that the user is a member of
+ const organization = await this.#prismaClient.organization.findFirstOrThrow({
+ where: {
+ slug: organizationSlug,
+ members: { some: { userId } },
+ },
+ });
+
+ // Find the project scoped to the organization
+ const project = await this.#prismaClient.project.findFirstOrThrow({
+ where: {
+ slug: projectSlug,
+ organizationId: organization.id,
+ },
+ });
+
+ const event = await this.#prismaClient.eventRecord.findFirst({
+ select: {
+ id: true,
+ name: true,
+ payload: true,
+ context: true,
+ timestamp: true,
+ deliveredAt: true,
+ },
+ where: {
+ id: eventId,
+ projectId: project.id,
+ organizationId: organization.id,
+ },
+ });
+
+ if (!event) {
+ throw new Error("Could not find Event");
+ }
+
+ return {
+ id: event.id,
+ name: event.name,
+ timestamp: event.timestamp,
+ payload: JSON.stringify(event.payload, null, 2),
+ context: JSON.stringify(event.context, null, 2),
+ deliveredAt: event.deliveredAt,
+ };
+ }
+}
diff --git a/apps/webapp/app/presenters/RunListPresenter.server.ts b/apps/webapp/app/presenters/RunListPresenter.server.ts
index ae6a841ded..09f6692868 100644
--- a/apps/webapp/app/presenters/RunListPresenter.server.ts
+++ b/apps/webapp/app/presenters/RunListPresenter.server.ts
@@ -10,6 +10,7 @@ import { getUsername } from "~/utils/username";
type RunListOptions = {
userId: string;
+ eventId?: string;
jobSlug?: string;
organizationSlug: string;
projectSlug: string;
@@ -33,6 +34,7 @@ export class RunListPresenter {
public async call({
userId,
+ eventId,
jobSlug,
organizationSlug,
projectSlug,
@@ -78,6 +80,10 @@ export class RunListPresenter {
})
: undefined;
+ const event = eventId
+ ? await this.#prismaClient.eventRecord.findUnique({ where: { id: eventId } })
+ : undefined;
+
const runs = await this.#prismaClient.jobRun.findMany({
select: {
id: true,
@@ -118,6 +124,7 @@ export class RunListPresenter {
},
},
where: {
+ eventId: event?.id,
jobId: job?.id,
projectId: project.id,
organizationId: organization.id,
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events.$eventParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events.$eventParam/route.tsx
new file mode 100644
index 0000000000..d525c61213
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events.$eventParam/route.tsx
@@ -0,0 +1,129 @@
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
+import { requireUserId } from "~/services/session.server";
+import { EventParamSchema, projectEventsPath, projectPath } from "~/utils/pathBuilder";
+import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
+import { Handle } from "~/utils/handle";
+import { EventDetail } from "~/components/event/EventDetail";
+import { EventPresenter } from "~/presenters/EventPresenter.server";
+import { useTypedMatchData } from "~/hooks/useTypedMatchData";
+import { Fragment } from "react";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { RunListSearchSchema } from "~/components/runs/RunStatuses";
+import { RunListPresenter } from "~/presenters/RunListPresenter.server";
+import { RunsTable } from "~/components/runs/RunsTable";
+import { RunsFilters } from "~/components/runs/RunFilters";
+import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
+import { useUser } from "~/hooks/useUser";
+import { useNavigation } from "@remix-run/react";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { eventParam, projectParam, organizationSlug } = EventParamSchema.parse(params);
+
+ const url = new URL(request.url);
+ const s = Object.fromEntries(url.searchParams.entries());
+ const searchParams = RunListSearchSchema.parse(s);
+
+ const presenter = new EventPresenter();
+ try {
+ const event = await presenter.call({
+ userId,
+ projectSlug: projectParam,
+ organizationSlug,
+ eventId: eventParam,
+ });
+
+ if (!event) {
+ throw new Response("Not Found", { status: 404 });
+ }
+
+ const runsPresenter = new RunListPresenter();
+
+ const list = await runsPresenter.call({
+ userId,
+ filterEnvironment: searchParams.environment,
+ filterStatus: searchParams.status,
+ eventId: event.id,
+ projectSlug: projectParam,
+ organizationSlug,
+ direction: searchParams.direction,
+ cursor: searchParams.cursor,
+ });
+
+ return typedjson({ event, list });
+ } catch (e) {
+ console.log(e);
+ throw new Response(e instanceof Error ? e.message : JSON.stringify(e), { status: 404 });
+ }
+};
+
+export const handle: Handle = {
+ breadcrumb: (match) => {
+ const eventData = useTypedMatchData(match);
+
+ return (
+
+ {eventData && eventData.event && (
+
+ )}
+
+ );
+ },
+};
+
+export default function Page() {
+ const { event, list } = useTypedLoaderData();
+ const navigation = useNavigation();
+ const isLoading = navigation.state !== "idle";
+ const organization = useOrganization();
+ const project = useProject();
+ const user = useUser();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events._index/route.tsx
new file mode 100644
index 0000000000..48e3181535
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events._index/route.tsx
@@ -0,0 +1,93 @@
+import { useNavigation } from "@remix-run/react";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { LinkButton } from "~/components/primitives/Buttons";
+import {
+ PageButtons,
+ PageDescription,
+ PageHeader,
+ PageTitle,
+ PageTitleRow,
+} from "~/components/primitives/PageHeader";
+import { EventsTable } from "~/components/events/EventsTable";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { EventListPresenter } from "~/presenters/EventListPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { ProjectParamSchema, docsPath, projectPath, trimTrailingSlash } from "~/utils/pathBuilder";
+import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
+import { EventListSearchSchema } from "~/components/events/EventStatuses";
+import { useUser } from "~/hooks/useUser";
+import { EventsFilters } from "~/components/events/EventsFilters";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
+
+ const url = new URL(request.url);
+ const s = Object.fromEntries(url.searchParams.entries());
+ const searchParams = EventListSearchSchema.parse(s);
+
+ const presenter = new EventListPresenter();
+ const list = await presenter.call({
+ userId,
+ filterEnvironment: searchParams.environment,
+ projectSlug: projectParam,
+ organizationSlug,
+ direction: searchParams.direction,
+ cursor: searchParams.cursor,
+ pageSize: 25,
+ });
+
+ return typedjson({
+ list,
+ });
+};
+
+export default function Page() {
+ const { list } = useTypedLoaderData();
+ const navigation = useNavigation();
+ const isLoading = navigation.state !== "idle";
+ const organization = useOrganization();
+ const project = useProject();
+ const user = useUser();
+
+ return (
+
+
+
+
+
+
+ Event documentation
+
+
+
+ All events in this project
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events/route.tsx
new file mode 100644
index 0000000000..5385d1eb71
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events/route.tsx
@@ -0,0 +1,11 @@
+import { Outlet } from "@remix-run/react";
+import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
+import { Handle } from "~/utils/handle";
+
+export const handle: Handle = {
+ breadcrumb: (match) => ,
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx
index a2ea7f9b09..1bb4c81879 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx
@@ -1,5 +1,6 @@
import { useLocation } from "@remix-run/react";
import { LinkButton } from "~/components/primitives/Buttons";
+import { EventList } from "~/presenters/EventListPresenter.server";
import { Direction } from "~/components/runs/RunStatuses";
import { RunList } from "~/presenters/RunListPresenter.server";
import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server";
@@ -9,7 +10,7 @@ export function ListPagination({
list,
className,
}: {
- list: RunList | WebhookDeliveryList;
+ list: RunList | WebhookDeliveryList | EventList;
className?: string;
}) {
return (
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index 855a7183fe..b12dcaad51 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -1,4 +1,5 @@
import type {
+ EventRecord,
Integration,
TriggerHttpEndpoint,
TriggerSource,
@@ -15,6 +16,7 @@ export type JobForPath = Pick;
export type RunForPath = Pick;
export type IntegrationForPath = Pick;
export type TriggerForPath = Pick;
+export type EventForPath = Pick;
export type WebhookForPath = Pick;
export type HttpEndpointForPath = Pick;
@@ -46,6 +48,10 @@ export const TriggerSourceParamSchema = ProjectParamSchema.extend({
triggerParam: z.string(),
});
+export const EventParamSchema = ProjectParamSchema.extend({
+ eventParam: z.string(),
+});
+
export const TriggerSourceRunParamsSchema = TriggerSourceParamSchema.extend({
runParam: z.string(),
});
@@ -202,6 +208,18 @@ export function projectTriggersPath(organization: OrgForPath, project: ProjectFo
return `${projectPath(organization, project)}/triggers`;
}
+export function projectEventsPath(organization: OrgForPath, project: ProjectForPath) {
+ return `${projectPath(organization, project)}/events`;
+}
+
+export function projectEventPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ event: EventForPath
+) {
+ return `${projectEventsPath(organization, project)}/${event.id}`;
+}
+
export function projectHttpEndpointsPath(organization: OrgForPath, project: ProjectForPath) {
return `${projectPath(organization, project)}/http-endpoints`;
}