From 67c62f7d258aea5ad063901797d2b5be5b16916d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 25 Mar 2024 11:28:21 +0000 Subject: [PATCH 1/2] v3 is restricted to approved orgs (#965) * v3 projects can only be created if the org has permissions to do it * Admin page * Removed unused classes * Admin org page, with links between user and org pages * Set v3 enabled or not --- apps/webapp/app/models/admin.server.ts | 207 +++++++++++++++++- apps/webapp/app/models/project.server.ts | 11 + .../route.tsx | 6 +- .../webapp/app/routes/_app.orgs.new/route.tsx | 5 +- apps/webapp/app/routes/admin._index.tsx | 194 +++++++++------- apps/webapp/app/routes/admin.orgs.tsx | 160 ++++++++++++++ apps/webapp/app/routes/admin.tsx | 20 ++ .../migration.sql | 2 + packages/database/prisma/schema.prisma | 2 + 9 files changed, 527 insertions(+), 80 deletions(-) create mode 100644 apps/webapp/app/routes/admin.orgs.tsx create mode 100644 packages/database/prisma/migrations/20240322165042_organization_v3_enabled_defaults_to_false/migration.sql diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index f7cfcd593..9fae4fc56 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -1,9 +1,212 @@ import { prisma } from "~/db.server"; +import { SearchParams } from "~/routes/admin._index"; -export async function adminGetUsers() { - return await prisma.user.findMany({ +const pageSize = 20; + +export async function adminGetUsers(userId: string, { page, search }: SearchParams) { + page = page || 1; + + search = search ? decodeURIComponent(search) : undefined; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (user?.admin !== true) { + throw new Error("Unauthorized"); + } + + const users = await prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + admin: true, + createdAt: true, + displayName: true, + orgMemberships: { + select: { + organization: { + select: { + title: true, + slug: true, + }, + }, + }, + }, + }, + where: search + ? { + OR: [ + { + name: { + contains: search, + mode: "insensitive", + }, + }, + { + email: { + contains: search, + mode: "insensitive", + }, + }, + { + orgMemberships: { + some: { + organization: { + title: { + contains: search, + mode: "insensitive", + }, + }, + }, + }, + }, + { + orgMemberships: { + some: { + organization: { + slug: { + contains: search, + mode: "insensitive", + }, + }, + }, + }, + }, + ], + } + : undefined, orderBy: { createdAt: "desc", }, + take: pageSize, + skip: (page - 1) * pageSize, + }); + + const totalUsers = await prisma.user.count(); + + return { + users, + page, + pageCount: Math.ceil(totalUsers / pageSize), + filters: { + search, + }, + }; +} + +export async function adminGetOrganizations(userId: string, { page, search }: SearchParams) { + page = page || 1; + + search = search ? decodeURIComponent(search) : undefined; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (user?.admin !== true) { + throw new Error("Unauthorized"); + } + + const organizations = await prisma.organization.findMany({ + select: { + id: true, + slug: true, + title: true, + v3Enabled: true, + members: { + select: { + user: { + select: { + email: true, + }, + }, + }, + }, + }, + where: search + ? { + OR: [ + { + members: { + some: { + user: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + }, + }, + { + members: { + some: { + user: { + email: { + contains: search, + mode: "insensitive", + }, + }, + }, + }, + }, + { + slug: { + contains: search, + mode: "insensitive", + }, + }, + { + title: { + contains: search, + mode: "insensitive", + }, + }, + ], + } + : undefined, + orderBy: { + createdAt: "desc", + }, + take: pageSize, + skip: (page - 1) * pageSize, + }); + + const totalOrgs = await prisma.organization.count(); + + return { + organizations, + page, + pageCount: Math.ceil(totalOrgs / pageSize), + filters: { + search, + }, + }; +} + +export async function setV3Enabled(userId: string, id: string, v3Enabled: boolean) { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (user?.admin !== true) { + throw new Error("Unauthorized"); + } + + return prisma.organization.update({ + where: { + id, + }, + data: { + v3Enabled, + }, }); } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 817466e50..d047a3e08 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -3,6 +3,7 @@ import slug from "slug"; import { prisma } from "~/db.server"; import type { Project } from "@trigger.dev/database"; import { Organization, createEnvironment } from "./organization.server"; +import { env } from "~/env.server"; export type { Project } from "@trigger.dev/database"; const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20); @@ -30,6 +31,16 @@ export async function createProject( ); } + if (version === "v3") { + if (!organization.v3Enabled) { + throw new Error(`Organization can't create v3 projects.`); + } + + if (!env.V3_ENABLED) { + throw new Error(`v3 is not available yet.`); + } + } + //ensure the slug is globally unique const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`; const projectWithSameSlug = await prisma.project.findFirst({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index dc3a39bba..eb7bad6ad 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -40,6 +40,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { select: { id: true, title: true, + v3Enabled: true, _count: { select: { projects: { @@ -62,6 +63,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { title: organization.title, slug: organizationSlug, projectsCount: organization._count.projects, + v3Enabled: organization.v3Enabled, }, defaultVersion: url.searchParams.get("version") ?? "v2", }); @@ -107,6 +109,8 @@ export default function NewOrganizationPage() { const lastSubmission = useActionData(); const { v3Enabled } = useFeatures(); + const canCreateV3Projects = organization.v3Enabled && v3Enabled; + const [form, { projectName, projectVersion }] = useForm({ id: "create-project", // TODO: type this @@ -141,7 +145,7 @@ export default function NewOrganizationPage() { /> {projectName.error} - {v3Enabled ? ( + {canCreateV3Projects ? ( diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index cf199725d..563215901 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -95,9 +95,12 @@ export const action: ActionFunction = async ({ request }) => { export default function NewOrganizationPage() { const { hasOrganizations } = useTypedLoaderData(); const lastSubmission = useActionData(); - const { isManagedCloud, v3Enabled } = useFeatures(); + const { isManagedCloud } = useFeatures(); const navigation = useNavigation(); + //this is temporary whilst v3 is invite-only. Switch to the useFeatures value when v3 is generally available. + const v3Enabled = false; + const [form, { orgName, projectName, projectVersion }] = useForm({ id: "create-organization", // TODO: type this diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 41038c13f..35865155b 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -1,18 +1,47 @@ +import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Header1 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers } from "~/models/admin.server"; import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; +import { requireUserId } from "~/services/session.server"; +import { createSearchParams } from "~/utils/searchParams"; -export async function loader() { - const users = await adminGetUsers(); +export const SearchParams = z.object({ + page: z.coerce.number().optional(), + search: z.string().optional(), +}); - return typedjson({ users }); -} +export type SearchParams = z.infer; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) { + throw new Error(searchParams.error); + } + const result = await adminGetUsers(userId, searchParams.params.getAll()); + + return typedjson(result); +}; const FormSchema = z.object({ id: z.string() }); @@ -30,87 +59,100 @@ export async function action({ request }: ActionFunctionArgs) { headers: { "Set-Cookie": await commitImpersonationSession(session) }, }); } - -const headerClassName = - "py-3 px-2 pr-3 text-xs font-semibold leading-tight text-text-bright text-left"; -const cellClassName = "whitespace-nowrap px-2 py-2 text-xs text-text-bright"; - export default function AdminDashboardRoute() { const user = useUser(); - const { users } = useTypedLoaderData(); + const { users, filters, page, pageCount } = useTypedLoaderData(); return (
-

Accounts ({users.length})

+
+
+ + +
- - Back to me - + + + + Email + Orgs + GitHub + id + Created + Admin? + Actions + + + + {users.length === 0 ? ( + + No users found for search + + ) : ( + users.map((user) => { + return ( + + {user.email} + + {user.orgMemberships.map((org) => ( + + {org.organization.title} ({org.organization.slug}) + + ))} + + + + {user.displayName} + + + {user.id} + {user.createdAt.toISOString()} + {user.admin ? "✅" : ""} + +
+ -
- - - - - - - - - - - - {users.map((user) => { - return ( - - - - - - -
- Email - - GitHub username - - id - - Created At - - Admin? - - Actions -
{user.email} - - {user.displayName} - - {user.id}{user.createdAt.toISOString()}{user.admin ? "✅" : ""} - - + + + + + ); + }) + )} + +
- - - - - ); - })} - - + +
); } diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx new file mode 100644 index 000000000..3c0321213 --- /dev/null +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -0,0 +1,160 @@ +import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; +import { Form } from "@remix-run/react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Header1 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { useUser } from "~/hooks/useUser"; +import { adminGetOrganizations, adminGetUsers, setV3Enabled } from "~/models/admin.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; +import { requireUserId } from "~/services/session.server"; +import { createSearchParams } from "~/utils/searchParams"; + +export const SearchParams = z.object({ + page: z.coerce.number().optional(), + search: z.string().optional(), +}); + +export type SearchParams = z.infer; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) { + throw new Error(searchParams.error); + } + const result = await adminGetOrganizations(userId, searchParams.params.getAll()); + + return typedjson(result); +}; + +const FormSchema = z.object({ id: z.string(), v3: z.enum(["enable", "disable"]) }); + +export async function action({ request }: ActionFunctionArgs) { + if (request.method.toLowerCase() !== "post") { + return new Response("Method not allowed", { status: 405 }); + } + + const userId = await requireUserId(request); + + const payload = Object.fromEntries(await request.formData()); + + const { id, v3 } = FormSchema.parse(payload); + + const result = await setV3Enabled(userId, id, v3 === "enable"); + + return redirectWithSuccessMessage("/admin/orgs", request, `v3 ${v3}d for org ${id}`); +} +export default function AdminDashboardRoute() { + const { organizations, filters, page, pageCount } = useTypedLoaderData(); + + return ( +
+
+
+ + +
+ + + + + Name + Slug + Members + id + v3? + Actions + + + + {organizations.length === 0 ? ( + + No orgs found for search + + ) : ( + organizations.map((org) => { + return ( + + {org.title} + {org.slug} + + {org.members.map((member) => ( + + {member.user.email} + + ))} + + {org.id} + {org.v3Enabled ? "✅" : ""} + +
+ + + {org.v3Enabled ? ( + + ) : ( + + )} + +
+
+ ); + }) + )} +
+
+ + +
+
+ ); +} diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index b0da203a4..e7803e353 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -1,6 +1,8 @@ import { Outlet } from "@remix-run/react"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect, typedjson } from "remix-typedjson"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Tabs } from "~/components/primitives/Tabs"; import { getUser, requireUserId } from "~/services/session.server"; export async function loader({ request }: LoaderFunctionArgs) { @@ -20,6 +22,24 @@ export async function loader({ request }: LoaderFunctionArgs) { export default function Page() { return (
+
+ + + Back to me + +
); diff --git a/packages/database/prisma/migrations/20240322165042_organization_v3_enabled_defaults_to_false/migration.sql b/packages/database/prisma/migrations/20240322165042_organization_v3_enabled_defaults_to_false/migration.sql new file mode 100644 index 000000000..67bfebc63 --- /dev/null +++ b/packages/database/prisma/migrations/20240322165042_organization_v3_enabled_defaults_to_false/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "v3Enabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 43ca5a208..750a98697 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -119,6 +119,8 @@ model Organization { runsEnabled Boolean @default(true) + v3Enabled Boolean @default(false) + environments RuntimeEnvironment[] connections IntegrationConnection[] endpoints Endpoint[] From 4266b308e7c98773f7063e564dcede949c9ef5cb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 25 Mar 2024 11:28:35 +0000 Subject: [PATCH 2/2] Link from `trigger()` to a run. Link back to the triggering run. (#967) * Schema for SpanMessagingEvents * RunFilters status map was missing the key * Link from a trigger to the run * Two-way links working --- .../app/components/runs/v3/RunFilters.tsx | 4 +- .../route.tsx | 64 ++++++++++++++++--- .../route.tsx | 35 ++++++++++ apps/webapp/app/utils/pathBuilder.ts | 9 +++ apps/webapp/app/v3/eventRepository.server.ts | 48 ++++++++++++++ packages/core/src/v3/schemas/openTelemetry.ts | 10 +++ 6 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.traces.$traceId.spans.$spanId/route.tsx diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 2e28d379a..cfbbce85b 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -190,10 +190,10 @@ export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersP {allTaskRunStatuses.map((status) => ( - + - + { const userId = await requireUserId(request); @@ -137,6 +132,17 @@ export default function Page() { )} + {event.links && event.links.length > 0 && ( +
+ Links +
+ {event.links.map((link, index) => ( + + ))} +
+
+ )} + {event.events !== undefined && } {event.payload !== undefined && ( @@ -284,3 +290,41 @@ function classNameForState(state: TimelineState) { } } } + +function SpanLinkElement({ link }: { link: SpanLink }) { + const organization = useOrganization(); + const project = useProject(); + + switch (link.type) { + case "run": { + return ( + + {link.title} + + ); + } + case "span": { + return ( + + {link.title} + + ); + } + } + + return null; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.traces.$traceId.spans.$spanId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.traces.$traceId.spans.$spanId/route.tsx new file mode 100644 index 000000000..8ca0b967c --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.traces.$traceId.spans.$spanId/route.tsx @@ -0,0 +1,35 @@ +import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; +import { v3RunSpanPath } from "~/utils/pathBuilder"; +import { eventRepository } from "~/v3/eventRepository.server"; + +const ParamsSchema = z.object({ + organizationSlug: z.string(), + projectParam: z.string(), + traceId: z.string(), + spanId: z.string(), +}); + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + + const validatedParams = ParamsSchema.parse(params); + + const trace = await eventRepository.getTraceSummary(validatedParams.traceId); + + if (!trace) { + return new Response("Not found", { status: 404 }); + } + + // Redirect to the project's runs page + return redirect( + v3RunSpanPath( + { slug: validatedParams.organizationSlug }, + { slug: validatedParams.projectParam }, + { friendlyId: trace.rootSpan.runId }, + { spanId: validatedParams.spanId } + ) + ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 50a705b58..1810ba3cd 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -365,6 +365,15 @@ export function v3RunSpanPath( return `${v3RunPath(organization, project, run)}/spans/${span.spanId}`; } +export function v3TraceSpanPath( + organization: OrgForPath, + project: ProjectForPath, + traceId: string, + spanId: string +) { + return `${v3ProjectPath(organization, project)}/traces/${traceId}/spans/${spanId}`; +} + export function v3RunStreamingPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository.server.ts index 54bea61a0..6341c56d7 100644 --- a/apps/webapp/app/v3/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository.server.ts @@ -7,6 +7,7 @@ import { SemanticInternalAttributes, SpanEvent, SpanEvents, + SpanMessagingEvent, TaskEventStyle, correctErrorStackTrace, flattenAttributes, @@ -96,6 +97,21 @@ export type PreparedEvent = Omit 0) { + backLinks.forEach((l) => { + links.push({ + type: "span", + icon: "trigger", + title: `Triggered by`, + traceId: l.context.traceId, + spanId: l.context.spanId, + }); + }); + } + const events = transformEvents(span.data.events, fullEvent.metadata as Attributes); return { @@ -374,6 +421,7 @@ export class EventRepository { properties, events, show, + links, }; } diff --git a/packages/core/src/v3/schemas/openTelemetry.ts b/packages/core/src/v3/schemas/openTelemetry.ts index fdf078ad3..140f1c45b 100644 --- a/packages/core/src/v3/schemas/openTelemetry.ts +++ b/packages/core/src/v3/schemas/openTelemetry.ts @@ -51,3 +51,13 @@ export function isExceptionSpanEvent(event: SpanEvent): event is ExceptionSpanEv export function isCancellationSpanEvent(event: SpanEvent): event is CancellationSpanEvent { return event.name === "cancellation"; } + +export const SpanMessagingEvent = z.object({ + system: z.string().optional(), + client_id: z.string().optional(), + operation: z.enum(["publish", "create", "receive", "deliver"]), + message: z.any(), + destination: z.string().optional(), +}); + +export type SpanMessagingEvent = z.infer;