Merge branch 'main' into v3/infra-updates
This commit is contained in:
@@ -190,10 +190,10 @@ export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersP
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{allTaskRunStatuses.map((status) => (
|
||||
<TooltipProvider>
|
||||
<TooltipProvider key={status}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<SelectItem key={status} value={status} className="">
|
||||
<SelectItem value={status} className="">
|
||||
<TaskRunStatusCombo
|
||||
status={status}
|
||||
className="text-xs"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
+54
-10
@@ -1,5 +1,5 @@
|
||||
import { QueueListIcon, StopCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher, useParams } from "@remix-run/react";
|
||||
import { useParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -8,17 +8,11 @@ import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
@@ -31,7 +25,8 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3RunPath, v3RunSpanPath, v3SpanParamsSchema } from "~/utils/pathBuilder";
|
||||
import { v3RunPath, v3RunSpanPath, v3SpanParamsSchema, v3TraceSpanPath } from "~/utils/pathBuilder";
|
||||
import { SpanLink } from "~/v3/eventRepository.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -137,6 +132,17 @@ export default function Page() {
|
||||
)}
|
||||
</PropertyTable>
|
||||
|
||||
{event.links && event.links.length > 0 && (
|
||||
<div>
|
||||
<Header2 spacing>Links</Header2>
|
||||
<div className="space-y-1">
|
||||
{event.links.map((link, index) => (
|
||||
<SpanLinkElement key={index} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.events !== undefined && <SpanEvents spanEvents={event.events} />}
|
||||
{event.payload !== undefined && (
|
||||
<CodeBlock rowTitle="Payload" code={event.payload} maxLines={20} />
|
||||
@@ -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 (
|
||||
<LinkButton
|
||||
to={v3RunPath(organization, project, { friendlyId: link.runId })}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={link.icon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
{link.title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
case "span": {
|
||||
return (
|
||||
<LinkButton
|
||||
to={v3TraceSpanPath(organization, project, link.traceId, link.spanId)}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={link.icon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
{link.title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+35
@@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
{v3Enabled ? (
|
||||
{canCreateV3Projects ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectVersion.id}>Project version</Label>
|
||||
<SelectGroup>
|
||||
|
||||
@@ -95,9 +95,12 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
export default function NewOrganizationPage() {
|
||||
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
|
||||
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
|
||||
|
||||
@@ -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<typeof SearchParams>;
|
||||
|
||||
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<typeof loader>();
|
||||
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<main
|
||||
aria-labelledby="primary-heading"
|
||||
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto p-4 lg:order-last"
|
||||
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last"
|
||||
>
|
||||
<h1 className="mb-2 text-2xl">Accounts ({users.length})</h1>
|
||||
<div className=" space-y-4">
|
||||
<Form className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search users or orgs"
|
||||
variant="small"
|
||||
icon={MagnifyingGlassIcon}
|
||||
fullWidth={true}
|
||||
name="search"
|
||||
defaultValue={filters.search}
|
||||
/>
|
||||
<Button type="submit" variant="tertiary/small">
|
||||
Search
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<LinkButton to="/" variant="secondary/small" className="mb-4">
|
||||
Back to me
|
||||
</LinkButton>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Email</TableHeaderCell>
|
||||
<TableHeaderCell>Orgs</TableHeaderCell>
|
||||
<TableHeaderCell>GitHub</TableHeaderCell>
|
||||
<TableHeaderCell>id</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Admin?</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<Paragraph>No users found for search</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
users.map((user) => {
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
{user.orgMemberships.map((org) => (
|
||||
<LinkButton
|
||||
key={org.organization.slug}
|
||||
variant="minimal/small"
|
||||
to={`/admin/orgs?search=${encodeURIComponent(org.organization.slug)}`}
|
||||
>
|
||||
{org.organization.title} ({org.organization.slug})
|
||||
</LinkButton>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://github.com/${user.displayName}`}
|
||||
target="_blank"
|
||||
className="text-indigo-500 underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{user.displayName}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.createdAt.toISOString()}</TableCell>
|
||||
<TableCell>{user.admin ? "✅" : ""}</TableCell>
|
||||
<TableCell isSticky={true}>
|
||||
<Form method="post" reloadDocument>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
|
||||
<table className="divide-border w-full divide-y">
|
||||
<thead className="bg-midnight-800 sticky -top-4 text-left">
|
||||
<tr>
|
||||
<th scope="col" className={headerClassName}>
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className={headerClassName}>
|
||||
GitHub username
|
||||
</th>
|
||||
<th scope="col" className={headerClassName}>
|
||||
id
|
||||
</th>
|
||||
<th scope="col" className={headerClassName}>
|
||||
Created At
|
||||
</th>
|
||||
<th scope="col" className={headerClassName}>
|
||||
Admin?
|
||||
</th>
|
||||
<th scope="col" className={headerClassName}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{users.map((user) => {
|
||||
return (
|
||||
<tr key={user.id} className="w-full px-4 py-2 text-left hover:bg-charcoal-900">
|
||||
<td className={cellClassName}>{user.email}</td>
|
||||
<td className={cellClassName}>
|
||||
<a
|
||||
href={`https://github.com/${user.displayName}`}
|
||||
target="_blank"
|
||||
className="text-indigo-500 underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{user.displayName}
|
||||
</a>
|
||||
</td>
|
||||
<td className={cellClassName}>{user.id}</td>
|
||||
<td className={cellClassName}>{user.createdAt.toISOString()}</td>
|
||||
<td className={cellClassName}>{user.admin ? "✅" : ""}</td>
|
||||
<td className={cellClassName}>
|
||||
<Form method="post" reloadDocument>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="impersonate"
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
</Form>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="impersonate"
|
||||
className="mr-2"
|
||||
variant="primary/small"
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
</Form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<PaginationControls currentPage={page} totalPages={pageCount} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof SearchParams>;
|
||||
|
||||
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<typeof loader>();
|
||||
|
||||
return (
|
||||
<main
|
||||
aria-labelledby="primary-heading"
|
||||
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last"
|
||||
>
|
||||
<div className=" space-y-4">
|
||||
<Form className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search users or orgs"
|
||||
variant="small"
|
||||
icon={MagnifyingGlassIcon}
|
||||
fullWidth={true}
|
||||
name="search"
|
||||
defaultValue={filters.search}
|
||||
/>
|
||||
<Button type="submit" variant="tertiary/small">
|
||||
Search
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Slug</TableHeaderCell>
|
||||
<TableHeaderCell>Members</TableHeaderCell>
|
||||
<TableHeaderCell>id</TableHeaderCell>
|
||||
<TableHeaderCell>v3?</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{organizations.length === 0 ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<Paragraph>No orgs found for search</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
organizations.map((org) => {
|
||||
return (
|
||||
<TableRow key={org.id}>
|
||||
<TableCell>{org.title}</TableCell>
|
||||
<TableCell>{org.slug}</TableCell>
|
||||
<TableCell>
|
||||
{org.members.map((member) => (
|
||||
<LinkButton
|
||||
key={member.user.email}
|
||||
variant="minimal/small"
|
||||
to={`/admin?search=${encodeURIComponent(member.user.email)}`}
|
||||
>
|
||||
{member.user.email}
|
||||
</LinkButton>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>{org.id}</TableCell>
|
||||
<TableCell>{org.v3Enabled ? "✅" : ""}</TableCell>
|
||||
<TableCell isSticky={true}>
|
||||
<Form method="post" reloadDocument>
|
||||
<input type="hidden" name="id" value={org.id} />
|
||||
|
||||
{org.v3Enabled ? (
|
||||
<Button
|
||||
type="submit"
|
||||
name="v3"
|
||||
value="disable"
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Disable v3
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
name="v3"
|
||||
value="enable"
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Enable v3
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<PaginationControls currentPage={page} totalPages={pageCount} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
label: "Users",
|
||||
to: "/admin",
|
||||
},
|
||||
{
|
||||
label: "Organizations",
|
||||
to: "/admin/orgs",
|
||||
},
|
||||
]}
|
||||
layoutId={"admin"}
|
||||
/>
|
||||
<LinkButton to="/" variant="tertiary/small" className="mb-4">
|
||||
Back to me
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SemanticInternalAttributes,
|
||||
SpanEvent,
|
||||
SpanEvents,
|
||||
SpanMessagingEvent,
|
||||
TaskEventStyle,
|
||||
correctErrorStackTrace,
|
||||
flattenAttributes,
|
||||
@@ -96,6 +97,21 @@ export type PreparedEvent = Omit<TaskEventRecord, "events" | "style" | "duration
|
||||
style: TaskEventStyle;
|
||||
};
|
||||
|
||||
export type SpanLink =
|
||||
| {
|
||||
type: "run";
|
||||
icon?: string;
|
||||
title: string;
|
||||
runId: string;
|
||||
}
|
||||
| {
|
||||
type: "span";
|
||||
icon?: string;
|
||||
title: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
};
|
||||
|
||||
export type SpanSummary = {
|
||||
recordId: string;
|
||||
id: string;
|
||||
@@ -364,6 +380,37 @@ export class EventRepository {
|
||||
|
||||
const properties = sanitizedAttributes(fullEvent.properties);
|
||||
|
||||
const messagingEvent = SpanMessagingEvent.optional().safeParse((properties as any)?.messaging);
|
||||
|
||||
const links: SpanLink[] = [];
|
||||
|
||||
if (messagingEvent.success && messagingEvent.data) {
|
||||
if ("id" in messagingEvent.data.message) {
|
||||
if (messagingEvent.data.message.id.startsWith("run_")) {
|
||||
links.push({
|
||||
type: "run",
|
||||
icon: "runs",
|
||||
title: `Run ${messagingEvent.data.message.id}`,
|
||||
runId: messagingEvent.data.message.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const backLinks = fullEvent.links as any as Link[] | undefined;
|
||||
|
||||
if (backLinks && backLinks.length > 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof SpanMessagingEvent>;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "v3Enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -119,6 +119,8 @@ model Organization {
|
||||
|
||||
runsEnabled Boolean @default(true)
|
||||
|
||||
v3Enabled Boolean @default(false)
|
||||
|
||||
environments RuntimeEnvironment[]
|
||||
connections IntegrationConnection[]
|
||||
endpoints Endpoint[]
|
||||
|
||||
Reference in New Issue
Block a user