From 4942e3585d39347523cc3cdb99c3e0e77b62fd3c Mon Sep 17 00:00:00 2001 From: Kritik Jiyaviya Date: Fri, 5 Jan 2024 15:23:40 +0530 Subject: [PATCH 1/2] feat: add events list --- .../app/components/event/EventDetail.tsx | 45 +++++ .../app/components/events/EventStatuses.tsx | 8 + .../app/components/events/EventsFilters.tsx | 67 ++++++++ .../app/components/events/EventsTable.tsx | 129 ++++++++++++++ .../app/components/navigation/SideMenu.tsx | 7 + .../presenters/EventListPresenter.server.ts | 160 ++++++++++++++++++ .../app/presenters/EventPresenter.server.ts | 68 ++++++++ .../app/presenters/RunListPresenter.server.ts | 7 + .../route.tsx | 129 ++++++++++++++ .../route.tsx | 93 ++++++++++ .../route.tsx | 11 ++ .../ListPagination.tsx | 3 +- apps/webapp/app/utils/pathBuilder.ts | 18 ++ 13 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/app/components/event/EventDetail.tsx create mode 100644 apps/webapp/app/components/events/EventStatuses.tsx create mode 100644 apps/webapp/app/components/events/EventsFilters.tsx create mode 100644 apps/webapp/app/components/events/EventsTable.tsx create mode 100644 apps/webapp/app/presenters/EventListPresenter.server.ts create mode 100644 apps/webapp/app/presenters/EventPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events.$eventParam/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events._index/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.events/route.tsx 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 ( +
+ {title} +
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 49c44c5961..9339e891eb 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -31,6 +31,7 @@ import { projectHttpEndpointsPath, projectPath, projectRunsPath, + projectEventsPath, projectSetupPath, projectTriggersPath, } from "~/utils/pathBuilder"; @@ -144,6 +145,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`; } From f6833d26c416ef9352a677034849ee182d9dc76b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 5 Jan 2024 11:52:57 +0000 Subject: [PATCH 2/2] Changed the Events icon in the sidemenu --- apps/webapp/app/components/navigation/SideMenu.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 9339e891eb..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"; @@ -147,8 +148,8 @@ export function SideMenu({ user, project, organization, organizations }: SideMen />