chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runParam: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const { runParam } = ParamsSchema.parse(params);
|
||||
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
if (!isAdmin) {
|
||||
return redirectWithErrorMessage(
|
||||
rootPath(),
|
||||
request,
|
||||
"You're not an admin and cannot impersonate",
|
||||
{
|
||||
ephemeral: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const run = await runStore.findRun(
|
||||
{
|
||||
friendlyId: runParam,
|
||||
},
|
||||
{
|
||||
select: {
|
||||
spanId: true,
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
// Admin impersonation route — bypass org membership so admins can
|
||||
// open any buffered run by friendlyId, mirroring the existing PG
|
||||
// behaviour above (no membership filter on the find).
|
||||
const buffered = await findBufferedRunRedirectInfo({
|
||||
runFriendlyId: runParam,
|
||||
userId: user.id,
|
||||
skipOrgMembershipCheck: true,
|
||||
});
|
||||
if (buffered) {
|
||||
// Preselect the root span so the run-detail trace tree opens with
|
||||
// the buffered run's span highlighted, matching the sibling
|
||||
// redirect routes (runs.$runParam.ts, projects.v3.$projectRef…).
|
||||
const path = buffered.spanId
|
||||
? v3RunSpanPath(
|
||||
{ slug: buffered.organizationSlug },
|
||||
{ slug: buffered.projectSlug },
|
||||
{ slug: buffered.environmentSlug },
|
||||
{ friendlyId: runParam },
|
||||
{ spanId: buffered.spanId }
|
||||
)
|
||||
: v3RunPath(
|
||||
{ slug: buffered.organizationSlug },
|
||||
{ slug: buffered.projectSlug },
|
||||
{ slug: buffered.environmentSlug },
|
||||
{ friendlyId: runParam }
|
||||
);
|
||||
return redirect(impersonate(path));
|
||||
}
|
||||
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
|
||||
ephemeral: false,
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(run.runtimeEnvironmentId);
|
||||
|
||||
if (!environment) {
|
||||
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
|
||||
ephemeral: false,
|
||||
});
|
||||
}
|
||||
|
||||
const path = v3RunSpanPath(
|
||||
{ slug: environment.organization.slug },
|
||||
{ slug: environment.project.slug },
|
||||
{ slug: environment.slug },
|
||||
{ friendlyId: runParam },
|
||||
{ spanId: run.spanId }
|
||||
);
|
||||
|
||||
return redirect(impersonate(path));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { clearImpersonation } from "~/models/admin.server";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
return clearImpersonation(request, "/admin");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
// If already impersonating, we need to clear the impersonation
|
||||
if (user.isImpersonating) {
|
||||
const url = new URL(request.url);
|
||||
return clearImpersonation(request, url.pathname);
|
||||
}
|
||||
|
||||
// Only admins can impersonate
|
||||
if (!user.admin) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const path = params["*"];
|
||||
const organizationSlug = params.organizationSlug;
|
||||
|
||||
logger.debug("Impersonating user", { path, organizationSlug });
|
||||
|
||||
if (!organizationSlug) {
|
||||
logger.debug("Exiting impersonation mode");
|
||||
return clearImpersonation(request, "/admin");
|
||||
}
|
||||
|
||||
// CSRF gate for the SET-impersonation path. Clearing impersonation
|
||||
// above is benign and stays reachable without the check.
|
||||
if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
|
||||
logger.warn("Refusing cross-site impersonation entry", {
|
||||
userId: user.id,
|
||||
organizationSlug,
|
||||
referer: request.headers.get("referer"),
|
||||
secFetchSite: request.headers.get("sec-fetch-site"),
|
||||
});
|
||||
return redirect("/admin");
|
||||
}
|
||||
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
confirmedBasicDetails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
logger.debug("Organization not found", { organizationSlug });
|
||||
return clearImpersonation(request, "/admin");
|
||||
}
|
||||
|
||||
const firstValidMember = org.members.find((m) => m.user.confirmedBasicDetails);
|
||||
|
||||
if (!firstValidMember) {
|
||||
logger.debug("No valid members found", { organizationSlug });
|
||||
return clearImpersonation(request, "/admin");
|
||||
}
|
||||
|
||||
return redirectWithImpersonation(
|
||||
request,
|
||||
firstValidMember.user.id,
|
||||
`/orgs/${organizationSlug}/${path}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getUsersInvites } from "~/models/member.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
invitesPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
v3EnvironmentPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
//this loader chooses the best project to redirect you to, ideally based on the cookie
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
|
||||
//if there are invites then we should redirect to the invites page
|
||||
const invites = await getUsersInvites({ email: user.email });
|
||||
if (invites.length > 0) {
|
||||
return redirect(invitesPath());
|
||||
}
|
||||
|
||||
const presenter = new SelectBestEnvironmentPresenter();
|
||||
try {
|
||||
const { project, organization, environment } = await presenter.call({
|
||||
user,
|
||||
});
|
||||
//redirect them to the most appropriate project
|
||||
return redirect(v3EnvironmentPath(organization, project, environment));
|
||||
} catch (_e) {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (organization) {
|
||||
return redirect(newProjectPath(organization));
|
||||
}
|
||||
|
||||
//this should only happen if the user has no projects, and no invites
|
||||
return redirect(newOrganizationPath());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
|
||||
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { $replica } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
const QuerySchema = z.discriminatedUnion("setup_action", [
|
||||
z.object({
|
||||
setup_action: z.literal("install"),
|
||||
installation_id: z.coerce.number(),
|
||||
state: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
setup_action: z.literal("update"),
|
||||
installation_id: z.coerce.number(),
|
||||
state: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
setup_action: z.literal("request"),
|
||||
state: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(url.searchParams);
|
||||
const cookieHeader = request.headers.get("Cookie");
|
||||
|
||||
const result = QuerySchema.safeParse(queryParams);
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn("GitHub App callback with invalid params", {
|
||||
queryParams,
|
||||
});
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const callbackData = result.data;
|
||||
|
||||
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);
|
||||
|
||||
if (!sessionResult.valid) {
|
||||
logger.error("GitHub App callback with invalid session", {
|
||||
callbackData,
|
||||
error: sessionResult.error,
|
||||
});
|
||||
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
|
||||
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);
|
||||
|
||||
const user = await requireUser(request);
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
// the secure cookie approach should already protect against this
|
||||
// just an additional check
|
||||
logger.error("GitHub app installation attempt on unauthenticated org", {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
switch (callbackData.setup_action) {
|
||||
case "install": {
|
||||
const [error] = await tryCatch(
|
||||
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed to link GitHub App installation", {
|
||||
error,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed to update GitHub App installation", {
|
||||
error,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
|
||||
}
|
||||
|
||||
case "request": {
|
||||
// This happens when a non-admin user requests installation
|
||||
// The installation_id won't be available until an admin approves
|
||||
logger.info("GitHub App installation requested, awaiting approval", {
|
||||
callbackData,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
|
||||
}
|
||||
|
||||
default:
|
||||
callbackData satisfies never;
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
|
||||
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { newOrganizationPath } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
org_slug: z.string(),
|
||||
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
|
||||
message: "Invalid redirect path",
|
||||
}),
|
||||
});
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
// The org for the auth scope comes from the `org_slug` query param.
|
||||
context: async (_params, request) => {
|
||||
const orgSlug = new URL(request.url).searchParams.get("org_slug");
|
||||
if (!orgSlug) return {};
|
||||
const organizationId = await resolveOrgIdFromSlug(orgSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: { action: "write", resource: { type: "github" } },
|
||||
// Redirect endpoint (no UI): keep redirecting on denial rather than
|
||||
// throwing the permission panel.
|
||||
unauthorizedRedirect: "/",
|
||||
},
|
||||
async ({ request, user }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.warn("GitHub App installation redirect with invalid params", {
|
||||
searchParams,
|
||||
error: parsed.error,
|
||||
});
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const { org_slug, redirect_to } = parsed.data;
|
||||
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw redirect(newOrganizationPath());
|
||||
}
|
||||
|
||||
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
"Set-Cookie": cookieHeader,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationId } = ParamsSchema.parse(params);
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingPath({ slug: org.slug }),
|
||||
request,
|
||||
"You didn't complete your details on Stripe. Please try again."
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { newProjectPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationId } = ParamsSchema.parse(params);
|
||||
|
||||
const org = await prisma.organization.findFirst({
|
||||
select: {
|
||||
slug: true,
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const hasProject = org._count.projects > 0;
|
||||
|
||||
if (hasProject) {
|
||||
return redirectWithSuccessMessage(
|
||||
v3BillingPath({ slug: org.slug }),
|
||||
request,
|
||||
"Your subscription has been successfully activated."
|
||||
);
|
||||
}
|
||||
|
||||
return redirect(
|
||||
newProjectPath({ slug: org.slug }, "Your subscription has been successfully activated.")
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationId } = ParamsSchema.parse(params);
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
const reason = searchParams.get("reason");
|
||||
|
||||
let errorMessage = reason ? decodeURIComponent(reason) : "Subscribing failed to complete";
|
||||
|
||||
return redirectWithErrorMessage(v3BillingPath({ slug: org.slug }), request, errorMessage);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
OrganizationParamsSchema,
|
||||
v3ProjectPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null, version: "V3" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw redirect(newOrganizationPath());
|
||||
}
|
||||
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const bestProject = await selector.selectBestProjectFromProjects({
|
||||
user,
|
||||
projectSlug: undefined,
|
||||
projects: org.projects,
|
||||
});
|
||||
if (!bestProject) {
|
||||
logger.info("Not Found: project", {
|
||||
request,
|
||||
project: bestProject,
|
||||
});
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
return redirect(v3ProjectPath({ slug: organizationSlug }, bestProject));
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
ArrowUpCircleIcon,
|
||||
EnvelopeIcon,
|
||||
LockOpenIcon,
|
||||
UserPlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import simplur from "simplur";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { inviteMembers } from "~/models/member.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
|
||||
import { scheduleEmail } from "~/services/scheduleEmail.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { ssoController } from "~/services/sso.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { isAtOrBelow } from "~/utils/inviteRoleLadder";
|
||||
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
|
||||
|
||||
const Params = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: Params,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: {
|
||||
action: "manage",
|
||||
resource: { type: "members" },
|
||||
message: "With your current role, you can't invite team members.",
|
||||
},
|
||||
},
|
||||
async ({ user, context, ability }) => {
|
||||
const organizationId = context.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
const userId = user.id;
|
||||
|
||||
const presenter = new TeamPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
// Inviter's own role drives the "below their level" filter on the
|
||||
// dropdown. Plus assignable role IDs already encode the org's plan
|
||||
// tier — the intersection is what we offer.
|
||||
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
|
||||
rbac.getUserRole({ userId, organizationId }),
|
||||
rbac.getAssignableRoleIds(organizationId),
|
||||
rbac.systemRoles(organizationId),
|
||||
]);
|
||||
|
||||
// Build the dropdown's offerable set server-side: roles that are
|
||||
// (a) assignable on the current plan AND (b) at or below the
|
||||
// inviter's own level. The client just renders these — it doesn't
|
||||
// need to know about the system-role catalogue or the ladder.
|
||||
const assignableSet = new Set(assignableRoleIds);
|
||||
const offerableRoleIds = systemRoles
|
||||
? result.roles
|
||||
.filter(
|
||||
(r) =>
|
||||
assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
|
||||
)
|
||||
.map((r) => r.id)
|
||||
: [];
|
||||
|
||||
// Buying seats is a billing operation: surface whether this user can, so
|
||||
// the purchase modal disables its trigger (the team action enforces it).
|
||||
const canManageBilling = ability.can("manage", { type: "billing" });
|
||||
|
||||
return typedjson({ ...result, offerableRoleIds, canManageBilling });
|
||||
}
|
||||
);
|
||||
|
||||
// Sentinel for "no RBAC role attached to invite" — the runtime
|
||||
// fallback will derive a role from the legacy OrgMember.role write at
|
||||
// accept time. Used when the org has no RBAC plugin installed (the
|
||||
// dropdown is hidden) or as a defensive default.
|
||||
const NO_RBAC_ROLE = "__no_rbac_role__";
|
||||
|
||||
const schema = z.object({
|
||||
emails: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
|
||||
if (Array.isArray(i)) {
|
||||
const emails = i.filter((v) => typeof v === "string" && v !== "");
|
||||
if (emails.length === 0) {
|
||||
return [""];
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
return [""];
|
||||
}, z.string().email().array().nonempty("At least one email is required")),
|
||||
rbacRoleId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: Params,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: { action: "manage", resource: { type: "members" } },
|
||||
},
|
||||
async ({ request, params, user, context }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug } = params;
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
// Directory-managed membership: inviting is disabled (the directory is the
|
||||
// authority). Enforced here; the Team page also hides the invite button.
|
||||
if (context.organizationId) {
|
||||
const policy = await ssoController.getMembershipPolicy(context.organizationId);
|
||||
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
|
||||
return json(
|
||||
{ errors: { body: "Membership is managed by Directory Sync" } },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
|
||||
// role → don't pass one through; the runtime fallback handles it.
|
||||
// Validation: the chosen role must be in the org's assignable set
|
||||
// (plan-tier) and at or below the inviter's own level.
|
||||
let resolvedRbacRoleId: string | null = null;
|
||||
const submittedRbacRoleId = submission.value.rbacRoleId;
|
||||
if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) {
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug: organizationSlug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!org) {
|
||||
return json({ errors: { body: "Organization not found" } }, { status: 404 });
|
||||
}
|
||||
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
|
||||
rbac.getUserRole({ userId, organizationId: org.id }),
|
||||
rbac.getAssignableRoleIds(org.id),
|
||||
rbac.systemRoles(org.id),
|
||||
]);
|
||||
if (!systemRoles) {
|
||||
// No plugin installed but the form somehow submitted a role id —
|
||||
// ignore it (fall through to legacy behaviour rather than 400).
|
||||
resolvedRbacRoleId = null;
|
||||
} else {
|
||||
const assignable = new Set(assignableRoleIds);
|
||||
if (!assignable.has(submittedRbacRoleId)) {
|
||||
return json(
|
||||
{ errors: { body: "You can't invite someone with this role on your current plan" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) {
|
||||
return json(
|
||||
{ errors: { body: "You can only invite members at or below your own role" } },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
resolvedRbacRoleId = submittedRbacRoleId;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const invites = await inviteMembers({
|
||||
slug: organizationSlug,
|
||||
emails: submission.value.emails,
|
||||
userId,
|
||||
rbacRoleId: resolvedRbacRoleId,
|
||||
});
|
||||
|
||||
for (const invite of invites) {
|
||||
try {
|
||||
await scheduleEmail({
|
||||
email: "invite",
|
||||
to: invite.email,
|
||||
orgName: invite.organization.title,
|
||||
inviterName: invite.inviter.name ?? undefined,
|
||||
inviterEmail: invite.inviter.email,
|
||||
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send invite email");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationTeamPath(invites[0].organization),
|
||||
request,
|
||||
simplur`${submission.value.emails.length} member[|s] invited`
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
limits,
|
||||
canPurchaseSeats,
|
||||
seatPricing,
|
||||
extraSeats,
|
||||
maxSeatQuota,
|
||||
planSeatLimit,
|
||||
roles,
|
||||
offerableRoleIds,
|
||||
canManageBilling,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const [total, setTotal] = useState(limits.used);
|
||||
const organization = useOrganization();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
// The loader filtered the catalogue to roles this inviter can
|
||||
// actually assign (plan tier × strict-below-my-level). With no plugin
|
||||
// installed, offerableRoleIds is [] and the picker hides entirely.
|
||||
const offerableSet = new Set(offerableRoleIds);
|
||||
const offerable = roles.filter((r) => offerableSet.has(r.id));
|
||||
const showRolePicker = offerable.length > 0;
|
||||
|
||||
// Default to the lowest-tier offered role (the loader returns roles
|
||||
// in its allRoles order, which the plugin emits Owner→Member; the
|
||||
// last entry is the most restrictive).
|
||||
const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE;
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
|
||||
|
||||
const [form, fields] = useForm<z.infer<typeof schema>>({
|
||||
id: "invite-members",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
defaultValue: {
|
||||
emails: [""],
|
||||
},
|
||||
});
|
||||
const { emails } = fields;
|
||||
|
||||
const fieldValues = useRef<string[]>([""]);
|
||||
const emailFields = emails.getFieldList();
|
||||
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<div>
|
||||
<FormTitle
|
||||
LeadingIcon={<UserPlusIcon className="size-6 text-indigo-500" />}
|
||||
title="Invite team members"
|
||||
description={`Invite new team members to ${organization.title}.`}
|
||||
/>
|
||||
{total > limits.limit &&
|
||||
(canPurchaseSeats && seatPricing ? (
|
||||
<InfoPanel
|
||||
variant="upgrade"
|
||||
icon={LockOpenIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
title="Need more seats?"
|
||||
accessory={
|
||||
<PurchaseSeatsModal
|
||||
seatPricing={seatPricing}
|
||||
extraSeats={extraSeats}
|
||||
usedSeats={limits.used}
|
||||
maxQuota={maxSeatQuota}
|
||||
planSeatLimit={planSeatLimit}
|
||||
canManageBilling={canManageBilling}
|
||||
triggerButton={<Button variant="primary/small">Purchase more seats…</Button>}
|
||||
/>
|
||||
}
|
||||
panelClassName="mb-4"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
You've used all {limits.limit} of your available team members. Purchase extra seats
|
||||
to add more.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
) : (
|
||||
<InfoPanel
|
||||
variant="upgrade"
|
||||
icon={LockOpenIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
title="Unlock more team members"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
panelClassName="mb-4"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
You've used all {limits.limit} of your available team members. Upgrade your plan to
|
||||
add more.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
))}
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={emails.id}>Email addresses</Label>
|
||||
{emailFields.map((email, index) => (
|
||||
<Fragment key={email.key}>
|
||||
<Input
|
||||
{...getInputProps(email, { type: "email" })}
|
||||
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
||||
icon={EnvelopeIcon}
|
||||
autoFocus={index === 0}
|
||||
onChange={(e) => {
|
||||
fieldValues.current[index] = e.target.value;
|
||||
const filledFields = fieldValues.current.filter((v) => v !== "");
|
||||
setTotal(limits.used + filledFields.length);
|
||||
if (
|
||||
emailFields.length === fieldValues.current.length &&
|
||||
fieldValues.current.every((v) => v !== "")
|
||||
) {
|
||||
form.insert({ name: emails.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormError id={email.errorId}>{email.errors}</FormError>
|
||||
</Fragment>
|
||||
))}
|
||||
</InputGroup>
|
||||
{showRolePicker ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor="rbacRoleId">Role</Label>
|
||||
<input type="hidden" name="rbacRoleId" value={selectedRoleId} />
|
||||
<Select<string, (typeof offerable)[number]>
|
||||
defaultValue={defaultRoleId}
|
||||
items={offerable}
|
||||
variant="tertiary/medium"
|
||||
dropdownIcon
|
||||
text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"}
|
||||
setValue={(next) => {
|
||||
if (typeof next === "string") setSelectedRoleId(next);
|
||||
}}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Invitees join with this role. They can be promoted later from the Team page.
|
||||
</Paragraph>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
|
||||
Send invitations
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton to={organizationTeamPath(organization)} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
deletedAt: null,
|
||||
organization: { slug: organizationSlug, members: { some: { userId: user.id } } },
|
||||
},
|
||||
include: {
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
parentEnvironmentId: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
|
||||
|
||||
return redirect(v3EnvironmentPath({ slug: organizationSlug }, project, environment));
|
||||
};
|
||||
+919
@@ -0,0 +1,919 @@
|
||||
import { BookOpenIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { json, type MetaFunction } from "@remix-run/node";
|
||||
import { useFetcher, useRevalidator } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import type { PanelHandle } from "@window-splitter/react";
|
||||
import { Fragment, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ClientOnly } from "remix-utils/client-only";
|
||||
import { Bar, BarChart, ReferenceLine, Tooltip, type TooltipProps, YAxis } from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
|
||||
import { ClockIcon } from "~/assets/icons/ClockIcon";
|
||||
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { PlusIcon } from "~/assets/icons/PlusIcon";
|
||||
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { HasNoTasksDeployed, HasNoTasksDev } from "~/components/BlankStatePanels";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
collapsibleHandleClassName,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { SearchInput } from "~/components/primitives/SearchInput";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import TooltipPortal from "~/components/primitives/TooltipPortal";
|
||||
import { TaskFileName } from "~/components/runs/v3/TaskPath";
|
||||
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
|
||||
import {
|
||||
TaskTriggerSourceIcon,
|
||||
taskTriggerSourceDescription,
|
||||
} from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
getUsefulLinksPreference,
|
||||
setUsefulLinksPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import {
|
||||
unifiedTaskListPresenter,
|
||||
type HourlyTaskActivity,
|
||||
type UnifiedRunningState,
|
||||
type UnifiedRunningStates,
|
||||
type UnifiedTaskKind,
|
||||
type UnifiedTaskListItem,
|
||||
} from "~/presenters/v3/UnifiedTaskListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3AgentTaskPath,
|
||||
v3PlaygroundAgentPath,
|
||||
v3RunsPath,
|
||||
v3ScheduledTaskPath,
|
||||
v3StandardTaskPath,
|
||||
v3TasksStreamingPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: `Tasks | Trigger.dev` }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { items, hourlyActivity, runningStates } = await unifiedTaskListPresenter.call({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
const usefulLinksPreference = await getUsefulLinksPreference(request);
|
||||
|
||||
return typeddefer({ items, hourlyActivity, runningStates, usefulLinksPreference });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const showUsefulLinks = formData.get("showUsefulLinks") === "true";
|
||||
|
||||
const session = await setUsefulLinksPreference(showUsefulLinks, request);
|
||||
|
||||
return json(
|
||||
{ success: true },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await uiPreferencesStorage.commitSession(session),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: { value: UnifiedTaskKind; label: string }[] = [
|
||||
{ value: "AGENT", label: "Agent" },
|
||||
{ value: "STANDARD", label: "Standard" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
];
|
||||
|
||||
const VALID_KINDS = new Set<UnifiedTaskKind>(KIND_OPTIONS.map((o) => o.value));
|
||||
|
||||
/** Parse `?types=…` URL values, dropping anything that isn't a known
|
||||
* `UnifiedTaskKind`. Without this, a shareable URL with a typo would
|
||||
* produce a filter that matches nothing and the user gets stuck on
|
||||
* "No tasks match your filters" with no way to recover. */
|
||||
function parseTypesParam(values: string[]): UnifiedTaskKind[] {
|
||||
return values.filter((v): v is UnifiedTaskKind => VALID_KINDS.has(v as UnifiedTaskKind));
|
||||
}
|
||||
|
||||
const ALL_TASK_TYPES = "ALL";
|
||||
|
||||
type TaskTypeSegment = typeof ALL_TASK_TYPES | UnifiedTaskKind;
|
||||
|
||||
/** Segmented control options. "All" shows as a word; the task kinds show as
|
||||
* icon-only segments. Every segment has a tooltip (label + shortcut).
|
||||
* Order = shortcut keys 0–3. */
|
||||
const TASK_TYPE_SEGMENTS: {
|
||||
value: TaskTypeSegment;
|
||||
tooltip: string;
|
||||
text?: string;
|
||||
source?: UnifiedTaskKind;
|
||||
}[] = [
|
||||
{ value: ALL_TASK_TYPES, tooltip: "All tasks", text: "All" },
|
||||
{ value: "AGENT", tooltip: "Agent tasks", source: "AGENT" },
|
||||
{ value: "STANDARD", tooltip: "Standard tasks", source: "STANDARD" },
|
||||
{ value: "SCHEDULED", tooltip: "Scheduled tasks", source: "SCHEDULED" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { items, hourlyActivity, runningStates, usefulLinksPreference } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { value, values } = useSearchParams();
|
||||
|
||||
// Live-reload on WORKER_CREATED.
|
||||
const revalidator = useRevalidator();
|
||||
const streamedEvents = useEventSource(v3TasksStreamingPath(organization, project, environment), {
|
||||
event: "message",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (streamedEvents !== null) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
// Don't add `revalidator` to deps — infinite loop.
|
||||
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
|
||||
// Hide (don't unmount) the charts during the panel animation; 25 reflowing SVGs tank the resize.
|
||||
const [isPanelAnimating, setIsPanelAnimating] = useState(false);
|
||||
const animatingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const usefulLinksPanelRef = useRef<PanelHandle>(null);
|
||||
const fetcher = useFetcher();
|
||||
const fetcherRef = useRef(fetcher);
|
||||
fetcherRef.current = fetcher;
|
||||
|
||||
const toggleUsefulLinks = useCallback((show: boolean) => {
|
||||
setShowUsefulLinks(show);
|
||||
setIsPanelAnimating(true);
|
||||
if (animatingTimerRef.current) clearTimeout(animatingTimerRef.current);
|
||||
// 300ms panel anim + 50ms buffer.
|
||||
animatingTimerRef.current = setTimeout(() => setIsPanelAnimating(false), 350);
|
||||
if (show) {
|
||||
usefulLinksPanelRef.current?.expand();
|
||||
} else {
|
||||
usefulLinksPanelRef.current?.collapse();
|
||||
}
|
||||
fetcherRef.current.submit({ showUsefulLinks: show.toString() }, { method: "post" });
|
||||
}, []);
|
||||
|
||||
const selectedTypes = useMemo(() => {
|
||||
const raw = parseTypesParam(values("types"));
|
||||
// Single-select: one kind filters to it; none or legacy multi → all.
|
||||
return raw.length === 1 ? new Set(raw) : null; // null = all
|
||||
}, [values]);
|
||||
|
||||
const { filteredItems } = useFuzzyFilter<UnifiedTaskListItem>({
|
||||
items,
|
||||
keys: ["slug", "filePath", "triggerSource"],
|
||||
filterText: value("search") ?? "",
|
||||
});
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (!selectedTypes) return filteredItems;
|
||||
return filteredItems.filter((item) => selectedTypes.has(item.kind));
|
||||
}, [filteredItems, selectedTypes]);
|
||||
|
||||
const hasItems = items.length > 0;
|
||||
|
||||
// Client-side pagination — presenter returns all tasks; we slice + clamp here.
|
||||
const totalPages = Math.max(1, Math.ceil(visibleItems.length / PAGE_SIZE));
|
||||
const requestedPage = Math.max(1, parseInt(value("page") ?? "1", 10) || 1);
|
||||
const currentPage = Math.min(requestedPage, totalPages);
|
||||
const pagedItems = useMemo(
|
||||
() => visibleItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
|
||||
[visibleItems, currentPage]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Tasks" accessory={<TasksHelpTooltip />} />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/tasks/overview")}
|
||||
>
|
||||
Task docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="tasks-main" min="100px" className="max-h-full">
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasItems ? (
|
||||
<div className="flex min-w-0 max-w-full flex-col">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-1.5 p-2">
|
||||
<div className="flex flex-1 items-center gap-1.5">
|
||||
<SearchInput placeholder="Search tasks…" resetParams={["page"]} />
|
||||
<TaskTypeFilter />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!showUsefulLinks && (
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="mr-[-0.7rem]"
|
||||
onClick={() => toggleUsefulLinks(true)}
|
||||
className="pl-1.5"
|
||||
>
|
||||
New task…
|
||||
</Button>
|
||||
)}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Table containerClassName="min-h-0 flex-1">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-sm">
|
||||
<TaskTypeBreakdown />
|
||||
</div>
|
||||
}
|
||||
disableTooltipHoverableContent
|
||||
>
|
||||
Type
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>File</TableHeaderCell>
|
||||
<TableHeaderCell>Running</TableHeaderCell>
|
||||
<TableHeaderCell>Activity (24h)</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedItems.length > 0 ? (
|
||||
pagedItems.map((item) => (
|
||||
<TaskRow
|
||||
key={item.slug}
|
||||
item={item}
|
||||
runningStates={runningStates}
|
||||
hourlyActivity={hourlyActivity}
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isPanelAnimating={isPanelAnimating}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableBlankRow colSpan={6}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No tasks match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<HasNoTasksDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<HasNoTasksDeployed environment={environment} />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id="tasks-handle"
|
||||
className={collapsibleHandleClassName(hasItems && showUsefulLinks)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="tasks-inspector"
|
||||
handle={usefulLinksPanelRef}
|
||||
default="400px"
|
||||
min="400px"
|
||||
max="500px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!hasItems || !showUsefulLinks}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 400 }}>
|
||||
{hasItems && <NewTaskPromptsPanel onClose={() => toggleUsefulLinks(false)} />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
type TaskRowProps = {
|
||||
item: UnifiedTaskListItem;
|
||||
runningStates: Promise<UnifiedRunningStates>;
|
||||
hourlyActivity: Promise<HourlyTaskActivity>;
|
||||
organization: ReturnType<typeof useOrganization>;
|
||||
project: ReturnType<typeof useProject>;
|
||||
environment: ReturnType<typeof useEnvironment>;
|
||||
isPanelAnimating: boolean;
|
||||
};
|
||||
|
||||
function TaskRow({
|
||||
item,
|
||||
runningStates,
|
||||
hourlyActivity,
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isPanelAnimating,
|
||||
}: TaskRowProps) {
|
||||
const rowPath =
|
||||
item.kind === "AGENT"
|
||||
? v3AgentTaskPath(organization, project, environment, item.slug)
|
||||
: item.kind === "SCHEDULED"
|
||||
? v3ScheduledTaskPath(organization, project, environment, item.slug)
|
||||
: v3StandardTaskPath(organization, project, environment, item.slug);
|
||||
|
||||
const testPath =
|
||||
item.kind === "AGENT"
|
||||
? v3PlaygroundAgentPath(organization, project, environment, item.slug)
|
||||
: v3TestTaskPath(organization, project, environment, { taskIdentifier: item.slug });
|
||||
|
||||
const runsPath = v3RunsPath(organization, project, environment, { tasks: [item.slug] });
|
||||
|
||||
return (
|
||||
<TableRow className="group">
|
||||
<TableCell to={rowPath} isTabbableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={<TaskTriggerSourceIcon source={item.triggerSource} />}
|
||||
content={taskTriggerSourceDescription(item.triggerSource)}
|
||||
disableHoverableContent
|
||||
/>
|
||||
<span>{item.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={rowPath}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{item.kind === "AGENT" ? "Agent" : item.kind === "SCHEDULED" ? "Scheduled" : "Standard"}
|
||||
</span>
|
||||
{item.kind === "AGENT" && item.agentType && (
|
||||
<Badge variant="extra-small">{formatAgentType(item.agentType)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={rowPath}>
|
||||
<TaskFileName fileName={item.filePath} variant="extra-extra-small" />
|
||||
</TableCell>
|
||||
<TableCell to={rowPath}>
|
||||
{/* Render the deferred stats client-side. A streamed Suspense boundary still pending at
|
||||
hydration otherwise bails to client rendering and throws React #421. */}
|
||||
<ClientOnly fallback={<Spinner color="blue" className="size-3" />}>
|
||||
{() => (
|
||||
<Suspense fallback={<Spinner color="blue" className="size-3" />}>
|
||||
<TypedAwait resolve={runningStates} errorElement={<FailedToLoadStats />}>
|
||||
{(data) => <RunningCell state={data[item.slug]} />}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TableCell>
|
||||
<TableCell to={rowPath} actionClassName="py-1.5">
|
||||
<div style={{ width: ACTIVITY_CELL_WIDTH, height: ACTIVITY_CHART_HEIGHT }}>
|
||||
<div hidden={isPanelAnimating}>
|
||||
<ClientOnly fallback={<TaskActivityBlankState />}>
|
||||
{() => (
|
||||
<Suspense fallback={<TaskActivityBlankState />}>
|
||||
<TypedAwait resolve={hourlyActivity} errorElement={<FailedToLoadStats />}>
|
||||
{(data) => {
|
||||
const taskData = data[item.slug];
|
||||
return taskData && taskData.length > 0 ? (
|
||||
<TaskActivityGraph activity={taskData} />
|
||||
) : (
|
||||
<TaskActivityBlankState />
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon={RunsIcon}
|
||||
to={runsPath}
|
||||
title="View runs"
|
||||
leadingIconClassName="-mx-1 text-runs"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={BeakerIcon}
|
||||
to={testPath}
|
||||
title="Test"
|
||||
leadingIconClassName="-mx-1 text-tests"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="-mx-2.5 text-tests"
|
||||
to={testPath}
|
||||
>
|
||||
<span className="text-text-bright">Test</span>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningCell({ state }: { state: UnifiedRunningState | undefined }) {
|
||||
if (!state) {
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
return <>{state.running ?? 0}</>;
|
||||
}
|
||||
|
||||
function TaskTypeFilter() {
|
||||
const { values, replace } = useSearchParams();
|
||||
const raw = parseTypesParam(values("types"));
|
||||
// Single-select: exactly one kind selects it, anything else falls back to All.
|
||||
const current: TaskTypeSegment = raw.length === 1 ? raw[0] : ALL_TASK_TYPES;
|
||||
|
||||
const select = (value: string) => {
|
||||
// "All" drops the param; a kind filters to just that kind. Always reset page.
|
||||
replace({ types: value === ALL_TASK_TYPES ? undefined : [value], page: undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{TASK_TYPE_SEGMENTS.map((option, index) => (
|
||||
<TaskTypeShortcut
|
||||
key={option.value}
|
||||
shortcut={String(index)}
|
||||
onSelect={() => select(option.value)}
|
||||
/>
|
||||
))}
|
||||
<SegmentedControl
|
||||
name="task-type"
|
||||
value={current}
|
||||
variant="secondary/small"
|
||||
onChange={select}
|
||||
options={TASK_TYPE_SEGMENTS.map((option, index) => ({
|
||||
value: option.value,
|
||||
label: <TaskTypeSegmentLabel option={option} shortcut={String(index)} />,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Registers a number-key shortcut that selects one segment.
|
||||
function TaskTypeShortcut({ shortcut, onSelect }: { shortcut: string; onSelect: () => void }) {
|
||||
useShortcutKeys({
|
||||
shortcut: { key: shortcut },
|
||||
action: (event) => {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function TaskTypeSegmentLabel({
|
||||
option,
|
||||
shortcut,
|
||||
}: {
|
||||
option: (typeof TASK_TYPE_SEGMENTS)[number];
|
||||
shortcut: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
button={
|
||||
option.source ? (
|
||||
// -mx-0.5 tightens the icon segment toward a square button.
|
||||
<span className="-mx-0.5 flex items-center justify-center">
|
||||
<TaskTriggerSourceIcon source={option.source} />
|
||||
<span className="sr-only">{option.tooltip}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center">{option.text}</span>
|
||||
)
|
||||
}
|
||||
content={
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-text-bright">{option.tooltip}</span>
|
||||
<ShortcutKey shortcut={{ key: shortcut }} variant="small" />
|
||||
</div>
|
||||
}
|
||||
className="px-2 py-1.5 text-xs"
|
||||
sideOffset={6}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAgentType(type: string): string {
|
||||
switch (type) {
|
||||
case "ai-sdk-chat":
|
||||
return "AI SDK Chat";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BARS: { status: TaskRunStatus; fill: string }[] = [
|
||||
{ status: "DELAYED", fill: "var(--color-run-delayed)" },
|
||||
{ status: "PENDING", fill: "var(--color-run-pending)" },
|
||||
{ status: "PENDING_VERSION", fill: "var(--color-run-pending-version)" },
|
||||
{ status: "EXECUTING", fill: "var(--color-run-executing)" },
|
||||
{ status: "RETRYING_AFTER_FAILURE", fill: "var(--color-run-retrying-after-failure)" },
|
||||
{ status: "WAITING_TO_RESUME", fill: "var(--color-run-waiting-to-resume)" },
|
||||
{ status: "COMPLETED_SUCCESSFULLY", fill: "var(--color-run-completed-successfully)" },
|
||||
{ status: "CANCELED", fill: "var(--color-run-canceled)" },
|
||||
{ status: "COMPLETED_WITH_ERRORS", fill: "var(--color-run-completed-with-errors)" },
|
||||
{ status: "INTERRUPTED", fill: "var(--color-run-interrupted)" },
|
||||
{ status: "SYSTEM_FAILURE", fill: "var(--color-run-system-failure)" },
|
||||
{ status: "PAUSED", fill: "var(--color-run-paused)" },
|
||||
{ status: "CRASHED", fill: "var(--color-run-crashed)" },
|
||||
{ status: "EXPIRED", fill: "var(--color-run-expired)" },
|
||||
{ status: "TIMED_OUT", fill: "var(--color-run-timed-out)" },
|
||||
];
|
||||
|
||||
// Fixed px dims skip ResponsiveContainer's ResizeObserver — otherwise every panel resize re-renders all 25 charts.
|
||||
const ACTIVITY_CHART_WIDTH = 112;
|
||||
const ACTIVITY_CHART_HEIGHT = 24;
|
||||
// chart (112) + gap-1.5 (6) + count min-w (28). Reserved so the column stays put while the chart unmounts.
|
||||
const ACTIVITY_CELL_WIDTH = 146;
|
||||
const ACTIVITY_CHART_COUNT_CLASS =
|
||||
"-mt-1 inline-block min-w-7 text-xxs tabular-nums text-text-dimmed";
|
||||
|
||||
function TaskActivityGraph({ activity }: { activity: HourlyTaskActivity[string] }) {
|
||||
const maxTotal = Math.max(...activity.map((d) => d.total));
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div
|
||||
className="rounded-sm"
|
||||
style={{ width: ACTIVITY_CHART_WIDTH, height: ACTIVITY_CHART_HEIGHT }}
|
||||
>
|
||||
<BarChart
|
||||
data={activity}
|
||||
width={ACTIVITY_CHART_WIDTH}
|
||||
height={ACTIVITY_CHART_HEIGHT}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<YAxis domain={[0, maxTotal || 1]} hide />
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
|
||||
content={<TaskActivityTooltip />}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
animationDuration={0}
|
||||
/>
|
||||
{STATUS_BARS.map(({ status, fill }) => (
|
||||
<Bar
|
||||
key={status}
|
||||
dataKey={status}
|
||||
stackId="a"
|
||||
fill={fill}
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
|
||||
{maxTotal > 0 && (
|
||||
<ReferenceLine
|
||||
y={maxTotal}
|
||||
stroke="var(--color-border-brighter)"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
button={<span className={ACTIVITY_CHART_COUNT_CLASS}>{formatNumberCompact(maxTotal)}</span>}
|
||||
content="Peak runs in a single hour"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SVG line matches recharts' y=0 ReferenceLine anti-aliasing; a CSS border looks too crisp.
|
||||
function TaskActivityBlankState() {
|
||||
return (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<svg width={ACTIVITY_CHART_WIDTH} height={ACTIVITY_CHART_HEIGHT} className="rounded-sm">
|
||||
<line
|
||||
x1={0}
|
||||
y1={ACTIVITY_CHART_HEIGHT}
|
||||
x2={ACTIVITY_CHART_WIDTH}
|
||||
y2={ACTIVITY_CHART_HEIGHT}
|
||||
stroke="var(--color-border-bright)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
<span className={ACTIVITY_CHART_COUNT_CLASS}>0</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TaskActivityTooltip = ({ active, payload }: TooltipProps<number, string>) => {
|
||||
if (active && payload && payload.length > 0) {
|
||||
const entry = payload[0].payload as { date: Date; total: number } & Partial<
|
||||
Record<TaskRunStatus, number>
|
||||
>;
|
||||
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
|
||||
const formattedDate = formatDateTime(date, "UTC", [], false, true);
|
||||
const items = STATUS_BARS.filter(({ status }) => (entry[status] ?? 0) > 0).map(
|
||||
({ status }) => ({ status, value: entry[status] ?? 0 })
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipPortal active={active}>
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-2 text-xs text-text-dimmed">No runs</div>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.status}>
|
||||
<TaskRunStatusCombo status={item.status} />
|
||||
<p className="tabular-nums">{item.value}</p>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipPortal>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function FailedToLoadStats() {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={<ExclamationTriangleIcon className="size-4 text-warning" />}
|
||||
content="We were unable to load the task stats, please try again later."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTypeBreakdown() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CubeSparkleIcon className="size-4.5 shrink-0 text-agents" />
|
||||
<Paragraph variant="small/bright">Agent task</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
A long-lived AI session. Streams LLM responses to your app and keeps context across
|
||||
messages, page refreshes, and deploys.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TaskIcon className="size-4.5 shrink-0 text-tasks" />
|
||||
<Paragraph variant="small/bright">Standard task</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
A background function you trigger from your code with a payload. Good for AI workflows,
|
||||
image generation, audio transcription, document processing, and any other long-running
|
||||
work where reliability matters.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ClockIcon className="size-4.5 shrink-0 text-schedules" />
|
||||
<Paragraph variant="small/bright">Scheduled task</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
Runs automatically on a recurring cron schedule. Use daily, weekly, or any custom interval
|
||||
you need.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksHelpTooltip() {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<QuestionMarkIcon className="size-4 text-text-dimmed transition hover:text-text-bright" />
|
||||
}
|
||||
side="bottom"
|
||||
className="max-w-sm p-3"
|
||||
disableHoverableContent
|
||||
content={
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<Paragraph variant="small/bright">What is a task?</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
A task is a durable function that runs in the background. It can run for as long as it
|
||||
needs without timing out, automatically retries on failure, and survives crashes and
|
||||
deploys.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="border-t border-grid-dimmed pt-3">
|
||||
<TaskTypeBreakdown />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CHAT_AGENT_CODE = `import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText, stepCountIs } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions(),
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});`;
|
||||
|
||||
const STANDARD_TASK_CODE = `import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "hello-world",
|
||||
run: async (payload: { message: string }) => {
|
||||
console.log(payload.message);
|
||||
},
|
||||
});`;
|
||||
|
||||
const SCHEDULED_TASK_CODE = `import { schedules } from "@trigger.dev/sdk";
|
||||
|
||||
export const firstScheduledTask = schedules.task({
|
||||
id: "first-scheduled-task",
|
||||
run: async (payload) => {
|
||||
console.log(payload.timestamp);
|
||||
console.log(payload.lastTimestamp);
|
||||
},
|
||||
});`;
|
||||
|
||||
function NewTaskPromptsPanel({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 py-2">
|
||||
<Header2>Create a new task</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Paragraph variant="small/bright" className="mb-6">
|
||||
Copy any example below into your project's{" "}
|
||||
<InlineCode variant="extra-small">trigger/</InlineCode> directory and customize it from
|
||||
there.
|
||||
</Paragraph>
|
||||
|
||||
<PromptCard
|
||||
icon={<CubeSparkleIcon className="size-4.5 shrink-0 text-agents" />}
|
||||
title="Chat agent"
|
||||
description="An AI agent you can chat with from your app. Streams responses, calls tools and keeps context across messages."
|
||||
code={CHAT_AGENT_CODE}
|
||||
/>
|
||||
<PromptCard
|
||||
icon={<TaskIcon className="size-4.5 shrink-0 text-tasks" />}
|
||||
title="Standard task"
|
||||
description="A durable background function you can trigger from your code. Runs as long as it needs without timing out."
|
||||
code={STANDARD_TASK_CODE}
|
||||
/>
|
||||
<PromptCard
|
||||
icon={<ClockIcon className="size-4.5 shrink-0 text-schedules" />}
|
||||
title="Scheduled task"
|
||||
description="A task that runs automatically on a recurring cron schedule: daily, weekly, or any interval you define."
|
||||
code={SCHEDULED_TASK_CODE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
code,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
code: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
{icon}
|
||||
<Header2>{title}</Header2>
|
||||
</div>
|
||||
<Paragraph variant="small" className="mb-2 text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
<CodeBlock code={code} language="typescript" showCopyButton showLineNumbers={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
|
||||
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
|
||||
import { ChartCard } from "~/components/primitives/charts/ChartCard";
|
||||
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
|
||||
import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext";
|
||||
import { statusColor } from "~/components/primitives/charts/statusColors";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
AgentDetailPresenter,
|
||||
type AgentActivity,
|
||||
type AgentDetail,
|
||||
} from "~/presenters/v3/AgentDetailPresenter.server";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3EnvironmentPath,
|
||||
v3PlaygroundAgentPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { parseFiniteInt } from "~/utils/searchParams";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
const slug = (data as { agent?: AgentDetail | null } | undefined)?.agent?.slug;
|
||||
return [{ title: slug ? `${slug} | Agents | Trigger.dev` : "Agent | Trigger.dev" }];
|
||||
};
|
||||
|
||||
const AgentParamSchema = EnvironmentParamSchema.extend({
|
||||
agentParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
const { organizationSlug, projectParam, envParam, agentParam } = AgentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const from = parseFiniteInt(url.searchParams.get("from"));
|
||||
const to = parseFiniteInt(url.searchParams.get("to"));
|
||||
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||
const directionRaw = url.searchParams.get("direction") ?? undefined;
|
||||
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
|
||||
const presenter = new AgentDetailPresenter($replica, clickhouse);
|
||||
const agent = await presenter.findAgent({
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
agentSlug: agentParam,
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new Response("Agent not found", { status: 404 });
|
||||
}
|
||||
|
||||
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
|
||||
|
||||
const runActivity = presenter
|
||||
.getActivity({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
agentSlug: agent.slug,
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
})
|
||||
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
|
||||
|
||||
const sessionActivity = presenter
|
||||
.getSessionActivity({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
agentSlug: agent.slug,
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
})
|
||||
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
|
||||
|
||||
const llmCostActivity = presenter
|
||||
.getLlmCostActivity({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
agentSlug: agent.slug,
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
})
|
||||
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
|
||||
|
||||
const llmTokenActivity = presenter
|
||||
.getLlmTokenActivity({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
agentSlug: agent.slug,
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
})
|
||||
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
|
||||
|
||||
const runList = new NextRunListPresenter($replica, clickhouse)
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks: [agent.slug],
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const sessionList = new SessionListPresenter($replica, clickhouse)
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
taskIdentifiers: [agent.slug],
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
return typeddefer({
|
||||
agent,
|
||||
runActivity,
|
||||
sessionActivity,
|
||||
llmCostActivity,
|
||||
llmTokenActivity,
|
||||
runList,
|
||||
sessionList,
|
||||
});
|
||||
};
|
||||
|
||||
type AgentTab = "sessions" | "runs";
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
agent,
|
||||
runActivity,
|
||||
sessionActivity,
|
||||
llmCostActivity,
|
||||
llmTokenActivity,
|
||||
runList,
|
||||
sessionList,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const playgroundPath = v3PlaygroundAgentPath(organization, project, environment, agent.slug);
|
||||
const tasksPath = v3EnvironmentPath(organization, project, environment);
|
||||
|
||||
const [tab, setTab] = useState<AgentTab>("sessions");
|
||||
const zoomToTimeFilter = useZoomToTimeFilter();
|
||||
const tabLabel = tab === "sessions" ? "Sessions" : "Runs";
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{ to: tasksPath, text: "Tasks" }}
|
||||
title={
|
||||
<span className="flex items-center gap-1">
|
||||
<CubeSparkleIcon className="size-4.5 text-agents" />
|
||||
<span>{agent.slug}</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("ai-chat/overview")}
|
||||
>
|
||||
Agents docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="agent-main" min="300px">
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Top bar — tabs on the left; TimeFilter + pagination on the right.
|
||||
h-10 matches the right-hand sidebar header height. */}
|
||||
<div className="flex h-10 items-end border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
|
||||
<TabContainer className="-mb-px">
|
||||
<TabButton
|
||||
isActive={tab === "sessions"}
|
||||
layoutId="agent-page-tabs"
|
||||
onClick={() => setTab("sessions")}
|
||||
>
|
||||
Sessions
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "runs"}
|
||||
layoutId="agent-page-tabs"
|
||||
onClick={() => setTab("runs")}
|
||||
>
|
||||
Runs
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
<div className="ml-auto flex items-center gap-2 self-center">
|
||||
<TimeFilter defaultPeriod="7d" labelName={tabLabel} />
|
||||
{tab === "sessions" ? (
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={sessionList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={runList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup orientation="vertical" className="max-h-full">
|
||||
{/* Activity / LLM cost / Token charts */}
|
||||
<ResizablePanel id="agent-activity" min="220px" default="320px">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-2">
|
||||
<ChartSyncProvider onZoom={zoomToTimeFilter}>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-3 gap-2">
|
||||
<ChartCard title={tabLabel}>
|
||||
{tab === "sessions" ? (
|
||||
<Suspense fallback={<ActivityChartSkeleton />}>
|
||||
<TypedAwait
|
||||
resolve={sessionActivity}
|
||||
errorElement={<ActivityChartSkeleton />}
|
||||
>
|
||||
{(result) => <ActivityChart activity={result} />}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<ActivityChartSkeleton />}>
|
||||
<TypedAwait
|
||||
resolve={runActivity}
|
||||
errorElement={<ActivityChartSkeleton />}
|
||||
>
|
||||
{(result) => <ActivityChart activity={result} />}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="LLM spend ($)">
|
||||
<Suspense fallback={<ActivityChartSkeleton />}>
|
||||
<TypedAwait
|
||||
resolve={llmCostActivity}
|
||||
errorElement={<ActivityChartSkeleton />}
|
||||
>
|
||||
{(result) => (
|
||||
<ScalarActivityChart
|
||||
activity={result}
|
||||
seriesKey="cost"
|
||||
label="Spend"
|
||||
color="var(--color-agents)"
|
||||
valueFormatter={formatCurrency}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Tokens">
|
||||
<Suspense fallback={<ActivityChartSkeleton />}>
|
||||
<TypedAwait
|
||||
resolve={llmTokenActivity}
|
||||
errorElement={<ActivityChartSkeleton />}
|
||||
>
|
||||
{(result) => (
|
||||
<ScalarActivityChart
|
||||
activity={result}
|
||||
seriesKey="tokens"
|
||||
label="Tokens"
|
||||
color="#14B8A6"
|
||||
valueFormatter={formatTokens}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</ChartSyncProvider>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle id="agent-activity-handle" />
|
||||
|
||||
{/* Table */}
|
||||
<ResizablePanel id="agent-content" min="160px">
|
||||
<AgentContentArea tab={tab} sessionList={sessionList} runList={runList} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle id="agent-detail-handle" />
|
||||
<ResizablePanel id="agent-detail" min="280px" default="380px" max="500px" isStaticAtRest>
|
||||
<AgentDetailSidebar agent={agent} playgroundPath={playgroundPath} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LoaderData = ReturnType<typeof useTypedLoaderData<typeof loader>>;
|
||||
|
||||
function AgentContentArea({
|
||||
tab,
|
||||
sessionList,
|
||||
runList,
|
||||
}: { tab: AgentTab } & Pick<LoaderData, "sessionList" | "runList">) {
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
{tab === "sessions" ? (
|
||||
<Suspense fallback={<TableLoading />}>
|
||||
<TypedAwait resolve={sessionList} errorElement={<TableLoading />}>
|
||||
{(list) =>
|
||||
list ? (
|
||||
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<SessionsTable
|
||||
sessions={list.sessions}
|
||||
filters={list.filters}
|
||||
hasFilters={list.hasFilters}
|
||||
showTopBorder={false}
|
||||
stickyHeader
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TableLoading />
|
||||
)
|
||||
}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<TableLoading />}>
|
||||
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
|
||||
{(list) =>
|
||||
list ? (
|
||||
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
variant="dimmed"
|
||||
showTopBorder={false}
|
||||
stickyHeader
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TableLoading />
|
||||
)
|
||||
}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDetailSidebar({
|
||||
agent,
|
||||
playgroundPath,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
playgroundPath: string;
|
||||
}) {
|
||||
const config = (agent.config ?? {}) as Record<string, unknown>;
|
||||
const agentType = typeof config.type === "string" ? config.type : undefined;
|
||||
const model = typeof config.model === "string" ? config.model : undefined;
|
||||
const instructions = typeof config.instructions === "string" ? config.instructions : undefined;
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2 className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<CubeSparkleIcon className="size-4.5 shrink-0 text-agents" />
|
||||
<span className="truncate">{agent.slug}</span>
|
||||
</Header2>
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
to={playgroundPath}
|
||||
LeadingIcon={BeakerIcon}
|
||||
iconSpacing="gap-x-2"
|
||||
leadingIconClassName="-mx-2"
|
||||
className="shrink-0"
|
||||
>
|
||||
Test agent
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Slug</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={agent.slug} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>File path</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={agent.filePath} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{agentType && (
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-sm">{agentType}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{model && (
|
||||
<Property.Item>
|
||||
<Property.Label>Model</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-sm">{model}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{instructions && (
|
||||
<Property.Item className="gap-1">
|
||||
<Property.Label>Instructions</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small" className="whitespace-pre-wrap text-text-dimmed">
|
||||
{instructions}
|
||||
</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={agent.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChart({ activity }: { activity: AgentActivity }) {
|
||||
const chartConfig: ChartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
for (const status of activity.statuses) {
|
||||
cfg[status] = {
|
||||
label: status.charAt(0) + status.slice(1).toLowerCase(),
|
||||
color: statusColor(status),
|
||||
};
|
||||
}
|
||||
return cfg;
|
||||
}, [activity.statuses]);
|
||||
|
||||
const { tickFormatter, tooltipLabelFormatter } = useMemo(
|
||||
() => buildActivityTimeAxis(activity.data),
|
||||
[activity.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={activity.data}
|
||||
dataKey="bucket"
|
||||
series={activity.statuses}
|
||||
fillContainer
|
||||
>
|
||||
<Chart.Bar
|
||||
stackId="status"
|
||||
barRadius={0}
|
||||
xAxisProps={{ tickFormatter }}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChartSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
|
||||
{Array.from({ length: 42 }).map((_, i) => (
|
||||
<div key={i} className="h-full flex-1 bg-background-dimmed" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableLoading() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScalarActivityChart({
|
||||
activity,
|
||||
seriesKey,
|
||||
label,
|
||||
color,
|
||||
valueFormatter,
|
||||
}: {
|
||||
activity: AgentActivity;
|
||||
seriesKey: string;
|
||||
label: string;
|
||||
color: string;
|
||||
valueFormatter: (value: number) => string;
|
||||
}) {
|
||||
const chartConfig: ChartConfig = useMemo(
|
||||
() => ({ [seriesKey]: { label, color } }),
|
||||
[seriesKey, label, color]
|
||||
);
|
||||
|
||||
const { tickFormatter, tooltipLabelFormatter } = useMemo(
|
||||
() => buildActivityTimeAxis(activity.data),
|
||||
[activity.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Chart.Root config={chartConfig} data={activity.data} dataKey="bucket" fillContainer>
|
||||
<Chart.Bar
|
||||
barRadius={0}
|
||||
xAxisProps={{ tickFormatter }}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={valueFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (value === 0) return "$0";
|
||||
if (value < 0.01) return `$${value.toFixed(4)}`;
|
||||
if (value < 1) return `$${value.toFixed(3)}`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatTokens(value: number): string {
|
||||
if (value < 1000) return value.toLocaleString();
|
||||
if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3NewProjectAlertPath,
|
||||
v3NewProjectAlertPathConnectToSlackPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const shouldReinstall = url.searchParams.get("reinstall") === "true";
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Find an integration for Slack for this org
|
||||
const integration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// If integration exists and we're not reinstalling, redirect back to alerts
|
||||
if (integration && !shouldReinstall) {
|
||||
return redirectWithSuccessMessage(
|
||||
`${v3NewProjectAlertPath({ slug: organizationSlug }, project, {
|
||||
slug: envParam,
|
||||
})}?option=slack`,
|
||||
request,
|
||||
"Successfully connected your Slack workspace"
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to Slack for new installation or reinstallation
|
||||
return await OrgIntegrationRepository.redirectToAuthService(
|
||||
"SLACK",
|
||||
project.organizationId,
|
||||
request,
|
||||
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project, {
|
||||
slug: envParam,
|
||||
})
|
||||
);
|
||||
}
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/router";
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { env } from "~/env.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3ProjectAlertsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import {
|
||||
type CreateAlertChannelOptions,
|
||||
CreateAlertChannelService,
|
||||
} from "~/v3/services/alerts/createAlertChannel.server";
|
||||
import {
|
||||
assertSafeWebhookUrl,
|
||||
UnsafeWebhookUrlError,
|
||||
} from "~/v3/services/alerts/safeWebhookUrl.server";
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
alertTypes: z
|
||||
.array(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
|
||||
.min(1)
|
||||
.or(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
|
||||
environmentTypes: z
|
||||
.array(z.enum(["STAGING", "PRODUCTION", "PREVIEW"]))
|
||||
.min(1)
|
||||
.or(z.enum(["STAGING", "PRODUCTION", "PREVIEW"])),
|
||||
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
|
||||
channelValue: z.string().nonempty(),
|
||||
integrationId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
|
||||
{
|
||||
message: "Must be a valid email address",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
|
||||
{
|
||||
message: "Must be a valid URL",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "SLACK"
|
||||
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
|
||||
: true,
|
||||
{
|
||||
message: "Must select a Slack channel",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
);
|
||||
|
||||
function formDataToCreateAlertChannelOptions(
|
||||
formData: z.infer<typeof FormSchema>
|
||||
): CreateAlertChannelOptions {
|
||||
switch (formData.type) {
|
||||
case "WEBHOOK": {
|
||||
return {
|
||||
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
environmentTypes: Array.isArray(formData.environmentTypes)
|
||||
? formData.environmentTypes
|
||||
: [formData.environmentTypes],
|
||||
channel: {
|
||||
type: "WEBHOOK",
|
||||
url: formData.channelValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "EMAIL": {
|
||||
return {
|
||||
name: `Email to ${formData.channelValue}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
environmentTypes: Array.isArray(formData.environmentTypes)
|
||||
? formData.environmentTypes
|
||||
: [formData.environmentTypes],
|
||||
channel: {
|
||||
type: "EMAIL",
|
||||
email: formData.channelValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "SLACK": {
|
||||
const [channelId, channelName] = formData.channelValue.split("/");
|
||||
|
||||
return {
|
||||
name: `Slack message to ${channelName}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
environmentTypes: Array.isArray(formData.environmentTypes)
|
||||
? formData.environmentTypes
|
||||
: [formData.environmentTypes],
|
||||
channel: {
|
||||
type: "SLACK",
|
||||
channelId,
|
||||
channelName,
|
||||
integrationId: formData.integrationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new NewAlertChannelPresenter();
|
||||
|
||||
const results = await presenter.call(project.id);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const option = url.searchParams.get("option");
|
||||
|
||||
const emailAlertsEnabled =
|
||||
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
|
||||
|
||||
return typedjson({
|
||||
...results,
|
||||
option: option === "slack" ? ("SLACK" as const) : undefined,
|
||||
emailAlertsEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = parseWithZod(formData, { schema: FormSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return json(submission.reply({ formErrors: ["Project not found"] }));
|
||||
}
|
||||
|
||||
// Validate the webhook URL before storing it, for an inline field error.
|
||||
if (submission.value.type === "WEBHOOK") {
|
||||
try {
|
||||
await assertSafeWebhookUrl(submission.value.channelValue);
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeWebhookUrlError) {
|
||||
return json(submission.reply({ fieldErrors: { channelValue: [error.message] } }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const service = new CreateAlertChannelService();
|
||||
const alertChannel = await service.call(
|
||||
project.externalRef,
|
||||
userId,
|
||||
formDataToCreateAlertChannelOptions(submission.value)
|
||||
);
|
||||
|
||||
if (!alertChannel) {
|
||||
return json(submission.reply({ formErrors: ["Failed to create alert channel"] }));
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Created ${alertChannel.name} alert`
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
|
||||
|
||||
const selectedSlackChannel = slack.channels?.find(
|
||||
(s) => selectedSlackChannelValue === `${s.id}/${s.name}`
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "create";
|
||||
|
||||
const [
|
||||
form,
|
||||
{ channelValue, alertTypes, environmentTypes, type, integrationId: _integrationId },
|
||||
] = useForm({
|
||||
id: "create-alert",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state !== "idle") return;
|
||||
if (lastSubmission !== undefined) return;
|
||||
|
||||
formRef.current?.reset();
|
||||
}, [navigation.state, lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3ProjectAlertsPath(organization, project, environment));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>New alert</DialogHeader>
|
||||
<Form ref={formRef} method="post" {...getFormProps(form)}>
|
||||
<Fieldset className="mt-2">
|
||||
<InputGroup fullWidth>
|
||||
<SegmentedControl
|
||||
{...getInputProps(type, { type: "text" })}
|
||||
options={[
|
||||
{ label: "Email", value: "EMAIL" },
|
||||
{ label: "Slack", value: "SLACK" },
|
||||
{ label: "Webhook", value: "WEBHOOK" },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setCurrentAlertChannel(value);
|
||||
}}
|
||||
fullWidth
|
||||
defaultValue={currentAlertChannel ?? undefined}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{currentAlertChannel === "EMAIL" ? (
|
||||
emailAlertsEnabled ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...getInputProps(channelValue, { type: "text" })}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Email integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)
|
||||
) : currentAlertChannel === "SLACK" ? (
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
<>
|
||||
<Select
|
||||
{...getSelectProps(channelValue)}
|
||||
placeholder="Select a Slack channel"
|
||||
heading="Filter channels…"
|
||||
defaultValue={undefined}
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
items={slack.channels}
|
||||
setValue={(value) => {
|
||||
typeof value === "string" && setSelectedSlackChannelValue(value);
|
||||
}}
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
text={(value) => {
|
||||
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
||||
if (!channel) return;
|
||||
return <SlackChannelTitle {...channel} />;
|
||||
}}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
{matches?.map((channel) => (
|
||||
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
|
||||
<SlackChannelTitle {...channel} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{selectedSlackChannel && selectedSlackChannel.is_private && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className={cn("text-sm", variantClasses.warning.textColor)}
|
||||
>
|
||||
To receive alerts in the{" "}
|
||||
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
|
||||
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
|
||||
Slack and type:{" "}
|
||||
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
|
||||
<input type="hidden" name="integrationId" value={slack.integrationId} />
|
||||
</>
|
||||
) : slack.status === "NOT_CONFIGURED" ? (
|
||||
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Callout variant="info">
|
||||
The Slack integration in your workspace has been revoked or has expired.
|
||||
Please re-connect your Slack workspace.
|
||||
</Callout>
|
||||
<LinkButton
|
||||
variant="tertiary/large"
|
||||
to={{
|
||||
pathname: "connect-to-slack",
|
||||
search: "?reinstall=true",
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Callout variant="warning">
|
||||
Failed loading channels from Slack. Please try again later.
|
||||
</Callout>
|
||||
</div>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Slack integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</InputGroup>
|
||||
) : (
|
||||
<InputGroup fullWidth>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
{...getInputProps(channelValue, { type: "text" })}
|
||||
placeholder="https://foobar.com/webhooks"
|
||||
type="url"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
|
||||
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<Label>Alert me when</Label>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="TASK_RUN"
|
||||
value="TASK_RUN"
|
||||
variant="simple/small"
|
||||
label="Task runs fail"
|
||||
defaultChecked
|
||||
className="pr-0"
|
||||
/>
|
||||
<InfoIconTooltip content="You'll receive an alert when a run completely fails." />
|
||||
</div>
|
||||
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_FAILURE"
|
||||
value="DEPLOYMENT_FAILURE"
|
||||
variant="simple/small"
|
||||
label="Deployments fail"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_SUCCESS"
|
||||
value="DEPLOYMENT_SUCCESS"
|
||||
variant="simple/small"
|
||||
label="Deployments succeed"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<FormError id={alertTypes.errorId}>{alertTypes.errors}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label>Environment</Label>
|
||||
<input type="hidden" name={environmentTypes.name} value={environment.type} />
|
||||
<EnvironmentCombo environment={{ type: environment.type }} />
|
||||
<FormError id={environmentTypes.errorId}>{environmentTypes.errors}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.errors}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button variant="primary/medium" disabled={isLoading} name="action" value="create">
|
||||
{isLoading ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
import { getFormProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
BellAlertIcon,
|
||||
BellSlashIcon,
|
||||
BookOpenIcon,
|
||||
EnvelopeIcon,
|
||||
GlobeAltIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AlertsNoneDev, AlertsNoneDeployed } from "~/components/BlankStatePanels";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DetailCell } from "~/components/primitives/DetailCell";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import {
|
||||
SimpleTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
AlertChannelListPresenter,
|
||||
type AlertChannelListPresenterRecord,
|
||||
} from "~/presenters/v3/AlertChannelListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3NewProjectAlertPath,
|
||||
v3ProjectAlertsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { alertsWorker } from "~/v3/alertsWorker.server";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Alerts | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new AlertChannelListPresenter();
|
||||
const data = await presenter.call(project.id, environment.type);
|
||||
|
||||
return typedjson(data);
|
||||
};
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("delete"), id: z.string() }),
|
||||
z.object({ action: z.literal("disable"), id: z.string() }),
|
||||
z.object({ action: z.literal("enable"), id: z.string() }),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return json(submission.reply({ formErrors: ["Project not found"] }));
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "delete": {
|
||||
const alertChannel = await prisma.projectAlertChannel.delete({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Deleted ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
case "disable": {
|
||||
const alertChannel = await prisma.projectAlertChannel.update({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Disabled ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
case "enable": {
|
||||
const alertChannel = await prisma.projectAlertChannel.update({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
data: { enabled: true },
|
||||
});
|
||||
|
||||
if (alertChannel.alertTypes.includes("ERROR_GROUP")) {
|
||||
await alertsWorker.enqueue({
|
||||
id: `evaluateErrorAlerts:${project.id}`,
|
||||
job: "v3.evaluateErrorAlerts",
|
||||
payload: {
|
||||
projectId: project.id,
|
||||
scheduledAt: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Enabled ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { alertChannels, limits } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
const requiresUpgrade = limits.used >= limits.limit;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Alerts" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/troubleshooting-alerts")}
|
||||
variant="docs/small"
|
||||
>
|
||||
Alerts docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{alertChannels.length > 0 ? (
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
<div className="flex h-fit items-end justify-between p-2 pl-3">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
{alertChannels.length > 0 && !requiresUpgrade && (
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Alert types</TableHeaderCell>
|
||||
<TableHeaderCell>Channel</TableHeaderCell>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertChannels.length > 0 ? (
|
||||
alertChannels.map((alertChannel) => (
|
||||
<TableRow key={alertChannel.id}>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.name}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
|
||||
<AlertChannelDetails alertChannel={alertChannel} />
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<EnabledStatus
|
||||
enabled={alertChannel.enabled}
|
||||
enabledIcon={BellAlertIcon}
|
||||
disabledIcon={BellSlashIcon}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<div className="flex items-center gap-3">
|
||||
{alertChannel.environmentTypes.map((environmentType) => (
|
||||
<EnvironmentCombo
|
||||
key={environmentType}
|
||||
environment={{ type: environmentType }}
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</>
|
||||
}
|
||||
className={
|
||||
alertChannel.enabled
|
||||
? ""
|
||||
: "group-hover/table-row:bg-background-bright/50"
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<Header2 spacing className="text-text-bright">
|
||||
You haven't created any project alerts yet
|
||||
</Header2>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Get alerted when runs or deployments fail, or when deployments succeed in
|
||||
both Prod and Staging environments.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex h-fit items-stretch gap-3">
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
requiresUpgrade ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your available alerts. Upgrade your plan
|
||||
to enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your alerts.
|
||||
</Header3>
|
||||
)}
|
||||
|
||||
{showSelfServe ? (
|
||||
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<AlertsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<AlertsNoneDeployed />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form] = useForm({
|
||||
id: "delete-alert-channel",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
<Button
|
||||
name="action"
|
||||
value="delete"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Deleting" : "Delete"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form] = useForm({
|
||||
id: "disable-alert-channel",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="disable"
|
||||
type="submit"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BellSlashIcon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Disabling" : "Disable"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function EnableAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form] = useForm({
|
||||
id: "enable-alert-channel",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="enable"
|
||||
type="submit"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-success"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Enabling" : "Enable"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
|
||||
switch (alertChannel.properties?.type) {
|
||||
case "EMAIL": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-text-dimmed"
|
||||
/>
|
||||
}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label={"Email"}
|
||||
description={alertChannel.properties.email}
|
||||
boxClassName="group-hover/table-row:bg-background-bright"
|
||||
className="h-12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-text-dimmed"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label={alertChannel.properties.url}
|
||||
description={
|
||||
<ClipboardField
|
||||
value={alertChannel.properties.secret}
|
||||
variant="secondary/small"
|
||||
icon={<LockClosedIcon className="size-4" />}
|
||||
iconButton
|
||||
secure={"•".repeat(alertChannel.properties.secret.length)}
|
||||
className="mt-1 w-80"
|
||||
/>
|
||||
}
|
||||
boxClassName="group-hover/table-row:bg-background-bright"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "SLACK": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-text-dimmed"
|
||||
/>
|
||||
}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label={"Slack"}
|
||||
description={`#${alertChannel.properties.channelName}`}
|
||||
boxClassName="group-hover/table-row:bg-background-bright"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alertTypeTitle(alertType: ProjectAlertType): string {
|
||||
switch (alertType) {
|
||||
case "TASK_RUN":
|
||||
return "Task run failure";
|
||||
case "TASK_RUN_ATTEMPT":
|
||||
return "Task attempt failure";
|
||||
case "DEPLOYMENT_FAILURE":
|
||||
return "Deployment failure";
|
||||
case "DEPLOYMENT_SUCCESS":
|
||||
return "Deployment success";
|
||||
case "ERROR_GROUP":
|
||||
return "Error group";
|
||||
default: {
|
||||
throw new Error(`Unknown alertType: ${alertType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function AlertChannelTypeIcon({
|
||||
channelType,
|
||||
className,
|
||||
}: {
|
||||
channelType: ProjectAlertChannelType;
|
||||
className: string;
|
||||
}) {
|
||||
switch (channelType) {
|
||||
case "EMAIL":
|
||||
return <EnvelopeIcon className={className} />;
|
||||
case "SLACK":
|
||||
return <SlackIcon className={className} />;
|
||||
case "WEBHOOK":
|
||||
return <GlobeAltIcon className={className} />;
|
||||
default: {
|
||||
assertNever(channelType);
|
||||
}
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import {
|
||||
EnvironmentCombo,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { PermissionDenied } from "~/components/PermissionDenied";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "~/components/primitives/Accordion";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
|
||||
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `API keys | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: EnvironmentParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
// No hard authorization: anyone with project access can open the page.
|
||||
// Reading the secret key is gated per environment tier below — a role
|
||||
// that can't read this tier's keys gets the info panel, not the key.
|
||||
},
|
||||
async ({ params, user, ability }) => {
|
||||
const { projectParam, envParam } = params;
|
||||
|
||||
try {
|
||||
const presenter = new ApiKeysPresenter();
|
||||
const { environment, hasVercelIntegration } = await presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
});
|
||||
|
||||
const canReadApiKeys =
|
||||
!environment || ability.can("read", { type: "apiKeys", envType: environment.type });
|
||||
|
||||
return typedjson({
|
||||
// Never serialize the secret key to the client when the role can't
|
||||
// read it for this environment tier.
|
||||
environment: environment && !canReadApiKeys ? { ...environment, apiKey: "" } : environment,
|
||||
hasVercelIntegration,
|
||||
canReadApiKeys,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const { environment, hasVercelIntegration, canReadApiKeys } = useTypedLoaderData<typeof loader>();
|
||||
const _organization = useOrganization();
|
||||
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
let envBlock = `TRIGGER_SECRET_KEY="${environment.apiKey}"`;
|
||||
if (environment.branchName) {
|
||||
envBlock += `\nTRIGGER_PREVIEW_BRANCH="${environment.branchName}"`;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="API keys" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
<Property.Item key={environment.id}>
|
||||
<Property.Label>{environment.slug}</Property.Label>
|
||||
<Property.Value>{environment.id}</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/v3/apikeys")}
|
||||
>
|
||||
API keys docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
<div className="mb-3 border-b border-grid-dimmed pb-1">
|
||||
<Header2
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 font-normal",
|
||||
environmentTextClassName(environment)
|
||||
)}
|
||||
>
|
||||
<EnvironmentCombo
|
||||
environment={environment}
|
||||
className="text-base"
|
||||
iconClassName="size-5"
|
||||
/>
|
||||
API keys
|
||||
</Header2>
|
||||
</div>
|
||||
{canReadApiKeys ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Label>Secret key</Label>
|
||||
<RegenerateApiKeyModal
|
||||
id={environment.parentEnvironment?.id ?? environment.id}
|
||||
title={environmentFullTitle(environment)}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={environment.type === "DEVELOPMENT"}
|
||||
/>
|
||||
</div>
|
||||
<ClipboardField
|
||||
className="w-full max-w-none"
|
||||
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
|
||||
value={environment.apiKey}
|
||||
variant={"secondary/small"}
|
||||
/>
|
||||
<Hint>
|
||||
Set this as your <InlineCode variant="extra-small">TRIGGER_SECRET_KEY</InlineCode>{" "}
|
||||
env var in your backend.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
{environment.branchName && (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Branch name</Label>
|
||||
<ClipboardField
|
||||
className="w-full max-w-none"
|
||||
value={environment.branchName}
|
||||
variant={"secondary/small"}
|
||||
/>
|
||||
<Hint>
|
||||
Set this as your{" "}
|
||||
<InlineCode variant="extra-small">TRIGGER_PREVIEW_BRANCH</InlineCode> env var in
|
||||
your backend.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
)}
|
||||
{environment.type === "DEVELOPMENT" && (
|
||||
<Callout variant="info">
|
||||
Every team member gets their own dev Secret key. Make sure you're using the one
|
||||
above otherwise you will trigger runs on your team member's machine.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>How to set these environment variables</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
You need to set these environment variables in your backend. This allows the
|
||||
SDK to authenticate with Trigger.dev.
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="javascript"
|
||||
code={envBlock}
|
||||
showOpenInModal={false}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
) : (
|
||||
<PermissionDenied
|
||||
message={`With your current role, you can't view the API keys for ${environmentFullTitle(
|
||||
environment
|
||||
)}.`}
|
||||
/>
|
||||
)}
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { motion } from "framer-motion";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { BatchStatusCombo, descriptionForBatchStatus } from "~/components/runs/v3/BatchStatus";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
const BatchParamSchema = EnvironmentParamSchema.extend({
|
||||
batchParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam, batchParam } = BatchParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new BatchPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
batchId: batchParam,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ batch: data });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batch } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-reload when batch is still in progress
|
||||
useAutoRevalidate({
|
||||
interval: 1000,
|
||||
onFocus: true,
|
||||
disabled: batch.hasFinished,
|
||||
});
|
||||
|
||||
const showProgressMeter =
|
||||
batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
{/* Header */}
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
|
||||
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{descriptionForBatchStatus(batch.status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="space-y-3">
|
||||
{/* Progress meter for v2 batches */}
|
||||
{showProgressMeter && (
|
||||
<div className="px-3 pt-3">
|
||||
<BatchProgressMeter
|
||||
successCount={batch.successfulRunCount}
|
||||
failureCount={batch.failedRunCount}
|
||||
totalCount={batch.runCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
<div className="px-3 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.friendlyId} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Total runs</Property.Label>
|
||||
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.isV2 && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Successfully created</Property.Label>
|
||||
<Property.Value className="text-success">
|
||||
{formatNumber(batch.successfulRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.failedRunCount > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Failed to create</Property.Label>
|
||||
<Property.Value className="text-error">
|
||||
{formatNumber(batch.failedRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{batch.idempotencyKey && (
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency key</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.processingStartedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing started</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingStartedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{batch.processingCompletedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing completed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingCompletedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Finished</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{/* Errors section */}
|
||||
{batch.errors.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
Run creation errors ({batch.errors.length})
|
||||
</Header3>
|
||||
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-background-deep">
|
||||
{batch.errors.map((error) => (
|
||||
<div key={error.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-text-dimmed">
|
||||
Item #{error.index}
|
||||
</span>
|
||||
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
|
||||
</div>
|
||||
{error.errorCode && (
|
||||
<span className="rounded bg-background-hover px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
|
||||
{error.errorCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1 text-error">
|
||||
{error.error}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
|
||||
<LinkButton
|
||||
variant="secondary/medium"
|
||||
to={v3BatchRunsPath(organization, project, environment, batch)}
|
||||
trailingIconClassName="text-runs"
|
||||
TrailingIcon={RunsIcon}
|
||||
className="text-text-bright"
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BatchProgressMeterProps = {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
|
||||
const processedCount = successCount + failureCount;
|
||||
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
|
||||
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Paragraph variant="small/bright">Run creation progress</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(processedCount)}/{formatNumber(totalCount)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-background-deep">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full bg-success"
|
||||
initial={{ width: `${successPercentage}%` }}
|
||||
animate={{ width: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 h-full bg-error"
|
||||
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-success" />
|
||||
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
|
||||
</div>
|
||||
{failureCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-error" />
|
||||
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { BatchesNone } from "~/components/BlankStatePanels";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
collapsibleHandleClassName,
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
CopyableTableCell,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
|
||||
import {
|
||||
allBatchStatuses,
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
$replica,
|
||||
runOpsNewReplicaClient,
|
||||
runOpsLegacyReplica,
|
||||
runOpsSplitReadEnabled,
|
||||
type PrismaClientOrTransaction,
|
||||
} from "~/db.server";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BatchPath,
|
||||
v3BatchRunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { throwNotFound } from "~/utils/httpErrors";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Batches | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throwNotFound("Environment not found");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
id: url.searchParams.get("id") ?? undefined,
|
||||
};
|
||||
const filters = BatchListFilters.parse(s);
|
||||
|
||||
const presenter = new BatchListPresenter(undefined, undefined, {
|
||||
runOpsNew: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
|
||||
runOpsLegacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
|
||||
controlPlaneReplica: $replica as unknown as PrismaClientOrTransaction,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
friendlyId: filters.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
return typedjson(list);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { batchParam } = useParams();
|
||||
const isShowingInspector = batchParam !== undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Batches" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/triggering")}
|
||||
>
|
||||
Batches docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{!hasAnyBatches ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<BatchesNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="batches-main" min={"100px"}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id="batches-handle"
|
||||
className={collapsibleHandleClassName(isShowingInspector)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="batches-inspector"
|
||||
min="370px"
|
||||
default="370px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!isShowingInspector}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 370 }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { batchParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{allBatchStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BatchStatusCombo status={status} />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
{descriptionForBatchStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Runs</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Finished</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to batch</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches match these filters</Paragraph>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch) => {
|
||||
const basePath = v3BatchPath(organization, project, environment, batch);
|
||||
const inspectorPath = `${basePath}${location.search}`;
|
||||
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
|
||||
const isSelected = batchParam === batch.friendlyId;
|
||||
|
||||
return (
|
||||
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
|
||||
<CopyableTableCell value={batch.friendlyId} to={inspectorPath} isTabbableCell>
|
||||
{batch.friendlyId}
|
||||
</CopyableTableCell>
|
||||
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<ExclamationCircleIcon className="size-4 text-text-dimmed" />
|
||||
<span>Legacy batch</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
content={descriptionForBatchStatus(batch.status)}
|
||||
disableHoverableContent
|
||||
button={<BatchStatusCombo status={batch.status} />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
|
||||
<TableCell
|
||||
to={inspectorPath}
|
||||
className="w-[1%]"
|
||||
actionClassName="pr-0 tabular-nums"
|
||||
>
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : (
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={inspectorPath}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell runsPath={runsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ runsPath }: { runsPath: string }) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
to={runsPath}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={RunsIcon}
|
||||
trailingIconClassName="text-runs"
|
||||
className="text-text-bright"
|
||||
>
|
||||
<span>View runs</span>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { useFetcher, useSearchParams } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { SearchInput } from "~/components/primitives/SearchInput";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { V4Title } from "~/components/V4Badge";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
branchesPath,
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3BillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
import { SetBranchesAddOnService } from "~/v3/services/setBranchesAddOn.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { ArchiveButton } from "../resources.branches.archive";
|
||||
import { NewBranchPanel } from "~/routes/resources.branches.create";
|
||||
import { BranchesOptions } from "~/utils/branches";
|
||||
import { IconArrowBearRight2 } from "@tabler/icons-react";
|
||||
|
||||
const PurchaseSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("purchase"),
|
||||
amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("quota-increase"),
|
||||
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
|
||||
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
|
||||
|
||||
try {
|
||||
const presenter = new BranchesPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
env: "preview",
|
||||
...options,
|
||||
});
|
||||
|
||||
return typedjson(result);
|
||||
} catch (error) {
|
||||
logger.error("Error loading preview branches page", { error });
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const formType = formData.get("_formType");
|
||||
|
||||
if (formType === "purchase-branches") {
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const redirectPath = branchesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
|
||||
}
|
||||
|
||||
const currentPlan = await getCurrentPlan(project.organizationId);
|
||||
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
|
||||
if (purchaseBlockReason === "plan_unavailable") {
|
||||
return json(
|
||||
{ ok: false, error: "Unable to verify billing status. Please try again." } as const,
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (purchaseBlockReason === "managed_billing") {
|
||||
return json({ ok: false, error: "Contact us to request more branches." } as const, {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const submission = parseWithZod(formData, { schema: PurchaseSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const service = new SetBranchesAddOnService();
|
||||
const [error, result] = await tryCatch(
|
||||
service.call({
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
action: submission.value.action,
|
||||
amount: submission.value.amount,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return json(
|
||||
submission.reply({
|
||||
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
|
||||
}
|
||||
|
||||
return json({ ok: true } as const);
|
||||
}
|
||||
|
||||
// Branch creation is handled by the `resources.branches.create` resource
|
||||
// route; this action only services the purchase flow above.
|
||||
return json({ ok: false, error: "Unsupported action" } as const, { status: 400 });
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
branchableEnvironment,
|
||||
branches,
|
||||
hasFilters: _hasFilters,
|
||||
limits,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasBranches,
|
||||
canPurchaseBranches,
|
||||
extraBranches,
|
||||
branchPricing,
|
||||
maxBranchQuota,
|
||||
planBranchLimit,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const plan = useCurrentPlan();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const requiresUpgrade =
|
||||
plan?.v3Subscription?.plan &&
|
||||
limits.used >= plan.v3Subscription.plan.limits.branches.number &&
|
||||
!plan.v3Subscription.plan.limits.branches.canExceed;
|
||||
const canUpgrade =
|
||||
plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed;
|
||||
const atBranchLimit = limits.used >= limits.limit;
|
||||
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
|
||||
|
||||
if (!branchableEnvironment) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={<V4Title>Preview branches</V4Title>} />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<BranchesNoBranchableEnvironment showSelfServe={showSelfServe} />
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={<V4Title>Preview branches</V4Title>} />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
{branches.map((branch) => (
|
||||
<Property.Item key={branch.id}>
|
||||
<Property.Label>{branch.branchName}</Property.Label>
|
||||
<Property.Value>{branch.id}</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("deployment/preview-branches")}
|
||||
>
|
||||
Branches docs
|
||||
</LinkButton>
|
||||
|
||||
{limits.isAtLimit ? (
|
||||
<UpgradePanel
|
||||
limits={limits}
|
||||
canUpgrade={canUpgrade ?? false}
|
||||
canPurchaseBranches={canPurchaseBranches}
|
||||
branchPricing={branchPricing}
|
||||
extraBranches={extraBranches}
|
||||
maxBranchQuota={maxBranchQuota}
|
||||
planBranchLimit={planBranchLimit}
|
||||
/>
|
||||
) : (
|
||||
<NewBranchPanel
|
||||
button={
|
||||
<Button
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-white"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
New branch…
|
||||
</Button>
|
||||
}
|
||||
env="preview"
|
||||
/>
|
||||
)}
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
{!hasBranches ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<BranchesNoBranches
|
||||
env="preview"
|
||||
limits={limits}
|
||||
canUpgrade={canUpgrade ?? false}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-x-1.5 p-2">
|
||||
<BranchFilters />
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Branch</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Git</TableHeaderCell>
|
||||
<TableHeaderCell>Archived</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{branches.length === 0 ? (
|
||||
<TableBlankRow colSpan={5}>
|
||||
<Paragraph>There are no matches for your filters</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
branches.map((branch) => {
|
||||
const path = branchesPath(organization, project, branch);
|
||||
const cellClass = branch.archivedAt ? "opacity-50" : "";
|
||||
const isSelected = branch.id === environment.id;
|
||||
|
||||
return (
|
||||
<TableRow key={branch.id}>
|
||||
<TableCell isTabbableCell className={cellClass}>
|
||||
<div className="flex items-center gap-1">
|
||||
<BranchEnvironmentIconSmall
|
||||
className={cn("size-4", isSelected && "text-preview")}
|
||||
/>
|
||||
<CopyableText
|
||||
value={branch.branchName ?? ""}
|
||||
className={cn(isSelected && "text-preview")}
|
||||
/>
|
||||
{isSelected && <Badge variant="extra-small">Current</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<DateTime date={branch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="-ml-1 flex items-center">
|
||||
<GitMetadata git={branch.git} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
{branch.archivedAt ? (
|
||||
<CheckIcon className="size-4 text-text-dimmed" />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
isSticky
|
||||
hiddenButtons={
|
||||
isSelected ? null : (
|
||||
<LinkButton
|
||||
to={path}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={IconArrowBearRight2}
|
||||
leadingIconClassName="text-blue-500 -mr-2"
|
||||
className="pl-1.5"
|
||||
>
|
||||
Switch to branch
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
popoverContent={
|
||||
!isSelected || !branch.archivedAt ? (
|
||||
<>
|
||||
{isSelected ? null : (
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={IconArrowBearRight2}
|
||||
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
|
||||
title="Switch to branch"
|
||||
/>
|
||||
)}
|
||||
{!branch.archivedAt ? (
|
||||
<ArchiveButton environment={branch} />
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
atBranchLimit ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${usageRatio * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round(usageRatio * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your branches. Archive one or upgrade your
|
||||
plan to enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Header3 className={atBranchLimit ? "text-error" : undefined}>
|
||||
You've used {limits.used}/{limits.limit} of your branches
|
||||
</Header3>
|
||||
<InfoIconTooltip content="Archived branches don't count towards your limit." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canPurchaseBranches && branchPricing ? (
|
||||
<PurchaseBranchesModal
|
||||
branchPricing={branchPricing}
|
||||
extraBranches={extraBranches}
|
||||
activeBranches={limits.used}
|
||||
maxQuota={maxBranchQuota}
|
||||
planBranchLimit={planBranchLimit}
|
||||
/>
|
||||
) : canUpgrade ? (
|
||||
showSelfServe ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Paragraph variant="small" className="whitespace-nowrap text-text-dimmed">
|
||||
Upgrade plan for more Preview Branches
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchFilters() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
|
||||
|
||||
const handleArchivedChange = useCallback((checked: boolean) => {
|
||||
setSearchParams((s) => {
|
||||
if (checked) {
|
||||
s.set("showArchived", "true");
|
||||
} else {
|
||||
s.delete("showArchived");
|
||||
}
|
||||
s.delete("page");
|
||||
return s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<SearchInput placeholder="Search branch name…" resetParams={["page"]} />
|
||||
<Switch
|
||||
checked={showArchived ?? false}
|
||||
onCheckedChange={handleArchivedChange}
|
||||
label="Show archived"
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpgradePanel({
|
||||
limits,
|
||||
canUpgrade,
|
||||
canPurchaseBranches,
|
||||
branchPricing,
|
||||
extraBranches,
|
||||
maxBranchQuota,
|
||||
planBranchLimit,
|
||||
}: {
|
||||
limits: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
canUpgrade: boolean;
|
||||
canPurchaseBranches: boolean;
|
||||
branchPricing: { stepSize: number; centsPerStep: number } | null;
|
||||
extraBranches: number;
|
||||
maxBranchQuota: number;
|
||||
planBranchLimit: number;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
if (canPurchaseBranches && branchPricing) {
|
||||
return (
|
||||
<PurchaseBranchesModal
|
||||
branchPricing={branchPricing}
|
||||
extraBranches={extraBranches}
|
||||
activeBranches={limits.used}
|
||||
maxQuota={maxBranchQuota}
|
||||
planBranchLimit={planBranchLimit}
|
||||
triggerButton={
|
||||
<Button
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-white"
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Purchase more…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-white"
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New branch
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>You've exceeded your limit</DialogHeader>
|
||||
<div className="mt-2">
|
||||
<Paragraph spacing>
|
||||
You've used {limits.used}/{limits.limit} of your branches.
|
||||
</Paragraph>
|
||||
<Paragraph>You can archive one or upgrade your plan for more.</Paragraph>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{canUpgrade ? (
|
||||
showSelfServe ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseBranchesModal({
|
||||
branchPricing,
|
||||
extraBranches,
|
||||
activeBranches,
|
||||
maxQuota,
|
||||
planBranchLimit,
|
||||
triggerButton,
|
||||
}: {
|
||||
branchPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
extraBranches: number;
|
||||
activeBranches: number;
|
||||
maxQuota: number;
|
||||
planBranchLimit: number;
|
||||
triggerButton?: React.ReactNode;
|
||||
}) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const fetcher = useFetcher();
|
||||
const lastSubmission =
|
||||
fetcher.data && typeof fetcher.data === "object" && "status" in fetcher.data
|
||||
? fetcher.data
|
||||
: undefined;
|
||||
const [form, { amount }] = useForm({
|
||||
id: "purchase-branches",
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: PurchaseSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const [amountValue, setAmountValue] = useState(extraBranches);
|
||||
useEffect(() => {
|
||||
setAmountValue(extraBranches);
|
||||
}, [extraBranches]);
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const data = fetcher.data;
|
||||
if (
|
||||
fetcher.state === "idle" &&
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"ok" in data &&
|
||||
data.ok
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [fetcher.state, fetcher.data]);
|
||||
|
||||
const state = updateBranchState({
|
||||
value: amountValue,
|
||||
existingValue: extraBranches,
|
||||
quota: maxQuota,
|
||||
activeBranches,
|
||||
planBranchLimit,
|
||||
});
|
||||
const changeClassName =
|
||||
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
|
||||
|
||||
const pricePerBranch = branchPricing.centsPerStep / branchPricing.stepSize / 100;
|
||||
const title = extraBranches === 0 ? "Purchase extra branches…" : "Add/remove extra branches…";
|
||||
|
||||
if (!showSelfServe) {
|
||||
return (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{triggerButton ?? (
|
||||
<Button variant="primary/small" onClick={() => setOpen(true)}>
|
||||
{title}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<fetcher.Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="_formType" value="purchase-branches" />
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="small/bright">
|
||||
Purchase extra preview branches at {formatCurrency(pricePerBranch, false)}/month per
|
||||
branch. Reducing the number of branches will take effect at the start of the next
|
||||
billing cycle (1st of the month).
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="amount" className="text-text-dimmed">
|
||||
Total extra branches
|
||||
</Label>
|
||||
<InputNumberStepper
|
||||
{...getInputProps(amount, { type: "number" })}
|
||||
step={branchPricing.stepSize}
|
||||
min={0}
|
||||
max={undefined}
|
||||
value={amountValue}
|
||||
onChange={(e) => setAmountValue(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<FormError id={amount.errorId}>{amount.errors}</FormError>
|
||||
<FormError>{form.errors}</FormError>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
{state === "need_to_archive" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
You need to archive{" "}
|
||||
{formatNumber(activeBranches - (planBranchLimit + amountValue))} more{" "}
|
||||
{activeBranches - (planBranchLimit + amountValue) === 1 ? "branch" : "branches"}{" "}
|
||||
before you can reduce to this level.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : state === "above_quota" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
Currently you can only have up to {maxQuota} extra preview branches. Send a
|
||||
request below to lift your current limit. We'll get back to you soon.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pb-3 tabular-nums">
|
||||
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
|
||||
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(extraBranches)}</span> current
|
||||
extra
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(extraBranches * pricePerBranch, true)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({extraBranches} {extraBranches === 1 ? "branch" : "branches"})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatNumber(amountValue - extraBranches)}
|
||||
</Header3>
|
||||
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatCurrency((amountValue - extraBranches) * pricePerBranch, true)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({Math.abs(amountValue - extraBranches)}{" "}
|
||||
{Math.abs(amountValue - extraBranches) === 1 ? "branch" : "branches"} @{" "}
|
||||
{formatCurrency(pricePerBranch, true)}/mth)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(amountValue * pricePerBranch, true)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({amountValue} {amountValue === 1 ? "branch" : "branches"})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
state === "above_quota" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="quota-increase" />
|
||||
<Button
|
||||
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
|
||||
amountValue
|
||||
)}`}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : state === "decrease" || state === "need_to_archive" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "need_to_archive"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
|
||||
extraBranches - amountValue
|
||||
)} ${extraBranches - amountValue === 1 ? "branch" : "branches"}`}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
|
||||
amountValue - extraBranches
|
||||
)} ${amountValue - extraBranches === 1 ? "branch" : "branches"}`}</span>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function updateBranchState({
|
||||
value,
|
||||
existingValue,
|
||||
quota,
|
||||
activeBranches,
|
||||
planBranchLimit,
|
||||
}: {
|
||||
value: number;
|
||||
existingValue: number;
|
||||
quota: number;
|
||||
activeBranches: number;
|
||||
planBranchLimit: number;
|
||||
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_archive" {
|
||||
if (value === existingValue) return "no_change";
|
||||
if (value < existingValue) {
|
||||
const newTotalLimit = planBranchLimit + value;
|
||||
if (activeBranches > newTotalLimit) {
|
||||
return "need_to_archive";
|
||||
}
|
||||
return "decrease";
|
||||
}
|
||||
if (value > quota) return "above_quota";
|
||||
return "increase";
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { NoSymbolIcon } from "@heroicons/react/24/solid";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import type { BulkActionType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { BulkActionFilterSummary } from "~/components/BulkActionFilterSummary";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
|
||||
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { env } from "~/env.server";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3BulkActionPath,
|
||||
v3BulkActionsPath,
|
||||
v3CreateBulkActionPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
|
||||
|
||||
const BulkActionParamSchema = EnvironmentParamSchema.extend({
|
||||
bulkActionParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: BulkActionParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: { action: "read", resource: { type: "runs" } },
|
||||
},
|
||||
async ({ params, user, ability }) => {
|
||||
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new BulkActionPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
bulkActionId: bulkActionParam,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
// Display flag for the Abort button — the action enforces write:runs.
|
||||
const { canAbort } = checkPermissions(ability, {
|
||||
canAbort: { action: "write", resource: { type: "runs" } },
|
||||
});
|
||||
|
||||
return typedjson({ bulkAction: data, autoReloadPollIntervalMs, canAbort });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: BulkActionParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: { action: "write", resource: { type: "runs" } },
|
||||
},
|
||||
async ({ request, params, user }) => {
|
||||
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const service = new BulkActionService();
|
||||
const [error, _result] = await tryCatch(service.abort(bulkActionParam, environment.id));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed to abort bulk action", {
|
||||
error,
|
||||
});
|
||||
|
||||
return redirectWithErrorMessage(
|
||||
v3BulkActionPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: bulkActionParam }
|
||||
),
|
||||
request,
|
||||
`Failed to abort bulk action: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3BulkActionPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: bulkActionParam }
|
||||
),
|
||||
request,
|
||||
"Bulk action aborted"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const { bulkAction, autoReloadPollIntervalMs, canAbort } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
useAutoRevalidate({
|
||||
interval: autoReloadPollIntervalMs,
|
||||
onFocus: true,
|
||||
disabled: bulkAction.status !== "PENDING",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
|
||||
<Header2 className={cn("truncate whitespace-nowrap")}>
|
||||
{bulkAction.name || bulkAction.friendlyId}
|
||||
</Header2>
|
||||
<LinkButton
|
||||
to={v3BulkActionsPath(organization, project, environment)}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
|
||||
<BulkActionStatusCombo status={bulkAction.status} />
|
||||
{bulkAction.status === "PENDING" ? (
|
||||
<ControlledAbortBulkActionDialog
|
||||
canAbort={canAbort}
|
||||
formAction={v3BulkActionPath(organization, project, environment, bulkAction)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="space-y-3">
|
||||
<div className="px-3 pt-3">
|
||||
<Meter
|
||||
type={bulkAction.type}
|
||||
successCount={bulkAction.successCount}
|
||||
failureCount={bulkAction.failureCount}
|
||||
totalCount={bulkAction.totalCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={bulkAction.friendlyId} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Bulk action</Property.Label>
|
||||
<Property.Value>
|
||||
<BulkActionTypeCombo type={bulkAction.type} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>User</Property.Label>
|
||||
<Property.Value>
|
||||
{bulkAction.user ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
name={bulkAction.user.name}
|
||||
avatarUrl={bulkAction.user.avatarUrl}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={bulkAction.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Completed</Property.Label>
|
||||
<Property.Value>
|
||||
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Summary</Property.Label>
|
||||
<Property.Value>
|
||||
<BulkActionFilterSummary
|
||||
selected={bulkAction.totalCount}
|
||||
mode={bulkAction.mode}
|
||||
action={bulkAction.type === "REPLAY" ? "replay" : "cancel"}
|
||||
filters={bulkAction.filters}
|
||||
final={true}
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<LinkButton
|
||||
to={v3CreateBulkActionPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{
|
||||
bulkId: bulkAction.friendlyId,
|
||||
},
|
||||
undefined,
|
||||
"replay"
|
||||
)}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Replay runs
|
||||
</LinkButton>
|
||||
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={v3RunsPath(organization, project, environment, {
|
||||
bulkId: bulkAction.friendlyId,
|
||||
})}
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MeterProps = {
|
||||
type: BulkActionType;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
|
||||
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
|
||||
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Paragraph variant="small/bright">Runs</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(successCount + failureCount)}/{formatNumber(totalCount)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-background-deep">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full w-full bg-success"
|
||||
initial={{ width: `${successPercentage}%` }}
|
||||
animate={{ width: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 h-full w-full bg-surface-control-hover"
|
||||
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-success" />
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(successCount)} {typeText(type)} successfully
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-surface-control-hover" />
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(failureCount)} {typeText(type)} failed{" "}
|
||||
{type === "CANCEL" ? " (already finished)" : ""}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function typeText(type: BulkActionType) {
|
||||
switch (type) {
|
||||
case "CANCEL":
|
||||
return "canceled";
|
||||
case "REPLAY":
|
||||
return "replayed";
|
||||
}
|
||||
}
|
||||
|
||||
function ControlledAbortBulkActionDialog({
|
||||
canAbort,
|
||||
formAction,
|
||||
}: {
|
||||
canAbort: boolean;
|
||||
formAction: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="danger/small"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
disabled={!canAbort}
|
||||
tooltip={canAbort ? undefined : "You don't have permission to abort bulk actions"}
|
||||
>
|
||||
Abort…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<AbortBulkActionDialog formAction={formAction} onAbortSubmitted={() => setOpen(false)} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { NoSymbolIcon } from "@heroicons/react/24/solid";
|
||||
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { BulkActionsNone } from "~/components/BlankStatePanels";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
collapsibleHandleClassName,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
|
||||
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type BulkActionListItem,
|
||||
BulkActionListPresenter,
|
||||
} from "~/presenters/v3/BulkActionListPresenter.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BulkActionPath,
|
||||
v3CreateBulkActionPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Bulk actions | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
page: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const { page } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
|
||||
|
||||
const presenter = new BulkActionListPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
page,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// Display flag for the row-menu Abort control. The abort action route
|
||||
// enforces write:runs independently. Permissive in OSS.
|
||||
const bulkAuth = await rbac.authenticateSession(request, {
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
const { canAbort } = bulkAuth.ok
|
||||
? checkPermissions(bulkAuth.ability, {
|
||||
canAbort: { action: "write", resource: { type: "runs" } },
|
||||
})
|
||||
: { canAbort: true };
|
||||
|
||||
return typedjson({ ...data, canAbort });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
bulkActions,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalCount: _totalCount,
|
||||
canAbort,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { bulkActionParam } = useParams();
|
||||
const isShowingInspector = bulkActionParam !== undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Bulk actions" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/bulk-actions")}
|
||||
>
|
||||
Bulk actions docs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
to={v3CreateBulkActionPath(organization, project, environment)}
|
||||
shortcut={{
|
||||
key: "n",
|
||||
}}
|
||||
>
|
||||
New bulk action
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{bulkActions.length === 0 ? (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<BulkActionsNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="bulk-actions-main" min={"100px"}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid max-h-full min-h-full overflow-x-auto",
|
||||
totalPages > 1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr]"
|
||||
)}
|
||||
>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-x-2 p-2">
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BulkActionsTable
|
||||
bulkActions={bulkActions}
|
||||
totalPages={totalPages}
|
||||
canAbort={canAbort}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-full",
|
||||
totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3"
|
||||
)}
|
||||
>
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id="bulk-actions-handle"
|
||||
className={collapsibleHandleClassName(isShowingInspector)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="bulk-actions-inspector"
|
||||
default="500px"
|
||||
min="500px"
|
||||
max="800px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!isShowingInspector}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 500 }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionsTable({
|
||||
bulkActions,
|
||||
totalPages,
|
||||
canAbort,
|
||||
}: {
|
||||
bulkActions: BulkActionListItem[];
|
||||
totalPages: number;
|
||||
canAbort: boolean;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { bulkActionParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table containerClassName={cn(totalPages === 1 && "border-t-0")}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BulkActionStatusCombo status="PENDING" />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
The bulk action is currently in progress. They can take some time if there are
|
||||
lots of runs.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BulkActionStatusCombo status="COMPLETED" />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
The bulk action has completed successfully.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BulkActionStatusCombo status="ABORTED" />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
The bulk action was aborted.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Bulk action</TableHeaderCell>
|
||||
<TableHeaderCell>Runs</TableHeaderCell>
|
||||
<TableHeaderCell>User</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Completed</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bulkActions.length === 0 ? (
|
||||
<TableBlankRow colSpan={9}>There are no matching bulk actions</TableBlankRow>
|
||||
) : (
|
||||
bulkActions.map((bulkAction) => {
|
||||
const path = v3BulkActionPath(organization, project, environment, bulkAction);
|
||||
const isSelected = bulkActionParam === bulkAction.friendlyId;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={bulkAction.friendlyId}
|
||||
className={isSelected ? "bg-grid-dimmed" : undefined}
|
||||
>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TruncatedCopyableValue value={bulkAction.friendlyId} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>{bulkAction.name || "–"}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<BulkActionStatusCombo status={bulkAction.status} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<BulkActionTypeCombo type={bulkAction.type} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>{bulkAction.totalCount}</TableCell>
|
||||
<TableCell to={path}>
|
||||
{bulkAction.user ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
name={bulkAction.user.name}
|
||||
avatarUrl={bulkAction.user.avatarUrl}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<DateTime date={bulkAction.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BulkActionActionsCell bulkAction={bulkAction} path={path} canAbort={canAbort} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionActionsCell({
|
||||
bulkAction,
|
||||
path,
|
||||
canAbort,
|
||||
}: {
|
||||
bulkAction: BulkActionListItem;
|
||||
path: string;
|
||||
canAbort: boolean;
|
||||
}) {
|
||||
// Abort is the only action, and only while the bulk action is still running.
|
||||
if (bulkAction.status !== "PENDING") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
hiddenButtons={
|
||||
canAbort ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
>
|
||||
<span className="text-text-bright">Abort…</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<AbortBulkActionDialog formAction={path} />
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
disabled
|
||||
tooltip="You don't have permission to abort bulk actions"
|
||||
>
|
||||
<span className="text-text-bright">Abort…</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
type MetaFunction,
|
||||
} from "@remix-run/react";
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import simplur from "simplur";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
ManageConcurrencyPresenter,
|
||||
type ConcurrencyResult,
|
||||
type EnvironmentWithConcurrency,
|
||||
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getPlans,
|
||||
getSelfServePurchaseBlockReason,
|
||||
} from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
|
||||
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Manage concurrency | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const {
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam: _envParam,
|
||||
} = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new ManageConcurrencyPresenter();
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
userId: userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const plans = await tryCatch(getPlans());
|
||||
if (!plans) {
|
||||
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
};
|
||||
|
||||
const FormSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.enum(["purchase"]),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["quota-increase"]),
|
||||
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["allocate"]),
|
||||
// It will only update environments that are passed in
|
||||
environments: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
})
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const redirectPath = concurrencyPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema: FormSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
if (submission.value.action === "allocate") {
|
||||
const allocate = new AllocateConcurrencyService();
|
||||
const [error, result] = await tryCatch(
|
||||
allocate.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
environments: submission.value.environments,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return json(
|
||||
submission.reply({
|
||||
fieldErrors: {
|
||||
environments: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return json(submission.reply({ fieldErrors: { environments: [result.error] } }));
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
`${redirectPath}?success=true`,
|
||||
request,
|
||||
"Concurrency allocated successfully"
|
||||
);
|
||||
}
|
||||
|
||||
const currentPlan = await getCurrentPlan(project.organizationId);
|
||||
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
|
||||
if (purchaseBlockReason === "plan_unavailable") {
|
||||
return json(
|
||||
submission.reply({
|
||||
fieldErrors: { amount: ["Unable to verify billing status. Please try again."] },
|
||||
}),
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (purchaseBlockReason === "managed_billing") {
|
||||
return json(
|
||||
submission.reply({ fieldErrors: { amount: ["Contact us to request more concurrency."] } }),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const service = new SetConcurrencyAddOnService();
|
||||
const [error, result] = await tryCatch(
|
||||
service.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
action: submission.value.action,
|
||||
amount: submission.value.amount,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return json(
|
||||
submission.reply({
|
||||
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
`${redirectPath}?success=true`,
|
||||
request,
|
||||
submission.value.action === "purchase"
|
||||
? "Concurrency updated successfully"
|
||||
: "Requested extra concurrency, we'll get back to you soon."
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
canAddConcurrency,
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
environments,
|
||||
concurrencyPricing,
|
||||
maxQuota,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Concurrency" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
{environments.map((environment) => (
|
||||
<Property.Item key={environment.id}>
|
||||
<Property.Label>
|
||||
{environment.type}{" "}
|
||||
{environment.branchName ? ` (${environment.branchName})` : ""}
|
||||
</Property.Label>
|
||||
<Property.Value>{environment.id}</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
{canAddConcurrency ? (
|
||||
<Upgradable
|
||||
canAddConcurrency={canAddConcurrency}
|
||||
extraConcurrency={extraConcurrency}
|
||||
extraAllocatedConcurrency={extraAllocatedConcurrency}
|
||||
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
||||
environments={environments}
|
||||
concurrencyPricing={concurrencyPricing}
|
||||
maxQuota={maxQuota}
|
||||
/>
|
||||
) : (
|
||||
<NotUpgradable environments={environments} />
|
||||
)}
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function initialAllocation(environments: ConcurrencyResult["environments"]) {
|
||||
return new Map<string, number>(
|
||||
environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
|
||||
);
|
||||
}
|
||||
|
||||
function allocationTotal(environments: ConcurrencyResult["environments"]) {
|
||||
const allocation = initialAllocation(environments);
|
||||
return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
||||
}
|
||||
|
||||
function Upgradable({
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
environments,
|
||||
concurrencyPricing,
|
||||
maxQuota,
|
||||
}: ConcurrencyResult) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form, fields] = useForm({
|
||||
id: "allocate-concurrency",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
const { environments: formEnvironments } = fields;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
||||
|
||||
const [allocation, setAllocation] = useState(initialAllocation(environments));
|
||||
|
||||
const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
||||
const initialAllocationInProject = allocationTotal(environments);
|
||||
const changeInAllocation = allocatedInProject - initialAllocationInProject;
|
||||
const unallocated = extraUnallocatedConcurrency - changeInAllocation;
|
||||
const allocationModified = changeInAllocation !== 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-b border-grid-dimmed pb-1">
|
||||
<Header2>Manage your concurrency</Header2>
|
||||
</div>
|
||||
<Paragraph variant="small">
|
||||
Concurrency limits determine how many runs you can execute at the same time. You can add
|
||||
extra concurrency to your organization which you can allocate to environments in your
|
||||
projects.
|
||||
</Paragraph>
|
||||
<div className="mt-3 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center first-letter:pb-1">
|
||||
<Header3 className="grow">Extra concurrency</Header3>
|
||||
<PurchaseConcurrencyModal
|
||||
concurrencyPricing={concurrencyPricing}
|
||||
extraConcurrency={extraConcurrency}
|
||||
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
||||
maxQuota={maxQuota}
|
||||
disabled={unallocated < 0 ? false : allocationModified}
|
||||
/>
|
||||
</div>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums text-text-bright">
|
||||
{extraConcurrency}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Allocated concurrency</TableCell>
|
||||
<TableCell alignment="right" className={"tabular-nums text-text-bright"}>
|
||||
{allocationModified ? (
|
||||
<>
|
||||
<span className="text-text-dimmed line-through">
|
||||
{extraAllocatedConcurrency}
|
||||
</span>{" "}
|
||||
{extraAllocatedConcurrency + changeInAllocation}
|
||||
</>
|
||||
) : (
|
||||
extraAllocatedConcurrency
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Unallocated concurrency</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
unallocated > 0
|
||||
? "text-success"
|
||||
: unallocated < 0
|
||||
? "text-error"
|
||||
: "text-text-bright"
|
||||
)}
|
||||
>
|
||||
{allocationModified ? (
|
||||
<>
|
||||
<span className="text-text-dimmed line-through">
|
||||
{extraUnallocatedConcurrency}
|
||||
</span>{" "}
|
||||
{extraUnallocatedConcurrency - changeInAllocation}
|
||||
</>
|
||||
) : (
|
||||
extraUnallocatedConcurrency
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
className={
|
||||
allocationModified || unallocated > 0 ? undefined : "after:bg-transparent"
|
||||
}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className={cn("py-0", (unallocated > 0 || allocationModified) && "pr-0")}
|
||||
>
|
||||
<div className="flex h-10 items-center">
|
||||
{allocationModified ? (
|
||||
unallocated < 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<ExclamationTriangleIcon className="size-4 text-error" />
|
||||
<span className="text-error">
|
||||
You're trying to allocate more concurrency than your total purchased
|
||||
amount.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
<span>
|
||||
Save your changes or{" "}
|
||||
<button
|
||||
className="inline text-indigo-500 hover:text-indigo-300"
|
||||
onClick={() => {
|
||||
setAllocation(initialAllocation(environments));
|
||||
}}
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
type="submit"
|
||||
form="allocate"
|
||||
disabled={unallocated < 0 || isLoading}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : unallocated > 0 ? (
|
||||
<div className="flex h-full w-full items-center justify-between bg-success/10 px-2.5">
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<InformationCircleIcon className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
You have {unallocated} extra concurrency available to allocate below.
|
||||
</span>
|
||||
</div>
|
||||
<ArrowDownIcon className="size-4 animate-bounce text-success" />
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<FormError id={formEnvironments.id}>{formEnvironments.errors}</FormError>
|
||||
</div>
|
||||
<Form className="flex flex-col gap-2" method="post" {...getFormProps(form)} id="allocate">
|
||||
<input type="hidden" name="action" value="allocate" />
|
||||
<div className="flex items-center pb-1">
|
||||
<Header3 className="grow">Concurrency allocation</Header3>
|
||||
</div>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Included{" "}
|
||||
<InfoIconTooltip content="This is the included concurrency based on your plan." />
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Extra concurrency</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Total</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment, index) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>
|
||||
<EnvironmentCombo
|
||||
environment={environment}
|
||||
className="max-w-[18ch]"
|
||||
tooltipSideOffset={6}
|
||||
tooltipSide="top"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<div className="flex items-center justify-end">
|
||||
{environment.type === "DEVELOPMENT" ? (
|
||||
Math.max(
|
||||
0,
|
||||
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`environments[${index}].id`}
|
||||
value={environment.id}
|
||||
/>
|
||||
<Input
|
||||
name={`environments[${index}].amount`}
|
||||
type="number"
|
||||
variant="outline/small"
|
||||
className="text-right"
|
||||
containerClassName="w-16"
|
||||
fullWidth={false}
|
||||
value={allocation.get(environment.id)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === "" ? 0 : Number(e.target.value);
|
||||
setAllocation(new Map(allocation).set(environment.id, value));
|
||||
}}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-b border-grid-dimmed pb-1">
|
||||
<Header2>Your concurrency</Header2>
|
||||
</div>
|
||||
{isManagedCloud ? (
|
||||
<>
|
||||
<Paragraph variant="small">
|
||||
Concurrency limits determine how many runs you can execute at the same time. You can
|
||||
upgrade your plan to get more concurrency. You are currently on the{" "}
|
||||
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
|
||||
</Paragraph>
|
||||
{showSelfServe ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade for more concurrency
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell className="pl-0">
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseConcurrencyModal({
|
||||
concurrencyPricing,
|
||||
extraConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
maxQuota,
|
||||
disabled,
|
||||
}: {
|
||||
concurrencyPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
extraConcurrency: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
maxQuota: number;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const lastSubmission = useActionData();
|
||||
const [form, fields] = useForm({
|
||||
id: "purchase-concurrency",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
const { amount } = fields;
|
||||
|
||||
const [amountValue, setAmountValue] = useState(extraConcurrency);
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
||||
|
||||
// Close the panel, when we've succeeded
|
||||
// This is required because a redirect to the same path doesn't clear state
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const success = searchParams.get("success");
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
setSearchParams((s) => {
|
||||
s.delete("success");
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}, [searchParams.get("success")]);
|
||||
|
||||
const state = updateState({
|
||||
value: amountValue,
|
||||
existingValue: extraConcurrency,
|
||||
quota: maxQuota,
|
||||
extraUnallocatedConcurrency,
|
||||
});
|
||||
const changeClassName =
|
||||
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
|
||||
|
||||
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
|
||||
|
||||
if (!showSelfServe) {
|
||||
return (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<Paragraph variant="base/bright" spacing>
|
||||
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
|
||||
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can
|
||||
remove any extra concurrency after you have unallocated it from your environments
|
||||
first.
|
||||
</Paragraph>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="amount" className="text-text-dimmed">
|
||||
Total extra concurrency
|
||||
</Label>
|
||||
<InputNumberStepper
|
||||
{...getInputProps(amount, { type: "number" })}
|
||||
step={concurrencyPricing.stepSize}
|
||||
min={0}
|
||||
max={undefined}
|
||||
value={amountValue}
|
||||
onChange={(e) => setAmountValue(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<FormError id={amount.errorId}>{amount.errors}</FormError>
|
||||
<FormError>{form.errors}</FormError>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
{state === "need_to_increase_unallocated" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
You need to unallocate{" "}
|
||||
{formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more
|
||||
concurrency from your environments in order to remove{" "}
|
||||
{formatNumber(extraConcurrency - amountValue)} concurrency from your account.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : state === "above_quota" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
Currently you can only have up to {maxQuota} extra concurrency. Send a request
|
||||
below to lift your current limit. We'll get back to you soon.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pb-3 tabular-nums">
|
||||
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
|
||||
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(extraConcurrency)}</span>{" "}
|
||||
current total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(
|
||||
(extraConcurrency * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({simplur`${extraConcurrency / concurrencyPricing.stepSize} bundle[|s]`})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatNumber(amountValue - extraConcurrency)}
|
||||
</Header3>
|
||||
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatCurrency(
|
||||
((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
(
|
||||
{simplur`${
|
||||
(amountValue - extraConcurrency) / concurrencyPricing.stepSize
|
||||
} bundle[|s]`}{" "}
|
||||
@ {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(
|
||||
(amountValue * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({simplur`${amountValue / concurrencyPricing.stepSize} bundle[|s]`})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
state === "above_quota" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="quota-increase" />
|
||||
<Button
|
||||
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{`Send request for ${formatNumber(amountValue)}`}
|
||||
</Button>
|
||||
</>
|
||||
) : state === "decrease" || state === "need_to_increase_unallocated" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "need_to_increase_unallocated"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
{`Remove ${formatNumber(extraConcurrency - amountValue)} concurrency`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
{`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function updateState({
|
||||
value,
|
||||
existingValue,
|
||||
quota,
|
||||
extraUnallocatedConcurrency,
|
||||
}: {
|
||||
value: number;
|
||||
existingValue: number;
|
||||
quota: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
|
||||
if (value === existingValue) return "no_change";
|
||||
if (value < existingValue) {
|
||||
const difference = existingValue - value;
|
||||
if (difference > extraUnallocatedConcurrency) {
|
||||
return "need_to_increase_unallocated";
|
||||
}
|
||||
return "decrease";
|
||||
}
|
||||
if (value > quota) return "above_quota";
|
||||
return "increase";
|
||||
}
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form } from "@remix-run/react";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { ModelsFilter, type ModelOption } from "~/components/metrics/ModelsFilter";
|
||||
import { OperationsFilter } from "~/components/metrics/OperationsFilter";
|
||||
import { PromptsFilter } from "~/components/metrics/PromptsFilter";
|
||||
import { ProvidersFilter } from "~/components/metrics/ProvidersFilter";
|
||||
import { type WidgetData } from "~/components/metrics/QueryWidget";
|
||||
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
|
||||
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
|
||||
import { TitleWidget } from "~/components/metrics/TitleWidget";
|
||||
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getTaskIdentifiers } from "~/models/task.server";
|
||||
import {
|
||||
type BuiltInDashboardFilter,
|
||||
type LayoutItem,
|
||||
type Widget,
|
||||
MetricDashboardPresenter,
|
||||
} from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { QueryScopeSchema } from "~/v3/querySchemas";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { MetricWidget } from "../resources.metric";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardKey: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam, dashboardKey } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new MetricDashboardPresenter();
|
||||
const [dashboard, possibleTasks] = await Promise.all([
|
||||
presenter.builtInDashboard({
|
||||
organizationId: project.organizationId,
|
||||
key: dashboardKey,
|
||||
}),
|
||||
getTaskIdentifiers(environment.id),
|
||||
]);
|
||||
|
||||
const filters = dashboard.filters ?? ["tasks", "queues"];
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
|
||||
// Load distinct models from ClickHouse if the dashboard has a models filter
|
||||
let possibleModels: { model: string; system: string }[] = [];
|
||||
if (filters.includes("models")) {
|
||||
const queryFn = clickhouse.reader.query({
|
||||
name: "getDistinctModels",
|
||||
query: `SELECT response_model, any(gen_ai_system) AS gen_ai_system FROM trigger_dev.llm_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} AND environment_id = {environmentId: String} AND response_model != '' GROUP BY response_model ORDER BY response_model`,
|
||||
params: z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
}),
|
||||
schema: z.object({ response_model: z.string(), gen_ai_system: z.string() }),
|
||||
});
|
||||
const [error, rows] = await queryFn({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
if (!error) {
|
||||
possibleModels = rows.map((r) => ({ model: r.response_model, system: r.gen_ai_system }));
|
||||
}
|
||||
}
|
||||
|
||||
const promptPresenter = new PromptPresenter(clickhouse);
|
||||
const [possiblePrompts, possibleOperations, possibleProviders] = await Promise.all([
|
||||
filters.includes("prompts")
|
||||
? promptPresenter.getDistinctPromptSlugs(project.organizationId, project.id, environment.id)
|
||||
: ([] as string[]),
|
||||
filters.includes("operations")
|
||||
? promptPresenter.getDistinctOperations(project.organizationId, project.id, environment.id)
|
||||
: ([] as string[]),
|
||||
filters.includes("providers")
|
||||
? promptPresenter.getDistinctProviders(project.organizationId, project.id, environment.id)
|
||||
: ([] as string[]),
|
||||
]);
|
||||
|
||||
return typedjson({
|
||||
...dashboard,
|
||||
filters,
|
||||
possibleTasks,
|
||||
possibleModels,
|
||||
possiblePrompts,
|
||||
possibleOperations,
|
||||
possibleProviders,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
key,
|
||||
title,
|
||||
layout: dashboardLayout,
|
||||
defaultPeriod,
|
||||
filters,
|
||||
possibleTasks,
|
||||
possibleModels,
|
||||
possiblePrompts,
|
||||
possibleOperations,
|
||||
possibleProviders,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={title} />
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="h-full">
|
||||
<MetricDashboard
|
||||
key={key}
|
||||
layout={dashboardLayout.layout}
|
||||
widgets={dashboardLayout.widgets}
|
||||
defaultPeriod={defaultPeriod}
|
||||
editable={false}
|
||||
filters={filters}
|
||||
possibleTasks={possibleTasks}
|
||||
possibleModels={possibleModels}
|
||||
possiblePrompts={possiblePrompts}
|
||||
possibleOperations={possibleOperations}
|
||||
possibleProviders={possibleProviders}
|
||||
filterAccessories={
|
||||
<CreateDashboardPageButton
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
shortcut={{ key: "n" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricDashboard({
|
||||
layout,
|
||||
widgets,
|
||||
defaultPeriod,
|
||||
editable,
|
||||
filters: filterConfig,
|
||||
possibleTasks,
|
||||
possibleModels,
|
||||
possiblePrompts,
|
||||
possibleOperations,
|
||||
possibleProviders,
|
||||
onLayoutChange,
|
||||
onEditWidget,
|
||||
onRenameWidget,
|
||||
onDeleteWidget,
|
||||
onDuplicateWidget,
|
||||
filterAccessories,
|
||||
}: {
|
||||
/** The layout items (positions/sizes) - fully controlled from parent */
|
||||
layout: LayoutItem[];
|
||||
/** The widget configurations keyed by widget ID - fully controlled from parent */
|
||||
widgets: Record<string, Widget>;
|
||||
defaultPeriod: string;
|
||||
editable: boolean;
|
||||
/** Which filters to show. Defaults to ["tasks", "queues"]. */
|
||||
filters?: BuiltInDashboardFilter[];
|
||||
/** Possible tasks for filtering */
|
||||
possibleTasks?: {
|
||||
slug: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
isInLatestDeployment: boolean;
|
||||
}[];
|
||||
/** Possible models for filtering */
|
||||
possibleModels?: ModelOption[];
|
||||
/** Possible prompt slugs for filtering */
|
||||
possiblePrompts?: string[];
|
||||
/** Possible operations for filtering */
|
||||
possibleOperations?: string[];
|
||||
/** Possible providers for filtering */
|
||||
possibleProviders?: string[];
|
||||
onLayoutChange?: (layout: LayoutItem[]) => void;
|
||||
onEditWidget?: (widgetId: string, widget: WidgetData) => void;
|
||||
onRenameWidget?: (widgetId: string, newTitle: string) => void;
|
||||
onDeleteWidget?: (widgetId: string) => void;
|
||||
onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void;
|
||||
filterAccessories?: ReactNode;
|
||||
}) {
|
||||
const { value, values } = useSearchParams();
|
||||
const { width, containerRef, mounted } = useContainerWidth();
|
||||
const [resizingItemId, setResizingItemId] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isInteracting = resizingItemId !== null || isDragging;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
const period = value("period");
|
||||
const from = value("from");
|
||||
const to = value("to");
|
||||
const parsedScope = QueryScopeSchema.safeParse(value("scope") ?? "environment");
|
||||
const scope = parsedScope.success ? parsedScope.data : "environment";
|
||||
const tasks = values("tasks").filter((v) => v !== "");
|
||||
const queues = values("queues").filter((v) => v !== "");
|
||||
const models = values("models").filter((v) => v !== "");
|
||||
const prompts = values("prompts").filter((v) => v !== "");
|
||||
const operations = values("operations").filter((v) => v !== "");
|
||||
const providers = values("providers").filter((v) => v !== "");
|
||||
|
||||
const activeFilters = filterConfig ?? ["tasks", "queues"];
|
||||
const hasAppliedFilters =
|
||||
tasks.length > 0 ||
|
||||
queues.length > 0 ||
|
||||
models.length > 0 ||
|
||||
prompts.length > 0 ||
|
||||
operations.length > 0 ||
|
||||
providers.length > 0;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(newLayout: readonly LayoutItem[]) => {
|
||||
const mutableLayout = [...newLayout];
|
||||
onLayoutChange?.(mutableLayout);
|
||||
},
|
||||
[onLayoutChange]
|
||||
);
|
||||
|
||||
// Apply constraints for title widgets: fixed height of 2, allow horizontal resize only
|
||||
const constrainedLayout = useMemo(
|
||||
() =>
|
||||
layout.map((item) => {
|
||||
const widget = widgets[item.i];
|
||||
if (widget?.display.type === "title") {
|
||||
return { ...item, h: 2, minH: 2, maxH: 2 };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
[layout, widgets]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-x-2 border-b border-b-grid-bright py-2 pl-2 pr-3">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<ScopeFilter shortcut={{ key: "s" }} />
|
||||
{activeFilters.includes("tasks") && (
|
||||
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
|
||||
)}
|
||||
{activeFilters.includes("queues") && <QueuesFilter />}
|
||||
{activeFilters.includes("models") && (
|
||||
<ModelsFilter possibleModels={possibleModels ?? []} />
|
||||
)}
|
||||
{activeFilters.includes("prompts") && (
|
||||
<PromptsFilter possiblePrompts={possiblePrompts ?? []} />
|
||||
)}
|
||||
{activeFilters.includes("operations") && (
|
||||
<OperationsFilter possibleOperations={possibleOperations ?? []} />
|
||||
)}
|
||||
{activeFilters.includes("providers") && (
|
||||
<ProvidersFilter possibleProviders={possibleProviders ?? []} />
|
||||
)}
|
||||
<TimeFilter
|
||||
defaultPeriod={defaultPeriod}
|
||||
labelName="Period"
|
||||
hideLabel
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
valueClassName="text-text-bright"
|
||||
shortcut={{ key: "d" }}
|
||||
/>
|
||||
{hasAppliedFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
{filterAccessories && <div className="flex shrink-0 items-center">{filterAccessories}</div>}
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"overflow-y-auto scrollbar-thin scrollbar-track-background-bright scrollbar-thumb-background-raised",
|
||||
isInteracting && "select-none"
|
||||
)}
|
||||
>
|
||||
{mounted && (
|
||||
<ReactGridLayout
|
||||
layout={constrainedLayout}
|
||||
width={width}
|
||||
gridConfig={{ cols: 12, rowHeight: 30 }}
|
||||
resizeConfig={{
|
||||
enabled: editable,
|
||||
handles: ["se"],
|
||||
}}
|
||||
dragConfig={{ enabled: editable, handle: ".drag-handle" }}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)}
|
||||
onResizeStop={() => setResizingItemId(null)}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragStop={() => setIsDragging(false)}
|
||||
>
|
||||
{Object.entries(widgets).map(([key, widget]) => (
|
||||
<div key={key}>
|
||||
{widget.display.type === "title" ? (
|
||||
<TitleWidget
|
||||
title={widget.title}
|
||||
isDraggable={editable}
|
||||
isResizing={resizingItemId === key}
|
||||
onRename={
|
||||
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
|
||||
}
|
||||
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<MetricWidget
|
||||
widgetKey={key}
|
||||
title={widget.title}
|
||||
query={widget.query}
|
||||
scope={scope}
|
||||
period={period ?? defaultPeriod}
|
||||
from={from ?? null}
|
||||
to={to ?? null}
|
||||
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
|
||||
queues={queues.length > 0 ? queues : undefined}
|
||||
responseModels={models.length > 0 ? models : undefined}
|
||||
promptSlugs={prompts.length > 0 ? prompts : undefined}
|
||||
operations={operations.length > 0 ? operations : undefined}
|
||||
providers={providers.length > 0 ? providers : undefined}
|
||||
config={widget.display}
|
||||
organizationId={organization.id}
|
||||
projectId={project.id}
|
||||
environmentId={environment.id}
|
||||
refreshIntervalMs={60_000}
|
||||
isResizing={resizingItemId === key}
|
||||
isDraggable={editable}
|
||||
onEdit={
|
||||
onEditWidget
|
||||
? (resultData) => onEditWidget(key, { ...widget, resultData })
|
||||
: undefined
|
||||
}
|
||||
onRename={
|
||||
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
|
||||
}
|
||||
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
|
||||
onDuplicate={
|
||||
onDuplicateWidget
|
||||
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ReactGridLayout>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useContainerWidth(initialWidth = 1280) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const measureWidth = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
setWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measureWidth();
|
||||
setMounted(true);
|
||||
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [measureWidth]);
|
||||
|
||||
return { width, containerRef, mounted };
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Dashboards | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) throw new Response("Project not found", { status: 404 });
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) throw new Response("Environment not found", { status: 404 });
|
||||
|
||||
return typedjson({ ok: true });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const runsPath = v3BuiltInDashboardPath(organization, project, environment, "overview");
|
||||
const agentsPath = v3BuiltInDashboardPath(organization, project, environment, "llm");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Dashboards" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<div className="mx-auto mt-8 flex w-full max-w-5xl flex-col gap-4 p-6">
|
||||
<div>
|
||||
<Header1 spacing className="text-text-bright">
|
||||
Select a dashboard
|
||||
</Header1>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Browse the built-in dashboards or create your own custom dashboard with your own
|
||||
charts and widgets.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DashboardCta
|
||||
to={runsPath}
|
||||
icon={<RunsIcon className="size-7 text-runs" />}
|
||||
title="Run metrics"
|
||||
description="Overview of runs, latency, throughput and queue health for this environment."
|
||||
/>
|
||||
<DashboardCta
|
||||
to={agentsPath}
|
||||
icon={<AIMetricsIcon className="size-7 text-aiMetrics" />}
|
||||
title="AI metrics"
|
||||
description="LLM-level metrics: cost, tokens, latency, and per-model usage for your AI agents."
|
||||
/>
|
||||
<CreateDashboardPageButton
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
>
|
||||
<DashboardCta
|
||||
icon={<PlusIcon className="size-7 text-text-bright" />}
|
||||
title="Create your own dashboard"
|
||||
description="Build a dashboard with custom widgets and queries."
|
||||
/>
|
||||
</CreateDashboardPageButton>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const CTA_CLASSNAME = cn(
|
||||
"group flex h-full min-h-40 flex-col gap-3 rounded-lg border border-grid-bright bg-background-bright p-5 text-left transition-colors",
|
||||
"hover:border-border-bright hover:bg-background-hover"
|
||||
);
|
||||
|
||||
type DashboardCtaProps = {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type DashboardCtaLinkProps = DashboardCtaProps & { to: string };
|
||||
type DashboardCtaButtonProps = DashboardCtaProps &
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & { to?: undefined };
|
||||
|
||||
const DashboardCta = forwardRef<HTMLElement, DashboardCtaLinkProps | DashboardCtaButtonProps>(
|
||||
function DashboardCta({ icon, title, description, ...rest }, ref) {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between">{icon}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header2 className="text-text-bright">{title}</Header2>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if ("to" in rest && rest.to) {
|
||||
return (
|
||||
<Link ref={ref as React.Ref<HTMLAnchorElement>} to={rest.to} className={CTA_CLASSNAME}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const { to: _to, ...buttonProps } = rest as DashboardCtaButtonProps;
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
className={CTA_CLASSNAME}
|
||||
{...buttonProps}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
+778
@@ -0,0 +1,778 @@
|
||||
import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { IconChartHistogram, IconEdit } from "@tabler/icons-react";
|
||||
import { Type } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getTaskIdentifiers } from "~/models/task.server";
|
||||
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
queryPath,
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const dashboardPresenter = new MetricDashboardPresenter();
|
||||
const queryPresenter = new QueryPresenter();
|
||||
|
||||
const [dashboard, { defaultQuery, history }, possibleTasks] = await Promise.all([
|
||||
dashboardPresenter.customDashboard({
|
||||
friendlyId: dashboardId,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
queryPresenter.call({
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
getTaskIdentifiers(environment.id),
|
||||
]);
|
||||
|
||||
// Admins and impersonating users can use EXPLAIN
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
// Compute widget count from dashboard layout
|
||||
const widgetCount = Object.keys(dashboard.layout.widgets).length;
|
||||
|
||||
return typedjson({
|
||||
...dashboard,
|
||||
// Query editor data
|
||||
queryDefaultQuery: defaultQuery,
|
||||
queryHistory: history,
|
||||
isAdmin,
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
possibleTasks,
|
||||
widgetCount,
|
||||
});
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Load the dashboard
|
||||
const dashboard = await prisma.metricsDashboard.findFirst({
|
||||
where: {
|
||||
friendlyId: dashboardId,
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new Response("Dashboard not found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action");
|
||||
|
||||
switch (action) {
|
||||
case "delete": {
|
||||
await prisma.metricsDashboard.delete({
|
||||
where: { id: dashboard.id },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3BuiltInDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
"overview"
|
||||
),
|
||||
request,
|
||||
`Deleted "${dashboard.title}" dashboard`
|
||||
);
|
||||
}
|
||||
case "rename": {
|
||||
const newTitle = formData.get("title");
|
||||
if (typeof newTitle !== "string" || newTitle.trim().length === 0) {
|
||||
throw new Response("Title is required", { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.metricsDashboard.update({
|
||||
where: { id: dashboard.id },
|
||||
data: { title: newTitle.trim() },
|
||||
});
|
||||
|
||||
return typedjson({ success: true });
|
||||
}
|
||||
default: {
|
||||
throw new Response("Invalid action", { status: 400 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
friendlyId,
|
||||
title,
|
||||
layout: dashboardLayout,
|
||||
defaultPeriod,
|
||||
queryDefaultQuery,
|
||||
queryHistory,
|
||||
isAdmin,
|
||||
maxRows,
|
||||
possibleTasks,
|
||||
widgetCount: _initialWidgetCount,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
// Widget limits
|
||||
const widgetLimitPerDashboard = useWidgetLimitPerDashboard();
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard;
|
||||
const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
|
||||
// Build the action URLs - both use the resource route to avoid full page renders on POST
|
||||
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
|
||||
const layoutActionUrl = widgetActionUrl;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const handleSyncError = useCallback(
|
||||
(error: Error, action: string) => {
|
||||
const actionMessages: Record<string, string> = {
|
||||
add: "Failed to add widget",
|
||||
update: "Failed to update widget",
|
||||
delete: "Failed to delete widget",
|
||||
duplicate: "Failed to duplicate widget",
|
||||
layout: "Failed to save layout",
|
||||
};
|
||||
|
||||
const message = actionMessages[action] || "Failed to save changes";
|
||||
|
||||
toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" });
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// Add title dialog state
|
||||
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
|
||||
const [newTitleValue, setNewTitleValue] = useState("");
|
||||
|
||||
// Widget limit dialog state (triggered when hook blocks add/duplicate)
|
||||
const [showWidgetLimitDialog, setShowWidgetLimitDialog] = useState(false);
|
||||
|
||||
const handleWidgetLimitReached = useCallback(() => {
|
||||
setShowWidgetLimitDialog(true);
|
||||
}, []);
|
||||
|
||||
// Use the dashboard editor hook for all state management
|
||||
const { state, actions } = useDashboardEditor({
|
||||
initialData: dashboardLayout,
|
||||
widgetActionUrl,
|
||||
layoutActionUrl,
|
||||
widgetLimit: canExceedWidgets ? undefined : widgetLimitPerDashboard,
|
||||
onSyncError: handleSyncError,
|
||||
onWidgetLimitReached: handleWidgetLimitReached,
|
||||
});
|
||||
|
||||
// Reactive widget count from editor state (title widgets don't count against limits)
|
||||
const currentWidgetCount = Object.values(state.widgets).filter(
|
||||
(w) => w.display.type !== "title"
|
||||
).length;
|
||||
const totalWidgetCount = Object.keys(state.widgets).length;
|
||||
const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard };
|
||||
const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard;
|
||||
const widgetLimitRatio =
|
||||
widgetLimits.limit > 0 ? widgetLimits.used / widgetLimits.limit : widgetLimits.used > 0 ? 1 : 0;
|
||||
const widgetLimitPercent = Math.min(100, Math.max(0, Math.round(widgetLimitRatio * 100)));
|
||||
const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets;
|
||||
|
||||
// Build the query action URL for the editor
|
||||
const queryActionUrl = queryPath(
|
||||
{ slug: organization.slug },
|
||||
{ slug: project.slug },
|
||||
{ slug: environment.slug }
|
||||
);
|
||||
|
||||
// Handle save from the QueryEditor
|
||||
const handleSave = useCallback(
|
||||
(data: QueryEditorSaveData) => {
|
||||
if (state.editorMode?.type === "add") {
|
||||
actions.addWidget(data.title, data.query, data.config);
|
||||
} else if (state.editorMode?.type === "edit") {
|
||||
actions.updateWidget(state.editorMode.widgetId, data.title, data.query, data.config);
|
||||
}
|
||||
},
|
||||
[state.editorMode, actions]
|
||||
);
|
||||
|
||||
// Render save button for the QueryEditor
|
||||
const renderSaveForm = useCallback(
|
||||
(data: QueryEditorSaveData) => {
|
||||
const isAdd = state.editorMode?.type === "add";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary/small"
|
||||
disabled={!data.query}
|
||||
onClick={() => handleSave(data)}
|
||||
>
|
||||
{isAdd ? "Add to dashboard" : "Save changes"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[state.editorMode, handleSave]
|
||||
);
|
||||
|
||||
// Prepare editor props when in editor mode
|
||||
const editorProps = state.editorMode
|
||||
? (() => {
|
||||
const mode =
|
||||
state.editorMode.type === "add"
|
||||
? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title }
|
||||
: {
|
||||
type: "dashboard-edit" as const,
|
||||
dashboardId: friendlyId,
|
||||
dashboardName: title,
|
||||
widgetId: state.editorMode.widgetId,
|
||||
widgetName: state.editorMode.widget.title,
|
||||
};
|
||||
|
||||
// For edit mode, use the widget's existing values as defaults
|
||||
const editorDefaultQuery =
|
||||
state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery;
|
||||
const editorDefaultChartConfig =
|
||||
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart"
|
||||
? {
|
||||
chartType: state.editorMode.widget.display.chartType,
|
||||
xAxisColumn: state.editorMode.widget.display.xAxisColumn,
|
||||
yAxisColumns: state.editorMode.widget.display.yAxisColumns,
|
||||
groupByColumn: state.editorMode.widget.display.groupByColumn,
|
||||
stacked: state.editorMode.widget.display.stacked,
|
||||
sortByColumn: state.editorMode.widget.display.sortByColumn,
|
||||
sortDirection: state.editorMode.widget.display.sortDirection,
|
||||
aggregation: state.editorMode.widget.display.aggregation,
|
||||
seriesColors: state.editorMode.widget.display.seriesColors,
|
||||
}
|
||||
: defaultChartConfig;
|
||||
const editorDefaultBigNumberConfig =
|
||||
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "bignumber"
|
||||
? {
|
||||
column: state.editorMode.widget.display.column,
|
||||
aggregation: state.editorMode.widget.display.aggregation,
|
||||
sortDirection: state.editorMode.widget.display.sortDirection,
|
||||
abbreviate: state.editorMode.widget.display.abbreviate,
|
||||
prefix: state.editorMode.widget.display.prefix,
|
||||
suffix: state.editorMode.widget.display.suffix,
|
||||
}
|
||||
: undefined;
|
||||
const editorDefaultResultsView =
|
||||
state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table";
|
||||
// Pass the existing result data when editing
|
||||
const editorDefaultData =
|
||||
state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined;
|
||||
|
||||
return {
|
||||
mode,
|
||||
editorDefaultQuery,
|
||||
editorDefaultChartConfig,
|
||||
editorDefaultBigNumberConfig,
|
||||
editorDefaultResultsView,
|
||||
editorDefaultData,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const dashboardMenu = (
|
||||
<Popover>
|
||||
<PopoverVerticalEllipseTrigger variant="secondary" />
|
||||
<PopoverContent className="w-fit min-w-40 p-1" align="end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<RenameDashboardDialog title={title} />
|
||||
<DeleteDashboardDialog title={title} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const filterAccessories = (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{widgetIsAtLimit ? (
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small" LeadingIcon={PlusIcon} shortcut={{ key: "c" }}>
|
||||
Add chart
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>You've exceeded your widget limit</DialogHeader>
|
||||
<DialogDescription>
|
||||
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
{widgetCanUpgrade ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="primary/small">Request more</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={Type}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-x-1"
|
||||
tooltip="Add section title"
|
||||
shortcut={{ key: "h" }}
|
||||
onClick={() => {
|
||||
setNewTitleValue("");
|
||||
setShowAddTitleDialog(true);
|
||||
}}
|
||||
>
|
||||
Add title
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={IconChartHistogram}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
shortcut={{ key: "c" }}
|
||||
onClick={actions.openAddEditor}
|
||||
>
|
||||
Add chart
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={Type}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-x-1"
|
||||
tooltip="Add section title"
|
||||
shortcut={{ key: "h" }}
|
||||
onClick={() => {
|
||||
setNewTitleValue("");
|
||||
setShowAddTitleDialog(true);
|
||||
}}
|
||||
>
|
||||
Add title
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{dashboardMenu}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={title} />
|
||||
<PageAccessories>{totalWidgetCount === 0 && dashboardMenu}</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
{totalWidgetCount === 0 ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
icon={IconChartHistogram}
|
||||
iconClassName="text-metrics"
|
||||
panelClassName="max-full"
|
||||
title="Add your first chart"
|
||||
accessory={
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
onClick={actions.openAddEditor}
|
||||
>
|
||||
Add chart
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
Charts let you visualize your task metrics. Write a query to pull data from your
|
||||
runs, then choose how to display it on this dashboard.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MetricDashboard
|
||||
key={friendlyId}
|
||||
layout={state.layout}
|
||||
widgets={state.widgets}
|
||||
defaultPeriod={defaultPeriod}
|
||||
editable={true}
|
||||
possibleTasks={possibleTasks}
|
||||
onLayoutChange={actions.updateLayout}
|
||||
onEditWidget={actions.openEditEditor}
|
||||
onRenameWidget={actions.renameWidget}
|
||||
onDeleteWidget={actions.deleteWidget}
|
||||
onDuplicateWidget={actions.duplicateWidget}
|
||||
filterAccessories={filterAccessories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
widgetIsAtLimit ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${widgetLimitRatio * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${widgetLimitPercent}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{widgetIsAtLimit ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {widgetLimits.limit} of your available widgets. Upgrade your
|
||||
plan to enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {widgetLimits.used}/{widgetLimits.limit} of your charts
|
||||
</Header3>
|
||||
)}
|
||||
{widgetCanUpgrade ? (
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request more…</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
{/* Query Editor Sheet - opens on top of the dashboard */}
|
||||
<Sheet open={!!state.editorMode} onOpenChange={(open) => !open && actions.closeEditor()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[90vw] max-w-none rounded-l-lg border-l border-grid-dimmed p-0 sm:max-w-none"
|
||||
>
|
||||
{editorProps && (
|
||||
<QueryEditor
|
||||
defaultQuery={editorProps.editorDefaultQuery}
|
||||
defaultScope="environment"
|
||||
defaultPeriod={defaultPeriod}
|
||||
defaultResultsView={
|
||||
editorProps.editorDefaultResultsView === "chart"
|
||||
? "graph"
|
||||
: editorProps.editorDefaultResultsView === "bignumber"
|
||||
? "bignumber"
|
||||
: "table"
|
||||
}
|
||||
defaultChartConfig={editorProps.editorDefaultChartConfig}
|
||||
defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig}
|
||||
defaultData={editorProps.editorDefaultData}
|
||||
history={queryHistory}
|
||||
isAdmin={isAdmin}
|
||||
maxRows={maxRows}
|
||||
queryActionUrl={queryActionUrl}
|
||||
mode={editorProps.mode}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
save={renderSaveForm}
|
||||
onClose={actions.closeEditor}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Add title dialog */}
|
||||
<Dialog open={showAddTitleDialog} onOpenChange={setShowAddTitleDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Add title</DialogHeader>
|
||||
<form
|
||||
className="space-y-4 pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newTitleValue.trim()) {
|
||||
actions.addTitleWidget(newTitleValue.trim());
|
||||
setShowAddTitleDialog(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={newTitleValue}
|
||||
onChange={(e) => setNewTitleValue(e.target.value)}
|
||||
placeholder="Section title"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary/medium" disabled={!newTitleValue.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */}
|
||||
<Dialog open={showWidgetLimitDialog} onOpenChange={setShowWidgetLimitDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>You've exceeded your widget limit</DialogHeader>
|
||||
<DialogDescription>
|
||||
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
{widgetCanUpgrade ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="primary/small">Request more</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDashboardDialog({ title }: { title: string }) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState(title);
|
||||
|
||||
const isRenaming =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "rename";
|
||||
|
||||
// Close dialog when navigation completes
|
||||
useEffect(() => {
|
||||
if (navigation.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.state]);
|
||||
|
||||
// Sync newTitle state when title changes (after successful rename)
|
||||
useEffect(() => {
|
||||
setNewTitle(title);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={IconEdit}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="pl-0.5 pr-3"
|
||||
leadingIconClassName="gap-x-0"
|
||||
>
|
||||
Rename dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Rename dashboard</DialogHeader>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
name="title"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Dashboard title"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={isRenaming || !newTitle.trim()}
|
||||
>
|
||||
{isRenaming ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteDashboardDialog({ title }: { title: string }) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const isDeleting =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
// Close dialog when navigation completes
|
||||
useEffect(() => {
|
||||
if (navigation.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.state]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Delete dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Delete dashboard</DialogHeader>
|
||||
<div className="mb-2 mt-4 flex flex-col gap-2">
|
||||
<Paragraph>
|
||||
Are you sure you want to delete <strong>"{title}"</strong>? This action cannot be undone
|
||||
and all widgets on this dashboard will be permanently removed.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+753
@@ -0,0 +1,753 @@
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { S2, S2Error } from "@s2-dev/streamstore";
|
||||
import {
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
TerminalSquareIcon,
|
||||
LayoutDashboardIcon,
|
||||
GitBranchIcon,
|
||||
ServerIcon,
|
||||
} from "lucide-react";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
|
||||
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { capitalizeWord } from "~/utils/string";
|
||||
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
|
||||
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam, deploymentParam } =
|
||||
v3DeploymentParams.parse(params);
|
||||
|
||||
try {
|
||||
const presenter = new DeploymentPresenter();
|
||||
const { deployment, eventStream } = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
deploymentShortCode: deploymentParam,
|
||||
});
|
||||
|
||||
return typedjson({ deployment, eventStream });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
level: "info" | "error" | "warn" | "debug";
|
||||
};
|
||||
|
||||
function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
} | null {
|
||||
if (!triggeredVia) return null;
|
||||
|
||||
const iconClass = "size-4 text-text-dimmed";
|
||||
|
||||
switch (triggeredVia) {
|
||||
case "cli:manual":
|
||||
return {
|
||||
icon: <TerminalSquareIcon className={iconClass} />,
|
||||
label: "CLI (Manual)",
|
||||
};
|
||||
case "cli:github_actions":
|
||||
return {
|
||||
icon: <GitBranchIcon className={iconClass} />,
|
||||
label: "CLI (GitHub Actions)",
|
||||
};
|
||||
case "cli:gitlab_ci":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (GitLab CI)",
|
||||
};
|
||||
case "cli:circleci":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (CircleCI)",
|
||||
};
|
||||
case "cli:jenkins":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Jenkins)",
|
||||
};
|
||||
case "cli:azure_pipelines":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Azure Pipelines)",
|
||||
};
|
||||
case "cli:bitbucket_pipelines":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Bitbucket Pipelines)",
|
||||
};
|
||||
case "cli:travis_ci":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Travis CI)",
|
||||
};
|
||||
case "cli:buildkite":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Buildkite)",
|
||||
};
|
||||
case "cli:ci_other":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (CI)",
|
||||
};
|
||||
case "git_integration:github":
|
||||
return {
|
||||
icon: <GitBranchIcon className={iconClass} />,
|
||||
label: "GitHub Integration",
|
||||
};
|
||||
case "dashboard":
|
||||
return {
|
||||
icon: <LayoutDashboardIcon className={iconClass} />,
|
||||
label: "Dashboard",
|
||||
};
|
||||
default:
|
||||
// Handle any unknown values gracefully
|
||||
if (triggeredVia.startsWith("cli:")) {
|
||||
return {
|
||||
icon: <TerminalSquareIcon className={iconClass} />,
|
||||
label: `CLI (${triggeredVia.replace("cli:", "")})`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: triggeredVia,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
const page = new URLSearchParams(location.search).get("page");
|
||||
|
||||
const logsDisabled = eventStream === undefined;
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const isPending = deployment.status === "PENDING";
|
||||
|
||||
useEffect(() => {
|
||||
if (logsDisabled) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setLogs([]);
|
||||
setStreamError(null);
|
||||
setIsStreaming(true);
|
||||
|
||||
const streamLogs = async () => {
|
||||
try {
|
||||
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
|
||||
const basin = s2.basin(eventStream.s2.basin);
|
||||
const stream = basin.stream(eventStream.s2.stream);
|
||||
|
||||
const readSession = await stream.readSession(
|
||||
{
|
||||
start: { from: { seqNum: 0 }, clamp: true },
|
||||
stop: { waitSecs: 60 },
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
for await (const record of readSession) {
|
||||
const decoded = record.body;
|
||||
const result = DeploymentEventFromString.safeParse(decoded);
|
||||
|
||||
if (!result.success) {
|
||||
// fallback to the previous format in s2 logs for compatibility
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (record.headers) {
|
||||
for (const [name, value] of record.headers) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: decoded,
|
||||
level,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log record:", err);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = result.data;
|
||||
if (event.type !== "log") {
|
||||
continue;
|
||||
}
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: event.data.message,
|
||||
level: event.data.level,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const isNotFoundError =
|
||||
error instanceof S2Error &&
|
||||
error.code &&
|
||||
["permission_denied", "stream_not_found"].includes(error.code);
|
||||
if (isNotFoundError) return;
|
||||
|
||||
console.error("Failed to stream logs:", error);
|
||||
setStreamError("Failed to stream logs");
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
streamLogs();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>Deploy: {deployment.shortCode}</Header2>
|
||||
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>{deployment.id}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Project ID</Property.Label>
|
||||
<Property.Value>{deployment.projectId}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Org ID</Property.Label>
|
||||
<Property.Value>{deployment.organizationId}</Property.Value>
|
||||
</Property.Item>
|
||||
{deployment.imageReference && (
|
||||
<Property.Item>
|
||||
<Property.Label>Image</Property.Label>
|
||||
<Property.Value>{deployment.imageReference}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Platform</Property.Label>
|
||||
<Property.Value>{deployment.imagePlatform}</Property.Value>
|
||||
</Property.Item>
|
||||
{deployment.externalBuildData && (
|
||||
<Property.Item>
|
||||
<Property.Label>Build Server</Property.Label>
|
||||
<Property.Value>
|
||||
<Link
|
||||
to={`/resources/${deployment.projectId}/deployments/${deployment.id}/logs`}
|
||||
className="extra-small/bright/mono underline"
|
||||
>
|
||||
{deployment.externalBuildData.buildId}
|
||||
</Link>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
to={`${v3DeploymentsPath(organization, project, environment)}${
|
||||
page ? `?page=${page}` : ""
|
||||
}`}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="flex flex-col">
|
||||
<div className="p-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Deploy</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{deployment.shortCode}</span>
|
||||
{deployment.label && (
|
||||
<Badge variant="extra-small" className="capitalize">
|
||||
{deployment.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentCombo environment={deployment.environment} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>{deployment.version}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<DeploymentStatus
|
||||
status={deployment.status}
|
||||
isBuilt={deployment.isBuilt}
|
||||
className="text-sm"
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{!logsDisabled && (
|
||||
<Property.Item>
|
||||
<Property.Label>Logs</Property.Label>
|
||||
<LogsDisplay
|
||||
logs={logs}
|
||||
isStreaming={isStreaming}
|
||||
streamError={streamError}
|
||||
initialCollapsed={(
|
||||
["PENDING", "DEPLOYED", "TIMED_OUT"] satisfies (typeof deployment.status)[]
|
||||
).includes(deployment.status)}
|
||||
/>
|
||||
</Property.Item>
|
||||
)}
|
||||
{deployment.canceledAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Canceled at</Property.Label>
|
||||
<Property.Value>
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.canceledAt} /> UTC
|
||||
</>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{deployment.canceledReason && (
|
||||
<Property.Item>
|
||||
<Property.Label>Cancelation reason</Property.Label>
|
||||
<Property.Value>{deployment.canceledReason}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Tasks</Property.Label>
|
||||
<Property.Value>{deployment.tasks ? deployment.tasks.length : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>SDK Version</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.sdkVersion ? deployment.sdkVersion : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>CLI Version</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.cliVersion ? deployment.cliVersion : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Runtime</Property.Label>
|
||||
<Property.Value>
|
||||
<RuntimeIcon
|
||||
runtime={deployment.runtime}
|
||||
runtimeVersion={deployment.runtimeVersion}
|
||||
withLabel
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Worker type</Property.Label>
|
||||
<Property.Value>{capitalizeWord(deployment.type)}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Started at</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.startedAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.startedAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Installed at</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.installedAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.installedAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Built at</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.builtAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.builtAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Deployed at</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.deployedAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.deployedAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Git</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="-ml-1 mt-0.5 flex flex-col">
|
||||
<GitMetadata git={deployment.git} />
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Deployed by</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.git?.source === "trigger_github_app" ? (
|
||||
<UserTag
|
||||
name={deployment.git.ghUsername ?? "GitHub Integration"}
|
||||
avatarUrl={deployment.git.ghUserAvatarUrl}
|
||||
/>
|
||||
) : deployment.deployedBy ? (
|
||||
<UserTag
|
||||
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
|
||||
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Triggered via</Property.Label>
|
||||
<Property.Value>
|
||||
{(() => {
|
||||
const display = getTriggeredViaDisplay(deployment.triggeredVia);
|
||||
if (!display) return "–";
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{display.icon}
|
||||
{display.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{deployment.vercelDeploymentUrl && (
|
||||
<Property.Item>
|
||||
<Property.Label>Linked</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="-ml-1 mt-0.5 flex flex-col">
|
||||
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{deployment.errorData && <DeploymentError errorData={deployment.errorData} />}
|
||||
|
||||
{deployment.tasks && (
|
||||
<div className="divide-y divide-background-bright overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Table variant="bright">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="px-2">Task</TableHeaderCell>
|
||||
<TableHeaderCell className="px-2">File path</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deployment.tasks.map((t) => {
|
||||
const path = v3RunsPath(organization, project, environment, {
|
||||
tasks: [t.slug],
|
||||
});
|
||||
return (
|
||||
<TableRow key={t.slug}>
|
||||
<TableCell to={path}>
|
||||
<div className="inline-flex flex-col gap-0.5">
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{t.slug}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>{t.filePath}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsDisplay({
|
||||
logs,
|
||||
isStreaming,
|
||||
streamError,
|
||||
initialCollapsed = false,
|
||||
}: {
|
||||
logs: LogEntry[];
|
||||
isStreaming: boolean;
|
||||
streamError: string | null;
|
||||
initialCollapsed?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(initialCollapsed);
|
||||
}, [initialCollapsed]);
|
||||
|
||||
// auto-scroll log container to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const onCopyLogs = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const logsText = logs.map((log) => log.message).join("\n");
|
||||
navigator.clipboard.writeText(logsText);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[logs]
|
||||
);
|
||||
|
||||
const errorCount = logs.filter((log) => log.level === "error").length;
|
||||
const warningCount = logs.filter((log) => log.level === "warn").length;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 overflow-hidden rounded-md border border-grid-bright">
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-3 py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
errorCount > 0 ? "bg-error/80" : "bg-surface-control"
|
||||
)}
|
||||
/>
|
||||
<Paragraph variant="extra-small/dimmed/mono" className="w-[ch-10]">
|
||||
{`${errorCount} ${errorCount === 1 ? "error" : "errors"}`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
warningCount > 0 ? "bg-warning/80" : "bg-surface-control"
|
||||
)}
|
||||
/>
|
||||
<Paragraph variant="extra-small/dimmed/mono">
|
||||
{`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
{logs.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || mouseOver} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={onCopyLogs}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="size-4 shrink-0">
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-full" />
|
||||
) : (
|
||||
<Clipboard className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
"text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronUp className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{collapsed ? "Expand" : "Collapse"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className={cn(
|
||||
"grow overflow-x-auto overflow-y-scroll font-mono text-xs transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
collapsed ? "h-16" : "h-64"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-fit min-w-full flex-col">
|
||||
{logs.length === 0 && (
|
||||
<div className="flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1">
|
||||
{streamError ? (
|
||||
<span className="text-error">Failed fetching logs</span>
|
||||
) : (
|
||||
<span className="text-text-dimmed">
|
||||
{isStreaming ? "Waiting for logs..." : "No logs yet"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{logs.map((log, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full gap-x-2.5 border-l-2 px-2.5 py-1",
|
||||
log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25",
|
||||
log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30",
|
||||
log.level === "info" && "border-transparent hover:bg-background-hover"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"select-none whitespace-nowrap py-px",
|
||||
log.level === "error" && "text-error/80",
|
||||
log.level === "warn" && "text-warning/70",
|
||||
log.level === "info" && "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<DateTimeAccurate date={log.timestamp} hideDate hour12={false} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
log.level === "error" && "text-error",
|
||||
log.level === "warn" && "text-warning",
|
||||
log.level === "info" && "text-text-bright"
|
||||
)}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-linear-to-t from-background-bright/90 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+732
@@ -0,0 +1,732 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
BookOpenIcon,
|
||||
NoSymbolIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useParams,
|
||||
} from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { CogIcon, GitBranchIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
collapsibleHandleClassName,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatusDescription,
|
||||
deploymentStatuses,
|
||||
} from "~/components/runs/v3/DeploymentStatus";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import {
|
||||
type DeploymentListItem,
|
||||
DeploymentListPresenter,
|
||||
} from "~/presenters/v3/DeploymentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
||||
import { titleCase } from "~/utils";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3DeploymentPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { env } from "~/env.server";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Deployments | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const SearchParams = z.object({
|
||||
page: z.coerce.number().optional(),
|
||||
version: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const searchParams = createSearchParams(request.url, SearchParams);
|
||||
|
||||
let page = searchParams.success ? Number(searchParams.params.get("page") ?? 1) : 1;
|
||||
const version = searchParams.success ? searchParams.params.get("version")?.toString() : undefined;
|
||||
|
||||
const presenter = new DeploymentListPresenter();
|
||||
|
||||
// If we have a version, find its page
|
||||
if (version) {
|
||||
try {
|
||||
page = await presenter.findPageForVersion({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error finding page for version", error);
|
||||
// Carry on, we'll just show the selected page
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
page,
|
||||
});
|
||||
|
||||
// If we have a version, find the deployment
|
||||
const selectedDeployment = version
|
||||
? result.deployments.find((d) => d.version === version)
|
||||
: undefined;
|
||||
|
||||
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
// Display flag for the rollback/promote/cancel controls — the action
|
||||
// routes enforce write:deployments independently. Permissive in OSS.
|
||||
const orgId = await resolveOrgIdFromSlug(organizationSlug);
|
||||
const deploymentAuth = orgId
|
||||
? await rbac.authenticateSession(request, { userId, organizationId: orgId })
|
||||
: null;
|
||||
const canWriteDeployments =
|
||||
deploymentAuth && deploymentAuth.ok
|
||||
? checkPermissions(deploymentAuth.ability, {
|
||||
canWriteDeployments: { action: "write", resource: { type: "deployments" } },
|
||||
}).canWriteDeployments
|
||||
: true;
|
||||
|
||||
return typedjson({
|
||||
...result,
|
||||
selectedDeployment,
|
||||
autoReloadPollIntervalMs,
|
||||
canWriteDeployments,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const {
|
||||
deployments,
|
||||
currentPage,
|
||||
totalPages,
|
||||
selectedDeployment,
|
||||
connectedGithubRepository,
|
||||
environmentGitHubBranch,
|
||||
autoReloadPollIntervalMs,
|
||||
hasVercelIntegration,
|
||||
canWriteDeployments,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const hasDeployments = totalPages > 0;
|
||||
|
||||
const { deploymentParam } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
// If we have a selected deployment from the version param, show it
|
||||
useEffect(() => {
|
||||
if (selectedDeployment && !deploymentParam) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.delete("version");
|
||||
searchParams.set("page", currentPage.toString());
|
||||
navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`);
|
||||
}
|
||||
}, [selectedDeployment, deploymentParam, location.search]);
|
||||
|
||||
const currentDeployment = deployments.find((d) => d.isCurrent);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Deployments" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/cli-deploy")}
|
||||
>
|
||||
Deployments docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
|
||||
{hasDeployments ? (
|
||||
<div className="flex h-full max-h-full flex-col">
|
||||
<Table containerClassName="border-t-0 grow">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{deploymentStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<DeploymentStatus status={status} isBuilt={false} />
|
||||
</div>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="text-wrap! text-text-dimmed"
|
||||
>
|
||||
{deploymentStatusDescription(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Runtime</TableHeaderCell>
|
||||
<TableHeaderCell>Tasks</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed at</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed by</TableHeaderCell>
|
||||
<TableHeaderCell>Git</TableHeaderCell>
|
||||
{hasVercelIntegration && <TableHeaderCell>Linked</TableHeaderCell>}
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deployments.length > 0 ? (
|
||||
deployments.map((deployment) => {
|
||||
const path = v3DeploymentPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
deployment,
|
||||
currentPage
|
||||
);
|
||||
const isSelected = deploymentParam === deployment.shortCode;
|
||||
return (
|
||||
<TableRow key={deployment.id} className="group" isSelected={isSelected}>
|
||||
<TableCell to={path} isTabbableCell isSelected={isSelected}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="group-hover/table-row:text-text-bright"
|
||||
>
|
||||
{deployment.shortCode}
|
||||
</Paragraph>
|
||||
{deployment.label && (
|
||||
<Badge variant="extra-small">{titleCase(deployment.label)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.version}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<DeploymentStatus
|
||||
status={deployment.status}
|
||||
isBuilt={deployment.isBuilt}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<RuntimeIcon
|
||||
runtime={deployment.runtime}
|
||||
runtimeVersion={deployment.runtimeVersion}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.tasksCount !== null ? deployment.tasksCount : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.deployedAt ? (
|
||||
<DateTime date={deployment.deployedAt} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.git?.source === "trigger_github_app" ? (
|
||||
<UserTag
|
||||
name={deployment.git.ghUsername ?? "GitHub Integration"}
|
||||
avatarUrl={deployment.git.ghUserAvatarUrl}
|
||||
/>
|
||||
) : deployment.deployedBy ? (
|
||||
<UserTag
|
||||
name={
|
||||
deployment.deployedBy.name ??
|
||||
deployment.deployedBy.displayName ??
|
||||
""
|
||||
}
|
||||
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell isSelected={isSelected}>
|
||||
<div className="-ml-1 flex items-center">
|
||||
<GitMetadata git={deployment.git} />
|
||||
</div>
|
||||
</TableCell>
|
||||
{hasVercelIntegration && (
|
||||
<TableCell isSelected={isSelected}>
|
||||
{deployment.vercelDeploymentUrl ? (
|
||||
<div
|
||||
className="-ml-1 flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VercelLink
|
||||
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<DeploymentActionsCell
|
||||
deployment={deployment}
|
||||
path={path}
|
||||
isSelected={isSelected}
|
||||
currentDeployment={currentDeployment}
|
||||
canWriteDeployments={canWriteDeployments}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={hasVercelIntegration ? 9 : 8}>
|
||||
<Paragraph className="flex items-center justify-center">
|
||||
No deploys match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div
|
||||
className={cn(
|
||||
"-mt-px flex flex-wrap justify-end gap-2 border-t border-grid-dimmed px-3 pb-[7px] pt-[6px]",
|
||||
connectedGithubRepository && environmentGitHubBranch && "justify-between"
|
||||
)}
|
||||
>
|
||||
{connectedGithubRepository && environmentGitHubBranch && (
|
||||
<div className="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm">
|
||||
<OctoKitty className="size-4" />
|
||||
Automatically triggered by pushes to{" "}
|
||||
<div className="flex max-w-32 items-center gap-1 truncate rounded bg-grid-dimmed px-1 font-mono">
|
||||
<GitBranchIcon className="size-3 shrink-0" />
|
||||
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
|
||||
</div>{" "}
|
||||
in
|
||||
<a
|
||||
href={connectedGithubRepository.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
|
||||
>
|
||||
{connectedGithubRepository.repository.fullName}
|
||||
</a>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={CogIcon}
|
||||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle
|
||||
id="deployments-handle"
|
||||
className={collapsibleHandleClassName(!!deploymentParam)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="deployments-inspector"
|
||||
default="400px"
|
||||
min="400px"
|
||||
max="800px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!deploymentParam}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 400 }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
className="h-4 w-4 group-hover/table-row:text-text-bright"
|
||||
/>
|
||||
<Paragraph variant="extra-small" className="group-hover/table-row:text-text-bright">
|
||||
{name}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentActionsCell({
|
||||
deployment,
|
||||
path,
|
||||
isSelected,
|
||||
currentDeployment,
|
||||
canWriteDeployments,
|
||||
}: {
|
||||
deployment: DeploymentListItem;
|
||||
path: string;
|
||||
isSelected: boolean;
|
||||
currentDeployment?: DeploymentListItem;
|
||||
canWriteDeployments: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const project = useProject();
|
||||
|
||||
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
|
||||
const canBeRolledBack =
|
||||
canBeMadeCurrent &&
|
||||
currentDeployment?.version &&
|
||||
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
|
||||
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
|
||||
|
||||
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
|
||||
const canBeCanceled = !finalStatuses.includes(deployment.status);
|
||||
|
||||
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
|
||||
return (
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{""}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
isSelected={isSelected}
|
||||
popoverContent={
|
||||
<>
|
||||
{canBeRolledBack &&
|
||||
(canWriteDeployments ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowUturnLeftIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RollbackDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowUturnLeftIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
disabled
|
||||
tooltip="You don't have permission to roll back deployments"
|
||||
>
|
||||
Rollback
|
||||
</Button>
|
||||
))}
|
||||
{canBePromoted &&
|
||||
(canWriteDeployments ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={PromoteIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Promote
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<PromoteDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={PromoteIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
disabled
|
||||
tooltip="You don't have permission to promote deployments"
|
||||
>
|
||||
Promote
|
||||
</Button>
|
||||
))}
|
||||
{canBeCanceled &&
|
||||
(canWriteDeployments ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CancelDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
disabled
|
||||
tooltip="You don't have permission to cancel deployments"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type RollbackDeploymentDialogProps = {
|
||||
projectId: string;
|
||||
deploymentShortCode: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
function RollbackDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="promote">
|
||||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs not explicitly tied to a
|
||||
specific deployment.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="cancel">
|
||||
<DialogHeader>Cancel this deployment?</DialogHeader>
|
||||
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Back</Button>
|
||||
</DialogClose>
|
||||
<Form action={formAction} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="danger/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : "Cancel deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
import { CheckIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useCallback } from "react";
|
||||
import { SearchInput } from "~/components/primitives/SearchInput";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { V4Title } from "~/components/V4Badge";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
|
||||
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { branchesDevPath, docsPath, ProjectParamSchema } from "~/utils/pathBuilder";
|
||||
import { ArchiveButton } from "../resources.branches.archive";
|
||||
import { NewBranchPanel } from "~/routes/resources.branches.create";
|
||||
import { BranchesOptions } from "~/utils/branches";
|
||||
import { IconArrowBearRight2 } from "@tabler/icons-react";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
|
||||
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
|
||||
|
||||
try {
|
||||
const presenter = new BranchesPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
env: "development",
|
||||
...options,
|
||||
});
|
||||
|
||||
return typedjson(result);
|
||||
} catch (error) {
|
||||
logger.error("Error loading dev branches page", { error });
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { branches, limits, currentPage, totalPages } = useTypedLoaderData<typeof loader>();
|
||||
useAutoRevalidate({ interval: 5000 });
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const atBranchLimit = limits.used >= limits.limit;
|
||||
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={<V4Title>Dev branches</V4Title>} />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
{branches.map((branch) => (
|
||||
<Property.Item key={branch.id}>
|
||||
<Property.Label>{branch.branchName}</Property.Label>
|
||||
<Property.Value>{branch.id}</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("deployment/dev-branches")}
|
||||
>
|
||||
Dev branches docs
|
||||
</LinkButton>
|
||||
|
||||
{limits.isAtLimit ? (
|
||||
<BranchLimitReachedDialog limits={limits} />
|
||||
) : (
|
||||
<NewBranchPanel
|
||||
button={
|
||||
<Button
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-white"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
New branch…
|
||||
</Button>
|
||||
}
|
||||
env="development"
|
||||
/>
|
||||
)}
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
<div className="flex items-center justify-between gap-x-1.5 p-2">
|
||||
<BranchFilters />
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Branch</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Last active</TableHeaderCell>
|
||||
<TableHeaderCell>Archived</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{branches.length === 0 ? (
|
||||
<TableBlankRow colSpan={5}>
|
||||
<Paragraph>There are no matches for your filters</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
branches.map((branch) => {
|
||||
const path = branchesDevPath(organization, project, branch);
|
||||
const cellClass = branch.archivedAt ? "opacity-50" : "";
|
||||
const isSelected = branch.id === environment.id;
|
||||
|
||||
return (
|
||||
<TableRow key={branch.id}>
|
||||
<TableCell isTabbableCell className={cellClass}>
|
||||
<div className="flex items-center gap-1">
|
||||
<BranchEnvironmentIconSmall
|
||||
className={cn("size-4", isSelected && "text-dev")}
|
||||
/>
|
||||
<CopyableText
|
||||
value={branch.branchName}
|
||||
className={cn(isSelected && "text-dev")}
|
||||
/>
|
||||
{isSelected && <Badge variant="extra-small">Current</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<DateTime date={branch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
{branch.isConnected ? (
|
||||
<>Online now</>
|
||||
) : branch.lastActivity ? (
|
||||
<DateTime date={branch.lastActivity} />
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
{branch.archivedAt ? (
|
||||
<CheckIcon className="size-4 text-text-dimmed" />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
isSticky
|
||||
hiddenButtons={
|
||||
isSelected ? null : (
|
||||
<LinkButton
|
||||
to={path}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={IconArrowBearRight2}
|
||||
leadingIconClassName="text-blue-500 -mr-2"
|
||||
className="pl-1.5"
|
||||
>
|
||||
Switch to branch
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
popoverContent={
|
||||
!isSelected || !branch.archivedAt ? (
|
||||
<>
|
||||
{isSelected ? null : (
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={IconArrowBearRight2}
|
||||
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
|
||||
title="Switch to branch"
|
||||
/>
|
||||
)}
|
||||
{!branch.archivedAt ? (
|
||||
<ArchiveButton
|
||||
environment={branch}
|
||||
// The root dev env (no parent) is the default
|
||||
// branch and can't be archived — matches the
|
||||
// guard in ArchiveBranchService.
|
||||
disabled={!branch.parentEnvironmentId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${atBranchLimit ? "stroke-error" : "stroke-success"}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${usageRatio * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round(usageRatio * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{atBranchLimit ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your branches. Archive one to free up space.
|
||||
</Header3>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your branches
|
||||
</Header3>
|
||||
<InfoIconTooltip content="Archived branches don't count towards your limit." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchFilters() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
|
||||
|
||||
const handleArchivedChange = useCallback((checked: boolean) => {
|
||||
setSearchParams((s) => {
|
||||
if (checked) {
|
||||
s.set("showArchived", "true");
|
||||
} else {
|
||||
s.delete("showArchived");
|
||||
}
|
||||
s.delete("page");
|
||||
return s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<SearchInput placeholder="Search branch name…" resetParams={["page"]} />
|
||||
<Switch
|
||||
checked={showArchived ?? false}
|
||||
onCheckedChange={handleArchivedChange}
|
||||
label="Show archived"
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchLimitReachedDialog({
|
||||
limits,
|
||||
}: {
|
||||
limits: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-white"
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New branch
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>You've exceeded your limit</DialogHeader>
|
||||
<div className="mt-2">
|
||||
<Paragraph spacing>
|
||||
You've used {limits.used}/{limits.limit} of your branches.
|
||||
</Paragraph>
|
||||
<Paragraph>You can archive a branch to free up space.</Paragraph>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+678
@@ -0,0 +1,678 @@
|
||||
import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
LockClosedIcon,
|
||||
LockOpenIcon,
|
||||
NoSymbolIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import dotenv from "dotenv";
|
||||
import { useCallback, useState } from "react";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentLabel, environmentFullTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useList } from "~/hooks/useList";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import {
|
||||
environmentVariablesRouteId,
|
||||
type loader as environmentVariablesLoader,
|
||||
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route";
|
||||
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3BillingPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
|
||||
|
||||
const Variable = z.object({
|
||||
key: EnvironmentVariableKey,
|
||||
value: z.string().nonempty("Value is required"),
|
||||
});
|
||||
|
||||
type Variable = z.infer<typeof Variable>;
|
||||
|
||||
const schema = z.object({
|
||||
override: z.preprocess((i) => {
|
||||
if (i === "true") return true;
|
||||
if (i === "false") return false;
|
||||
return;
|
||||
}, z.boolean()),
|
||||
isSecret: z.preprocess((i) => {
|
||||
if (i === "true") return true;
|
||||
return false;
|
||||
}, z.boolean()),
|
||||
environmentIds: z.preprocess(
|
||||
(i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
|
||||
if (Array.isArray(i)) {
|
||||
const ids = i.filter((v) => typeof v === "string" && v !== "");
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
z.array(z.string(), { required_error: "At least one environment is required" })
|
||||
),
|
||||
variables: z.preprocess((i) => {
|
||||
if (!Array.isArray(i)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return i;
|
||||
}, Variable.array().nonempty("At least one variable is required")),
|
||||
});
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: EnvironmentParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
// Per-environment write:envvars is enforced in the handler — the target
|
||||
// environments come from the submission, not the route params.
|
||||
},
|
||||
async ({ request, params, user, ability }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug, projectParam, envParam } = params;
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
throw new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
// Enforce env-tier write:envvars for every targeted environment, so a role
|
||||
// that can't write a deployed tier can't create vars there via a direct
|
||||
// POST (the disabled checkboxes are not the boundary).
|
||||
const targetEnvironments = await prisma.runtimeEnvironment.findMany({
|
||||
where: { id: { in: submission.value.environmentIds } },
|
||||
select: { type: true },
|
||||
});
|
||||
const hasDeniedEnvironment = targetEnvironments.some(
|
||||
(env) => !ability.can("write", { type: "envvars", envType: env.type })
|
||||
);
|
||||
if (hasDeniedEnvironment) {
|
||||
return json(
|
||||
submission.reply({
|
||||
fieldErrors: {
|
||||
environmentIds: [
|
||||
"You don't have permission to manage environment variables in one of the selected environments.",
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
slug: params.projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
return json(submission.reply({ formErrors: ["Project not found"] }));
|
||||
}
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.create(project.id, {
|
||||
...submission.value,
|
||||
lastUpdatedBy: {
|
||||
type: "user",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors: Record<string, string[]> = {};
|
||||
if (result.variableErrors) {
|
||||
for (const { key, error } of result.variableErrors) {
|
||||
const index = submission.value.variables.findIndex((v) => v.key === key);
|
||||
|
||||
if (index !== -1) {
|
||||
fieldErrors[`variables[${index}].key`] = [error];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fieldErrors.variables = [result.error];
|
||||
}
|
||||
|
||||
return json(submission.reply({ fieldErrors }));
|
||||
}
|
||||
|
||||
return redirect(
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, _setIsOpen] = useState(true);
|
||||
const parentData = useTypedMatchesData<typeof environmentVariablesLoader>({
|
||||
id: environmentVariablesRouteId,
|
||||
});
|
||||
invariant(
|
||||
parentData,
|
||||
"Environment variables page loader data must be defined when rendering the create dialog"
|
||||
);
|
||||
const { environments, hasStaging, writableEnvironmentIds } = parentData;
|
||||
// Creating a variable is a write, so gate the targets on write access.
|
||||
const writableEnvironmentIdSet = new Set(writableEnvironmentIds);
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<Set<string>>(new Set());
|
||||
const [selectedBranchId, setSelectedBranchId] = useState<string | undefined>(undefined);
|
||||
|
||||
// TODO for no we only support branch-specific env vars for Preview environments
|
||||
// Mostly to keep the UX for setting consistent env-vars across Dev/Staging/Prod easier
|
||||
const previewBranches = environments.filter(
|
||||
(env) => env.type === "PREVIEW" && env.parentEnvironmentId !== null
|
||||
);
|
||||
const nonBranchEnvironments = environments.filter((env) => env.parentEnvironmentId === null);
|
||||
const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id));
|
||||
const previewIsSelected = selectedEnvironments.some((env) => env.type === "PREVIEW");
|
||||
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
|
||||
|
||||
const [form, fields] = useForm<z.infer<typeof schema>>({
|
||||
id: "create-environment-variables",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
defaultValue: {
|
||||
variables: [{ key: "", value: "" }],
|
||||
},
|
||||
});
|
||||
const { environmentIds, variables } = fields;
|
||||
|
||||
const handleEnvironmentChange = (
|
||||
environmentId: string,
|
||||
isChecked: boolean,
|
||||
environmentType?: string
|
||||
) => {
|
||||
setSelectedEnvironmentIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
|
||||
if (isChecked) {
|
||||
if (environmentType === "PREVIEW") {
|
||||
// If PREVIEW is checked, clear all other selections including branches
|
||||
newSet.clear();
|
||||
|
||||
newSet.add(environmentId);
|
||||
} else {
|
||||
// If a non-PREVIEW environment is checked, remove PREVIEW if it's selected
|
||||
const previewEnv = environments.find((env) => env.type === "PREVIEW");
|
||||
if (previewEnv) {
|
||||
newSet.delete(previewEnv.id);
|
||||
}
|
||||
newSet.add(environmentId);
|
||||
setSelectedBranchId(undefined);
|
||||
}
|
||||
} else {
|
||||
newSet.delete(environmentId);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBranchChange = (branchId: string) => {
|
||||
if (branchId === "all") {
|
||||
setSelectedBranchId(undefined);
|
||||
} else {
|
||||
setSelectedBranchId(branchId);
|
||||
}
|
||||
};
|
||||
|
||||
const [revealAll, setRevealAll] = useState(true);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3EnvironmentVariablesPath(organization, project, environment));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-0 pt-2.5 md:max-w-2xl lg:max-w-3xl">
|
||||
<DialogHeader className="px-4">New environment variables</DialogHeader>
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<Fieldset className="max-h-[70vh] overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Environments</Label>
|
||||
{selectedBranchId ? (
|
||||
<input type="hidden" name="environmentIds" value={selectedBranchId} />
|
||||
) : (
|
||||
Array.from(selectedEnvironmentIds).map((id) => (
|
||||
<input key={id} type="hidden" name="environmentIds" value={id} />
|
||||
))
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{nonBranchEnvironments.map((environment) =>
|
||||
writableEnvironmentIdSet.has(environment.id) ? (
|
||||
<CheckboxWithLabel
|
||||
key={environment.id}
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
defaultChecked={selectedEnvironmentIds.has(environment.id)}
|
||||
onChange={(isChecked) =>
|
||||
handleEnvironmentChange(environment.id, isChecked, environment.type)
|
||||
}
|
||||
label={<EnvironmentLabel environment={environment} className="text-sm" />}
|
||||
variant="button"
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider key={environment.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<CheckboxWithLabel
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
disabled
|
||||
defaultChecked={false}
|
||||
label={
|
||||
<EnvironmentLabel environment={environment} className="text-sm" />
|
||||
}
|
||||
variant="button"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
<NoSymbolIcon className="size-4 text-text-dimmed" />
|
||||
With your current role, you can't manage{" "}
|
||||
{environmentFullTitle(environment)} environment variables.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)}
|
||||
{!hasStaging && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TextLink
|
||||
to={v3BillingPath(organization)}
|
||||
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
|
||||
>
|
||||
<LockClosedIcon className="size-4 text-text-faint" />
|
||||
<EnvironmentLabel
|
||||
environment={{ type: "STAGING" }}
|
||||
className="text-sm"
|
||||
/>
|
||||
</TextLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
<LockOpenIcon className="size-4 text-indigo-500" />
|
||||
Upgrade your plan to add a Staging environment.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TextLink
|
||||
to={v3BillingPath(organization)}
|
||||
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
|
||||
>
|
||||
<LockClosedIcon className="size-4 text-text-faint" />
|
||||
<EnvironmentLabel
|
||||
environment={{ type: "PREVIEW" }}
|
||||
className="text-sm"
|
||||
/>
|
||||
</TextLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
<LockOpenIcon className="size-4 text-indigo-500" />
|
||||
Upgrade your plan to add Preview branches.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<FormError id={environmentIds.errorId}>{environmentIds.errors}</FormError>
|
||||
<Hint>
|
||||
Dev environment variables specified here will be overridden by ones in your .env
|
||||
file when running locally.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
|
||||
{previewIsSelected && (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Select branch</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
variant="tertiary/medium"
|
||||
value={selectedBranchId ?? "all"}
|
||||
setValue={handleBranchChange}
|
||||
placeholder="All branches"
|
||||
items={[{ id: "all", branchName: "All branches" }, ...previewBranches]}
|
||||
className="w-fit min-w-52"
|
||||
filter={{
|
||||
keys: [
|
||||
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
|
||||
],
|
||||
}}
|
||||
text={(val) =>
|
||||
val ? previewBranches.find((b) => b.id === val)?.branchName : null
|
||||
}
|
||||
dropdownIcon
|
||||
>
|
||||
{(matches) =>
|
||||
matches?.map((env) => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
{env.branchName}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
{selectedBranchId !== "all" && selectedBranchId !== undefined && (
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
type="button"
|
||||
onClick={() => setSelectedBranchId(undefined)}
|
||||
LeadingIcon={XMarkIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Hint>Select a branch to override variables in the Preview environment.</Hint>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup className="w-auto">
|
||||
<Switch
|
||||
name="isSecret"
|
||||
variant="medium"
|
||||
defaultChecked={false}
|
||||
value="true"
|
||||
className="-ml-2 inline-flex w-fit"
|
||||
label={<span className="text-text-bright">Secret value</span>}
|
||||
/>
|
||||
<Hint className="-mt-1">
|
||||
If enabled, you and your team will not be able to view the values after creation.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<FieldLayout>
|
||||
<Label>Keys</Label>
|
||||
<div className="flex justify-between gap-1">
|
||||
<Label>Values</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Reveal"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
</FieldLayout>
|
||||
<VariableFields
|
||||
revealValues={revealAll}
|
||||
formId={form.id}
|
||||
form={form}
|
||||
variablesFields={variables}
|
||||
/>
|
||||
<FormError id={variables.errorId}>{variables.errors}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormError>{form.errors}</FormError>
|
||||
</Fieldset>
|
||||
<FormButtons
|
||||
className="px-4 pb-4"
|
||||
confirmButton={
|
||||
<div className="flex flex-row-reverse items-center gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
name="override"
|
||||
value="false"
|
||||
>
|
||||
{isLoading ? "Saving" : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
disabled={isLoading}
|
||||
name="override"
|
||||
value="true"
|
||||
>
|
||||
{isLoading ? "Overriding" : "Override"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid w-full grid-cols-[1fr_1fr] gap-1.5">{children}</div>;
|
||||
}
|
||||
|
||||
function VariableFields({
|
||||
revealValues,
|
||||
formId,
|
||||
variablesFields,
|
||||
form,
|
||||
}: {
|
||||
revealValues: boolean;
|
||||
formId?: string;
|
||||
variablesFields: FieldMetadata<Variable[]>;
|
||||
form: FormMetadata<any>;
|
||||
}) {
|
||||
const {
|
||||
items,
|
||||
append,
|
||||
update,
|
||||
delete: remove,
|
||||
insertAfter,
|
||||
} = useList<Variable>([{ key: "", value: "" }]);
|
||||
|
||||
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const clipboardData = e.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
let text = clipboardData.getData("text");
|
||||
if (!text) return;
|
||||
|
||||
const variables = dotenv.parse(text);
|
||||
const keyValuePairs = Object.entries(variables).map(([key, value]) => ({ key, value }));
|
||||
|
||||
//do the default paste
|
||||
if (keyValuePairs.length === 0) return;
|
||||
|
||||
//prevent default pasting
|
||||
e.preventDefault();
|
||||
|
||||
const [firstPair, ...rest] = keyValuePairs;
|
||||
update(index, firstPair);
|
||||
|
||||
for (const _pair of rest) {
|
||||
form.insert({ name: variablesFields.name });
|
||||
}
|
||||
insertAfter(index, rest);
|
||||
}, []);
|
||||
|
||||
const fields = variablesFields.getFieldList();
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field, index) => {
|
||||
const item = items[index];
|
||||
|
||||
return (
|
||||
<VariableField
|
||||
formId={formId}
|
||||
key={index}
|
||||
index={index}
|
||||
value={item}
|
||||
onChange={(value) => update(index, value)}
|
||||
onPaste={(e) => handlePaste(index, e)}
|
||||
onDelete={() => {
|
||||
form.remove({ name: variablesFields.name, index });
|
||||
remove(index);
|
||||
}}
|
||||
showDeleteButton={items.length > 1}
|
||||
showValue={revealValues}
|
||||
config={field}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Paragraph variant="extra-small">
|
||||
Tip: Paste all your .env values at once into this form to populate it.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
className="w-fit"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.insert({ name: variablesFields.name });
|
||||
append([{ key: "", value: "" }]);
|
||||
}}
|
||||
LeadingIcon={PlusIcon}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableField({
|
||||
formId,
|
||||
index,
|
||||
value,
|
||||
onChange,
|
||||
onPaste,
|
||||
onDelete,
|
||||
showDeleteButton,
|
||||
showValue,
|
||||
config,
|
||||
}: {
|
||||
formId?: string;
|
||||
index: number;
|
||||
value: Variable;
|
||||
onChange: (value: Variable) => void;
|
||||
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
|
||||
onDelete: () => void;
|
||||
showDeleteButton: boolean;
|
||||
showValue: boolean;
|
||||
config: FieldMetadata<Variable>;
|
||||
}) {
|
||||
const fields = config.getFieldset();
|
||||
const baseFieldName = `variables[${index}]`;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<FieldLayout>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id={`${formId}-${baseFieldName}.key`}
|
||||
name={`${baseFieldName}.key`}
|
||||
placeholder="e.g. CLIENT_KEY"
|
||||
value={value.key}
|
||||
onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
|
||||
autoFocus={index === 0}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
<FormError id={fields.key.errorId}>{fields.key.errors}</FormError>
|
||||
</div>
|
||||
<div className={cn("flex items-start gap-1")}>
|
||||
<div className="grow space-y-2">
|
||||
<Input
|
||||
id={`${formId}-${baseFieldName}.value`}
|
||||
name={`${baseFieldName}.value`}
|
||||
type={showValue ? "text" : "password"}
|
||||
placeholder="Not set"
|
||||
value={value.value}
|
||||
onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
|
||||
/>
|
||||
<FormError id={fields.value.errorId}>{fields.value.errors}</FormError>
|
||||
</div>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
type="button"
|
||||
onClick={() => onDelete()}
|
||||
LeadingIcon={XMarkIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FieldLayout>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
+971
@@ -0,0 +1,971 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
InformationCircleIcon,
|
||||
LockClosedIcon,
|
||||
NoSymbolIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
Outlet,
|
||||
useActionData,
|
||||
useFetcher,
|
||||
useNavigation,
|
||||
useRevalidator,
|
||||
type MetaFunction,
|
||||
} from "@remix-run/react";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { SearchInput } from "~/components/primitives/SearchInput";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import {
|
||||
EnvironmentVariablesPresenter,
|
||||
type EnvironmentVariableWithSetValues,
|
||||
} from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { type EnvironmentVariablesEnvironment } from "~/presenters/v3/environmentVariablesEnvironments.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3NewEnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import {
|
||||
DeleteEnvironmentVariableValue,
|
||||
EditEnvironmentVariableValue,
|
||||
} from "~/v3/environmentVariables/repository";
|
||||
import {
|
||||
isPullEnvVarsEnabledForEnvironment,
|
||||
shouldSyncEnvVar,
|
||||
type TriggerEnvironmentType,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Environment variables | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
type PageVercelIntegration = NonNullable<
|
||||
Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>["vercelIntegration"]
|
||||
>;
|
||||
|
||||
// A value the current role can't read for its environment tier is masked
|
||||
// server-side: the value is withheld and the cell renders "Permission denied".
|
||||
export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & {
|
||||
permissionDenied?: boolean;
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesPageLoaderData = {
|
||||
environmentVariables: MaskedEnvironmentVariable[];
|
||||
environments: EnvironmentVariablesEnvironment[];
|
||||
hasStaging: boolean;
|
||||
vercelIntegration: PageVercelIntegration | null;
|
||||
// Environment ids whose env vars the current role can read.
|
||||
accessibleEnvironmentIds: string[];
|
||||
// Environment ids whose env vars the current role can write (create/edit/delete).
|
||||
writableEnvironmentIds: string[];
|
||||
};
|
||||
|
||||
export const environmentVariablesRouteId =
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables";
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: EnvironmentParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
// No hard authorization: the page lists every environment. Values in
|
||||
// environments the role can't read are masked per-tier below.
|
||||
},
|
||||
async ({ params, user, ability }) => {
|
||||
const { projectParam } = params;
|
||||
|
||||
try {
|
||||
const presenter = new EnvironmentVariablesPresenter();
|
||||
const { environmentVariables, environments, hasStaging, vercelIntegration } =
|
||||
await presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
const accessibleEnvironmentIds = environments
|
||||
.filter((env) => ability.can("read", { type: "envvars", envType: env.type }))
|
||||
.map((env) => env.id);
|
||||
const accessible = new Set(accessibleEnvironmentIds);
|
||||
|
||||
// Write access is a separate grant from read: gate write controls (edit,
|
||||
// delete, create) on this set, not on read-accessibility.
|
||||
const writableEnvironmentIds = environments
|
||||
.filter((env) => ability.can("write", { type: "envvars", envType: env.type }))
|
||||
.map((env) => env.id);
|
||||
|
||||
// Withhold values (and the "who/when" metadata) for environments the
|
||||
// role can't read — never serialize them to the client.
|
||||
const masked: MaskedEnvironmentVariable[] = environmentVariables.map((variable) =>
|
||||
accessible.has(variable.environment.id)
|
||||
? variable
|
||||
: {
|
||||
...variable,
|
||||
value: "",
|
||||
isSecret: false,
|
||||
permissionDenied: true,
|
||||
lastUpdatedBy: null,
|
||||
updatedByUser: null,
|
||||
}
|
||||
);
|
||||
|
||||
return typedjson({
|
||||
environmentVariables: masked,
|
||||
environments,
|
||||
hasStaging,
|
||||
vercelIntegration,
|
||||
accessibleEnvironmentIds,
|
||||
writableEnvironmentIds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
key: z.string(),
|
||||
...DeleteEnvironmentVariableValue.shape,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("update-vercel-sync"),
|
||||
key: z.string(),
|
||||
environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]),
|
||||
syncEnabled: z
|
||||
.union([z.literal("true"), z.literal("false")])
|
||||
.transform((val) => val === "true"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: EnvironmentParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
// Per-environment write:envvars is enforced in the handler — the target
|
||||
// environment tier comes from the submission, not the route params.
|
||||
},
|
||||
async ({ request, params, user, ability }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug, projectParam, envParam } = params;
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
throw new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
// Enforce env-tier write:envvars on the targeted environment, so a role
|
||||
// that can't write a deployed tier can't mutate it via a direct POST.
|
||||
const targetEnvType =
|
||||
submission.value.action === "update-vercel-sync"
|
||||
? submission.value.environmentType
|
||||
: (
|
||||
await prisma.runtimeEnvironment.findFirst({
|
||||
where: { id: submission.value.environmentId },
|
||||
select: { type: true },
|
||||
})
|
||||
)?.type;
|
||||
if (targetEnvType && !ability.can("write", { type: "envvars", envType: targetEnvType })) {
|
||||
return json(
|
||||
submission.reply({
|
||||
formErrors: [
|
||||
"You don't have permission to manage environment variables in this environment.",
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
slug: params.projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
return json(submission.reply({ formErrors: ["Project not found"] }));
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "edit": {
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.editValue(project.id, {
|
||||
...submission.value,
|
||||
lastUpdatedBy: {
|
||||
type: "user",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return json(submission.reply({ formErrors: [result.error] }));
|
||||
}
|
||||
|
||||
return json({ ...submission.reply(), success: true });
|
||||
}
|
||||
case "delete": {
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.deleteValue(project.id, submission.value);
|
||||
|
||||
if (!result.success) {
|
||||
return json(submission.reply({ formErrors: [result.error] }));
|
||||
}
|
||||
|
||||
// Clean up syncEnvVarsMapping if Vercel integration exists (best-effort)
|
||||
const { environmentId, key } = submission.value;
|
||||
const vercelService = new VercelIntegrationService();
|
||||
await fromPromise(
|
||||
(async () => {
|
||||
const integration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
if (integration) {
|
||||
const runtimeEnv = await prisma.runtimeEnvironment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: { type: true },
|
||||
});
|
||||
if (runtimeEnv) {
|
||||
await vercelService.removeSyncEnvVarForEnvironment(
|
||||
project.id,
|
||||
key,
|
||||
runtimeEnv.type as TriggerEnvironmentType
|
||||
);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
(error) => error
|
||||
).mapErr((error) => {
|
||||
logger.error("Failed to remove Vercel sync mapping", { error });
|
||||
return error;
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
),
|
||||
request,
|
||||
`Deleted ${submission.value.key} environment variable`
|
||||
);
|
||||
}
|
||||
case "update-vercel-sync": {
|
||||
const vercelService = new VercelIntegrationService();
|
||||
const integration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
|
||||
if (!integration) {
|
||||
return json(submission.reply({ formErrors: ["Vercel integration not found"] }));
|
||||
}
|
||||
|
||||
// Update the sync mapping for the specific env var and environment
|
||||
await vercelService.updateSyncEnvVarForEnvironment(
|
||||
project.id,
|
||||
submission.value.key,
|
||||
submission.value.environmentType,
|
||||
submission.value.syncEnabled
|
||||
);
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const SSR_ROW_WINDOW = 50;
|
||||
const ROW_ESTIMATE_HEIGHT = 44;
|
||||
const VIRTUAL_OVERSCAN = 10;
|
||||
|
||||
type GroupedEnvironmentVariable = MaskedEnvironmentVariable & {
|
||||
isFirstTime: boolean;
|
||||
isLastTime: boolean;
|
||||
occurences: number;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const loaderData = useTypedLoaderData<EnvironmentVariablesPageLoaderData>();
|
||||
|
||||
return <EnvironmentVariablesListPage loaderData={loaderData} />;
|
||||
}
|
||||
|
||||
function EnvironmentVariablesListPage({
|
||||
loaderData,
|
||||
}: {
|
||||
loaderData: EnvironmentVariablesPageLoaderData;
|
||||
}) {
|
||||
const [revealAll, setRevealAll] = useState(false);
|
||||
const { environmentVariables, vercelIntegration } = loaderData;
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { value } = useSearchParams();
|
||||
const urlSearch = value("search") ?? "";
|
||||
const { filteredItems } = useFuzzyFilter<EnvironmentVariableWithSetValues>({
|
||||
items: environmentVariables,
|
||||
keys: ["key", "value", "environment.type", "environment.branchName"],
|
||||
filterText: urlSearch,
|
||||
});
|
||||
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Add isFirst and isLast to each environment variable
|
||||
// They're set based on if they're the first or last time that `key` has been seen in the list
|
||||
const groupedEnvironmentVariables = useMemo((): GroupedEnvironmentVariable[] => {
|
||||
// Create a map to track occurrences of each key
|
||||
const keyOccurrences = new Map<string, number>();
|
||||
|
||||
// First pass: count total occurrences of each key
|
||||
filteredItems.forEach((variable) => {
|
||||
keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1);
|
||||
});
|
||||
|
||||
// Second pass: add isFirstTime, isLastTime, and occurrences flags
|
||||
const seenKeys = new Set<string>();
|
||||
const currentOccurrences = new Map<string, number>();
|
||||
|
||||
return filteredItems.map((variable) => {
|
||||
// Track current occurrence number for this key
|
||||
const currentCount = (currentOccurrences.get(variable.key) || 0) + 1;
|
||||
currentOccurrences.set(variable.key, currentCount);
|
||||
|
||||
const totalOccurrences = keyOccurrences.get(variable.key) || 1;
|
||||
const isFirstTime = !seenKeys.has(variable.key);
|
||||
const isLastTime = currentCount === totalOccurrences;
|
||||
|
||||
if (isFirstTime) {
|
||||
seenKeys.add(variable.key);
|
||||
}
|
||||
|
||||
return {
|
||||
...variable,
|
||||
isFirstTime,
|
||||
isLastTime,
|
||||
occurences: totalOccurrences,
|
||||
};
|
||||
});
|
||||
}, [filteredItems]);
|
||||
|
||||
const shouldVirtualize = groupedEnvironmentVariables.length > SSR_ROW_WINDOW;
|
||||
const [isVirtualized, setIsVirtualized] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsVirtualized(shouldVirtualize);
|
||||
}, [shouldVirtualize]);
|
||||
|
||||
const staticRows = useMemo(() => {
|
||||
if (shouldVirtualize) {
|
||||
return groupedEnvironmentVariables.slice(0, SSR_ROW_WINDOW);
|
||||
}
|
||||
return groupedEnvironmentVariables;
|
||||
}, [groupedEnvironmentVariables, shouldVirtualize]);
|
||||
|
||||
const vercelColumnCount = vercelIntegration?.enabled ? 6 : 5;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Environment variables" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/deploy-environment-variables")}
|
||||
variant="docs/small"
|
||||
>
|
||||
Environment variables docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className={cn("flex h-full min-h-0 flex-col")}>
|
||||
{environmentVariables.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-2">
|
||||
<SearchInput placeholder="Search variables…" autoFocus />
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Switch
|
||||
variant="secondary/small"
|
||||
label="Reveal values"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={tableScrollRef}
|
||||
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<Table
|
||||
containerClassName={cn(
|
||||
filteredItems.length === 0 && "border-t-0",
|
||||
"overflow-visible"
|
||||
)}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
|
||||
Key
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
|
||||
Value
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
Environment
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
</span>
|
||||
}
|
||||
content="Dev environment variables specified here will be overridden by ones in your .env file when running locally."
|
||||
className="max-w-60"
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableHeaderCell className="w-[8%]">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
Sync
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
</span>
|
||||
}
|
||||
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
|
||||
Updated
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel className="w-0">
|
||||
Actions
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{groupedEnvironmentVariables.length > 0 ? (
|
||||
isVirtualized && shouldVirtualize ? (
|
||||
<EnvironmentVariablesVirtualTableBody
|
||||
groupedEnvironmentVariables={groupedEnvironmentVariables}
|
||||
scrollRef={tableScrollRef}
|
||||
revealAll={revealAll}
|
||||
vercelIntegration={vercelIntegration}
|
||||
columnCount={vercelColumnCount}
|
||||
/>
|
||||
) : (
|
||||
<TableBody>
|
||||
{staticRows.map((variable) => (
|
||||
<EnvironmentVariableTableRow
|
||||
key={`${variable.id}-${variable.environment.id}`}
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
vercelIntegration={vercelIntegration}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
)
|
||||
) : (
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={vercelColumnCount}>
|
||||
{environmentVariables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Header2>You haven't set any environment variables yet.</Header2>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Paragraph>No variables match your search.</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function getBorderedCellClassName(variable: GroupedEnvironmentVariable) {
|
||||
if (variable.occurences <= 1) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (variable.isLastTime) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright";
|
||||
}
|
||||
|
||||
function EnvironmentVariableTableRow({
|
||||
variable,
|
||||
revealAll,
|
||||
vercelIntegration,
|
||||
}: {
|
||||
variable: GroupedEnvironmentVariable;
|
||||
revealAll: boolean;
|
||||
vercelIntegration: PageVercelIntegration | null;
|
||||
}) {
|
||||
const cellClassName = "py-2";
|
||||
const borderedCellClassName = getBorderedCellClassName(variable);
|
||||
|
||||
return (
|
||||
<TableRow className={variable.isLastTime ? "after:bg-surface-control" : "after:bg-transparent"}>
|
||||
<TableCell className={cellClassName}>
|
||||
{variable.isFirstTime ? <CopyableText value={variable.key} className="font-mono" /> : null}
|
||||
</TableCell>
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName, "after:left-3")}>
|
||||
{variable.permissionDenied ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex items-center gap-x-1">
|
||||
<NoSymbolIcon className="size-3 text-text-dimmed" />
|
||||
<span className="text-xs text-text-dimmed">Permission denied</span>
|
||||
</div>
|
||||
}
|
||||
content="With your current role, you can't view this environment's variables."
|
||||
/>
|
||||
) : variable.isSecret ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
<span className="text-xs text-text-dimmed">Secret</span>
|
||||
</div>
|
||||
}
|
||||
content="This variable is secret and cannot be revealed."
|
||||
/>
|
||||
) : (
|
||||
<ClipboardField
|
||||
secure={!revealAll}
|
||||
value={variable.value}
|
||||
variant={"secondary/small"}
|
||||
fullWidth={true}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<EnvironmentCombo environment={variable.environment} className="text-sm" />
|
||||
</TableCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
{variable.environment.type !== "DEVELOPMENT" && (
|
||||
<VercelSyncCheckbox
|
||||
envVarKey={variable.key}
|
||||
environmentType={variable.environment.type as TriggerEnvironmentType}
|
||||
syncEnabled={shouldSyncEnvVar(
|
||||
vercelIntegration.syncEnvVarsMapping,
|
||||
variable.key,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
|
||||
vercelIntegration.pullEnvVarsBeforeBuild,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{variable.updatedAt ? (
|
||||
<span className="shrink-0 text-sm tabular-nums text-text-dimmed">
|
||||
<DateTime date={variable.updatedAt} includeSeconds={false} />
|
||||
</span>
|
||||
) : null}
|
||||
{variable.updatedByUser ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={variable.updatedByUser.avatarUrl}
|
||||
name={variable.updatedByUser.name}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span className="truncate text-sm">{variable.updatedByUser.name}</span>
|
||||
</div>
|
||||
) : variable.lastUpdatedBy?.type === "integration" &&
|
||||
variable.lastUpdatedBy?.integration === "vercel" ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<VercelLogo className="size-4 shrink-0 text-text-dimmed transition-colors group-hover/table-row:text-text-bright" />
|
||||
<span className="truncate text-sm capitalize text-text-dimmed transition-colors group-hover/table-row:text-text-bright">
|
||||
{variable.lastUpdatedBy.integration}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
className="[&:has([data-hidden-buttons])]:w-auto w-0"
|
||||
hiddenButtons={
|
||||
// No edit/delete for environments the role can't manage — the value
|
||||
// is withheld, and the action enforces write:envvars independently.
|
||||
variable.permissionDenied ? undefined : (
|
||||
<>
|
||||
<EditEnvironmentVariablePanel variable={variable} revealAll={revealAll} />
|
||||
<DeleteEnvironmentVariableButton variable={variable} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentVariablesVirtualTableBody({
|
||||
groupedEnvironmentVariables,
|
||||
scrollRef,
|
||||
revealAll,
|
||||
vercelIntegration,
|
||||
columnCount,
|
||||
}: {
|
||||
groupedEnvironmentVariables: GroupedEnvironmentVariable[];
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
revealAll: boolean;
|
||||
vercelIntegration: PageVercelIntegration | null;
|
||||
columnCount: number;
|
||||
}) {
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: groupedEnvironmentVariables.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_ESTIMATE_HEIGHT,
|
||||
overscan: VIRTUAL_OVERSCAN,
|
||||
});
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const topSpacerHeight = virtualItems[0]?.start ?? 0;
|
||||
const bottomSpacerHeight = rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
{topSpacerHeight > 0 && (
|
||||
<tr aria-hidden style={{ height: topSpacerHeight }}>
|
||||
<td colSpan={columnCount} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const variable = groupedEnvironmentVariables[virtualRow.index];
|
||||
if (!variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentVariableTableRow
|
||||
key={`${variable.id}-${variable.environment.id}`}
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
vercelIntegration={vercelIntegration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{bottomSpacerHeight > 0 && (
|
||||
<tr aria-hidden style={{ height: bottomSpacerHeight }}>
|
||||
<td colSpan={columnCount} />
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
function EditEnvironmentVariablePanel({
|
||||
variable,
|
||||
revealAll,
|
||||
}: {
|
||||
variable: EnvironmentVariableWithSetValues;
|
||||
revealAll: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const lastSubmission = fetcher.data as any;
|
||||
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
// Close dialog on successful submission
|
||||
useEffect(() => {
|
||||
if (lastSubmission?.success && fetcher.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [lastSubmission?.success, fetcher.state]);
|
||||
|
||||
const [form, { id, environmentId, value }] = useForm({
|
||||
id: `edit-environment-variable-${variable.id}-${variable.environment.id}`,
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
></Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Edit environment variable</DialogHeader>
|
||||
<fetcher.Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="action" value="edit" />
|
||||
<input {...getInputProps(id, { type: "hidden" })} value={variable.id} />
|
||||
<input
|
||||
{...getInputProps(environmentId, { type: "hidden" })}
|
||||
value={variable.environment.id}
|
||||
/>
|
||||
<FormError id={id.errorId}>{id.errors}</FormError>
|
||||
<FormError id={environmentId.errorId}>{environmentId.errors}</FormError>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth className="mt-2 gap-0">
|
||||
<Label>Key</Label>
|
||||
<CopyableText value={variable.key} className="w-fit font-mono text-sm" />
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Environment</Label>
|
||||
<EnvironmentCombo environment={variable.environment} className="text-sm" />
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Value</Label>
|
||||
<Input
|
||||
{...getInputProps(value, { type: "text" })}
|
||||
placeholder={variable.isSecret ? "Set new secret value" : "Not set"}
|
||||
defaultValue={variable.value}
|
||||
type={"text"}
|
||||
/>
|
||||
<FormError id={value.errorId}>{value.errors}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormError>{form.errors}</FormError>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium" disabled={isLoading}>
|
||||
{isLoading ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<Button onClick={() => setIsOpen(false)} variant="tertiary/medium" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteEnvironmentVariableButton({
|
||||
variable,
|
||||
}: {
|
||||
variable: EnvironmentVariableWithSetValues;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form] = useForm({
|
||||
id: "delete-environment-variable",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="id" value={variable.id} />
|
||||
<input type="hidden" name="key" value={variable.key} />
|
||||
<input type="hidden" name="environmentId" value={variable.environment.id} />
|
||||
<Button
|
||||
name="action"
|
||||
value="delete"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
|
||||
className="ml-0.5 transition-colors group-hover/button:bg-error"
|
||||
>
|
||||
{isLoading ? "Deleting" : ""}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle component for controlling whether an environment variable is pulled from Vercel.
|
||||
*
|
||||
* When enabled, the variable will be pulled from Vercel during builds.
|
||||
* By default, all variables are pulled unless explicitly disabled.
|
||||
*
|
||||
* Note: If the env slug is missing from syncEnvVarsMapping, all vars are pulled by default.
|
||||
* Only when syncEnvVarsMapping[envSlug][envVarName] = false, the env var is skipped during builds.
|
||||
*/
|
||||
function VercelSyncCheckbox({
|
||||
envVarKey,
|
||||
environmentType,
|
||||
syncEnabled,
|
||||
pullEnvVarsEnabledForEnv,
|
||||
}: {
|
||||
envVarKey: string;
|
||||
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT";
|
||||
syncEnabled: boolean;
|
||||
pullEnvVarsEnabledForEnv: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
// Revalidate loader data after successful submission (without full page reload)
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data) {
|
||||
const data = fetcher.data as { success?: boolean };
|
||||
if (data.success) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, revalidator]);
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
fetcher.submit(
|
||||
{
|
||||
action: "update-vercel-sync",
|
||||
key: envVarKey,
|
||||
environmentType,
|
||||
syncEnabled: checked.toString(),
|
||||
},
|
||||
{ method: "post" }
|
||||
);
|
||||
};
|
||||
|
||||
// If pull env vars is disabled for this environment, show disabled state
|
||||
if (!pullEnvVarsEnabledForEnv) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={<Switch variant="small" checked={false} disabled onCheckedChange={() => {}} />}
|
||||
content="Enable 'Pull env vars before build' for this environment in Vercel settings."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={syncEnabled}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+966
@@ -0,0 +1,966 @@
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { BellAlertIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { IconAlarmSnooze as IconAlarmSnoozeBase, IconCircleDotted } from "@tabler/icons-react";
|
||||
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { isPast } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BugIcon } from "~/assets/icons/BugIcon";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
|
||||
import {
|
||||
CustomIgnoreDialog,
|
||||
ErrorStatusMenuItems,
|
||||
statusActionToastMessage,
|
||||
} from "~/components/errors/ErrorStatusMenu";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
|
||||
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { PermissionLink } from "~/components/primitives/PermissionLink";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import TooltipPortal from "~/components/primitives/TooltipPortal";
|
||||
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type ErrorGroupActivity,
|
||||
type ErrorGroupActivityVersions,
|
||||
type ErrorGroupOccurrences,
|
||||
ErrorGroupPresenter,
|
||||
type ErrorGroupState,
|
||||
type ErrorGroupSummary,
|
||||
} from "~/presenters/v3/ErrorGroupPresenter.server";
|
||||
import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3CreateBulkActionPath,
|
||||
v3ErrorsPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
return [
|
||||
{
|
||||
title: `Error Details | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const emptyStringToUndefined = z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.coerce.number().positive().optional()
|
||||
);
|
||||
|
||||
const actionSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("resolve"),
|
||||
taskIdentifier: z.string().min(1),
|
||||
resolvedInVersion: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("ignore"),
|
||||
taskIdentifier: z.string().min(1),
|
||||
duration: emptyStringToUndefined,
|
||||
occurrenceRate: emptyStringToUndefined,
|
||||
totalOccurrences: emptyStringToUndefined,
|
||||
reason: z.preprocess((v) => (v === "" ? undefined : v), z.string().optional()),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("unresolve"),
|
||||
taskIdentifier: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const fingerprint = params.fingerprint;
|
||||
|
||||
if (!fingerprint) {
|
||||
return json({ error: "Fingerprint parameter is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema: actionSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const actions = new ErrorGroupActions();
|
||||
const identifier = {
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
taskIdentifier: submission.value.taskIdentifier,
|
||||
errorFingerprint: fingerprint,
|
||||
};
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "resolve": {
|
||||
await actions.resolveError(identifier, {
|
||||
userId,
|
||||
resolvedInVersion: submission.value.resolvedInVersion,
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
case "ignore": {
|
||||
let occurrenceCountAtIgnoreTime: number | undefined;
|
||||
|
||||
if (submission.value.totalOccurrences) {
|
||||
const clickhouseClient = await clickhouseFactory.getClickhouseForOrganization(
|
||||
environment.organizationId,
|
||||
"query"
|
||||
);
|
||||
|
||||
const qb = clickhouseClient.errors.listQueryBuilder();
|
||||
qb.where("organization_id = {organizationId: String}", {
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
qb.where("project_id = {projectId: String}", { projectId: project.id });
|
||||
qb.where("environment_id = {environmentId: String}", {
|
||||
environmentId: environment.id,
|
||||
});
|
||||
qb.where("error_fingerprint = {fingerprint: String}", { fingerprint });
|
||||
qb.where("task_identifier = {taskIdentifier: String}", {
|
||||
taskIdentifier: submission.value.taskIdentifier,
|
||||
});
|
||||
qb.groupBy("error_fingerprint, task_identifier");
|
||||
|
||||
const [err, results] = await qb.execute();
|
||||
if (err || !results || results.length === 0) {
|
||||
return json(
|
||||
{ error: "Failed to fetch current occurrence count. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
occurrenceCountAtIgnoreTime = results[0].occurrence_count;
|
||||
}
|
||||
|
||||
await actions.ignoreError(identifier, {
|
||||
userId,
|
||||
duration: submission.value.duration,
|
||||
occurrenceRateThreshold: submission.value.occurrenceRate,
|
||||
totalOccurrencesThreshold: submission.value.totalOccurrences,
|
||||
occurrenceCountAtIgnoreTime,
|
||||
reason: submission.value.reason,
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
case "unresolve": {
|
||||
await actions.unresolveError(identifier);
|
||||
return json({ ok: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const fingerprint = params.fingerprint;
|
||||
|
||||
if (!fingerprint) {
|
||||
throw new Response("Fingerprint parameter is required", { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
const from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
const to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
|
||||
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||
const directionRaw = url.searchParams.get("direction") ?? undefined;
|
||||
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
|
||||
|
||||
const [logsClickhouseClient, clickhouseClient] = await Promise.all([
|
||||
clickhouseFactory.getClickhouseForOrganization(environment.organizationId, "logs"),
|
||||
clickhouseFactory.getClickhouseForOrganization(environment.organizationId, "standard"),
|
||||
]);
|
||||
|
||||
const presenter = new ErrorGroupPresenter($replica, logsClickhouseClient, clickhouseClient);
|
||||
|
||||
const detailPromise = presenter
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
fingerprint,
|
||||
versions: versions.length > 0 ? versions : undefined,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
|
||||
|
||||
const activityPromise = presenter
|
||||
.getOccurrences(
|
||||
project.organizationId,
|
||||
project.id,
|
||||
environment.id,
|
||||
fingerprint,
|
||||
time.from,
|
||||
time.to,
|
||||
versions.length > 0 ? versions : undefined
|
||||
)
|
||||
.catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] }));
|
||||
|
||||
// Display flags for the row-menu and bulk-replay controls — the cancel/
|
||||
// replay action routes enforce write:runs independently. Permissive in OSS.
|
||||
const runAuth = await rbac.authenticateSession(request, {
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
const runPermissions = runAuth.ok
|
||||
? checkPermissions(runAuth.ability, {
|
||||
canCancelRuns: { action: "write", resource: { type: "runs" } },
|
||||
canReplayRuns: { action: "write", resource: { type: "runs" } },
|
||||
})
|
||||
: { canCancelRuns: true, canReplayRuns: true };
|
||||
|
||||
return typeddefer({
|
||||
data: detailPromise,
|
||||
activity: activityPromise,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
fingerprint,
|
||||
...runPermissions,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
data,
|
||||
activity,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
fingerprint,
|
||||
canCancelRuns,
|
||||
canReplayRuns,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
const errorsPath = useMemo(() => {
|
||||
const base = v3ErrorsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
const carry = new URLSearchParams();
|
||||
const period = searchParams.get("period");
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
if (period) carry.set("period", period);
|
||||
if (from) carry.set("from", from);
|
||||
if (to) carry.set("to", to);
|
||||
for (const v of searchParams.getAll("versions")) {
|
||||
if (v) carry.append("versions", v);
|
||||
}
|
||||
const qs = carry.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}, [organizationSlug, projectParam, envParam, searchParams.toString()]);
|
||||
|
||||
const alertsHref = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("alerts", "true");
|
||||
return `?${params.toString()}`;
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: errorsPath,
|
||||
text: "Errors",
|
||||
}}
|
||||
title={<span className="font-mono text-xs">{ErrorId.toFriendlyId(fingerprint)}</span>}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
to={alertsHref}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-alerts"
|
||||
>
|
||||
Configure alerts…
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading error details…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load error details. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(result) => {
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ErrorGroupDetail
|
||||
errorGroup={result.errorGroup}
|
||||
runList={result.runList}
|
||||
activity={activity}
|
||||
organizationSlug={organizationSlug}
|
||||
projectParam={projectParam}
|
||||
envParam={envParam}
|
||||
fingerprint={fingerprint}
|
||||
canCancelRuns={canCancelRuns}
|
||||
canReplayRuns={canReplayRuns}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorGroupDetail({
|
||||
errorGroup,
|
||||
runList,
|
||||
activity,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
fingerprint,
|
||||
canCancelRuns,
|
||||
canReplayRuns,
|
||||
}: {
|
||||
errorGroup: ErrorGroupSummary | undefined;
|
||||
runList: NextRunList | undefined;
|
||||
activity: Promise<ErrorGroupOccurrences>;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
fingerprint: string;
|
||||
canCancelRuns: boolean;
|
||||
canReplayRuns: boolean;
|
||||
}) {
|
||||
const { value, values } = useSearchParams();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
if (!errorGroup) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Header3 className="mb-2">Error not found</Header3>
|
||||
<Paragraph variant="small">
|
||||
This error group does not exist or has no instances.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fromValue = value("from") ?? undefined;
|
||||
const toValue = value("to") ?? undefined;
|
||||
const selectedVersions = values("versions").filter((v) => v !== "");
|
||||
|
||||
const filters: TaskRunListSearchFilters = {
|
||||
period: value("period") ?? undefined,
|
||||
from: fromValue ? parseInt(fromValue, 10) : undefined,
|
||||
to: toValue ? parseInt(toValue, 10) : undefined,
|
||||
versions: selectedVersions.length > 0 ? selectedVersions : undefined,
|
||||
rootOnly: false,
|
||||
errorId: ErrorId.toFriendlyId(fingerprint),
|
||||
};
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
{/* Main content: chart + runs */}
|
||||
<ResizablePanel id="error-main" min="300px">
|
||||
<div className="grid h-full grid-rows-[12rem_1fr] overflow-hidden">
|
||||
{/* Activity chart */}
|
||||
<div className="flex flex-col gap-3 overflow-hidden border-b border-grid-bright bg-background-bright py-2 pl-2 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TimeFilter defaultPeriod="7d" labelName="Occurred" />
|
||||
<LogsVersionFilter />
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<ActivityChartBlankState />}>
|
||||
<TypedAwait resolve={activity} errorElement={<ActivityChartBlankState />}>
|
||||
{(result) => {
|
||||
if (result.data.length > 0 && result.versions.length > 0) {
|
||||
return <ActivityChart activity={result.data} versions={result.versions} />;
|
||||
}
|
||||
return <ActivityChartBlankState />;
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Runs Table */}
|
||||
<div className="flex flex-col gap-1 overflow-y-hidden">
|
||||
<div className="flex items-center justify-between pl-3 pr-2 pt-1">
|
||||
<Header3 className="mb-1 mt-2">Runs</Header3>
|
||||
{runList && (
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to={v3RunsPath(organization, project, environment, filters)}
|
||||
LeadingIcon={RunsIcon}
|
||||
>
|
||||
View all runs
|
||||
</LinkButton>
|
||||
<PermissionLink
|
||||
hasPermission={canReplayRuns}
|
||||
noPermissionTooltip="You don't have permission to replay runs"
|
||||
variant="secondary/small"
|
||||
to={v3CreateBulkActionPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
filters,
|
||||
"filter",
|
||||
"replay"
|
||||
)}
|
||||
LeadingIcon={ListCheckedIcon}
|
||||
>
|
||||
Bulk replay…
|
||||
</PermissionLink>
|
||||
<ListPagination list={runList} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{runList ? (
|
||||
<TaskRunsTable
|
||||
total={runList.runs.length}
|
||||
hasFilters={selectedVersions.length > 0}
|
||||
filters={{
|
||||
tasks: [],
|
||||
versions: selectedVersions,
|
||||
statuses: [],
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
}}
|
||||
runs={runList.runs}
|
||||
isLoading={false}
|
||||
variant="dimmed"
|
||||
additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }}
|
||||
canCancelRuns={canCancelRuns}
|
||||
canReplayRuns={canReplayRuns}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3">
|
||||
<BugIcon className="size-16 text-secondary" />
|
||||
<Paragraph className="max-w-32 text-center text-text-dimmed">
|
||||
No runs found for this error.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Right-hand detail sidebar */}
|
||||
<ResizableHandle id="error-detail-handle" />
|
||||
<ResizablePanel id="error-detail" min="280px" default="380px" max="500px" isStaticAtRest>
|
||||
<ErrorDetailSidebar errorGroup={errorGroup} fingerprint={fingerprint} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDetailSidebar({
|
||||
errorGroup,
|
||||
fingerprint,
|
||||
}: {
|
||||
errorGroup: ErrorGroupSummary;
|
||||
fingerprint: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="border-b border-grid-dimmed px-3 py-2">
|
||||
<Header2 className="truncate">Details</Header2>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Property.Table>
|
||||
{/* Status */}
|
||||
<Property.Item className="gap-1">
|
||||
<Property.Label>Error status</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="flex items-center justify-between">
|
||||
<ErrorStatusBadge status={errorGroup.state.status} className="w-fit" />
|
||||
<ErrorStatusDropdown
|
||||
state={errorGroup.state}
|
||||
taskIdentifier={errorGroup.taskIdentifier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorGroup.state.status === "IGNORED" && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<IgnoredDetails
|
||||
className="mt-2"
|
||||
state={errorGroup.state}
|
||||
totalOccurrences={errorGroup.count}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{/* Error message */}
|
||||
<Property.Item className="gap-1">
|
||||
<Property.Label>Error</Property.Label>
|
||||
<Property.Value>
|
||||
<CodeBlock
|
||||
code={errorGroup.errorMessage}
|
||||
showCopyButton
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
language="typescript"
|
||||
wrap
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={ErrorId.toFriendlyId(errorGroup.fingerprint)} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={errorGroup.taskIdentifier} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Occurrences</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="tabular-nums">{errorGroup.count.toLocaleString()}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>First seen</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={errorGroup.firstSeen} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Last seen</Property.Label>
|
||||
<Property.Value>
|
||||
<RelativeDateTime date={errorGroup.lastSeen} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{errorGroup.affectedVersions.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Versions</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-sm">
|
||||
{errorGroup.affectedVersions.join(", ")}
|
||||
</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IgnoredDetails({
|
||||
state,
|
||||
totalOccurrences,
|
||||
className,
|
||||
}: {
|
||||
state: ErrorGroupState;
|
||||
totalOccurrences: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (state.status !== "IGNORED") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasConditions =
|
||||
state.ignoredUntil || state.ignoredUntilOccurrenceRate || state.ignoredUntilTotalOccurrences;
|
||||
|
||||
const ignoredForever = !hasConditions;
|
||||
|
||||
const occurrencesSinceIgnore =
|
||||
state.ignoredUntilTotalOccurrences && state.ignoredAtOccurrenceCount !== null
|
||||
? totalOccurrences - state.ignoredAtOccurrenceCount
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded border border-text-dimmed/20 bg-text-dimmed/5 px-3 py-2.5 text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<IconAlarmSnoozeBase className="size-5 text-blue-500" />
|
||||
<Header3 className="text-text-bright">
|
||||
{ignoredForever ? "Ignored permanently" : "Ignored with conditions"}
|
||||
</Header3>
|
||||
</div>
|
||||
{(state.ignoredByUserDisplayName || state.ignoredAt) && (
|
||||
<Paragraph variant="extra-small/dimmed">
|
||||
{state.ignoredByUserDisplayName && <>Configured by {state.ignoredByUserDisplayName}</>}
|
||||
{state.ignoredByUserDisplayName && state.ignoredAt && " "}
|
||||
{state.ignoredAt && <RelativeDateTime date={state.ignoredAt} capitalize={false} />}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.ignoredReason && (
|
||||
<Paragraph variant="extra-small/dimmed">Reason: {state.ignoredReason}</Paragraph>
|
||||
)}
|
||||
|
||||
{hasConditions && (
|
||||
<div className="flex flex-col gap-1 text-xs text-text-dimmed">
|
||||
{state.ignoredUntil && (
|
||||
<span>
|
||||
Will revert to "Unresolved" at:{" "}
|
||||
<span className="text-text-bright">
|
||||
<DateTime date={state.ignoredUntil} />
|
||||
</span>
|
||||
{isPast(state.ignoredUntil) && <span className="ml-1 text-warning">(expired)</span>}
|
||||
</span>
|
||||
)}
|
||||
{state.ignoredUntilOccurrenceRate !== null && state.ignoredUntilOccurrenceRate > 0 && (
|
||||
<span>
|
||||
Will revert to "Unresolved" when: Occurrence rate exceeds{" "}
|
||||
<span className="tabular-nums text-text-bright">
|
||||
{state.ignoredUntilOccurrenceRate}/min
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{state.ignoredUntilTotalOccurrences !== null &&
|
||||
state.ignoredUntilTotalOccurrences > 0 && (
|
||||
<span>
|
||||
Will revert to "Unresolved" when: Total occurrences exceed{" "}
|
||||
<span className="tabular-nums text-text-bright">
|
||||
{state.ignoredUntilTotalOccurrences.toLocaleString()}
|
||||
</span>
|
||||
{occurrencesSinceIgnore !== null && (
|
||||
<span className="ml-1 tabular-nums text-text-dimmed">
|
||||
({occurrencesSinceIgnore.toLocaleString()} since ignored)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorStatusDropdown({
|
||||
state,
|
||||
taskIdentifier,
|
||||
}: {
|
||||
state: ErrorGroupState;
|
||||
taskIdentifier: string;
|
||||
}) {
|
||||
const fetcher = useFetcher<{ ok?: boolean }>();
|
||||
const revalidator = useRevalidator();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false);
|
||||
const isSubmitting = fetcher.state !== "idle";
|
||||
const toast = useToast();
|
||||
const pendingToast = useRef<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) {
|
||||
toast.success(pendingToast.current);
|
||||
pendingToast.current = undefined;
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, toast, revalidator]);
|
||||
|
||||
const act = (data: Record<string, string>) => {
|
||||
setPopoverOpen(false);
|
||||
pendingToast.current = statusActionToastMessage(data);
|
||||
fetcher.submit(data, { method: "post" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverArrowTrigger variant="primary" disabled={isSubmitting} className="items-center">
|
||||
<IconCircleDotted className="-ml-1 mr-1 size-3.5 text-text-bright" />
|
||||
Mark error as…
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent className="inline-flex min-w-0! flex-col p-1" align="end">
|
||||
<ErrorStatusMenuItems
|
||||
status={state.status}
|
||||
taskIdentifier={taskIdentifier}
|
||||
onAction={act}
|
||||
onCustomIgnore={() => {
|
||||
setPopoverOpen(false);
|
||||
setCustomIgnoreOpen(true);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<CustomIgnoreDialog
|
||||
open={customIgnoreOpen}
|
||||
onOpenChange={setCustomIgnoreOpen}
|
||||
taskIdentifier={taskIdentifier}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChart({
|
||||
activity,
|
||||
versions,
|
||||
}: {
|
||||
activity: ErrorGroupActivity;
|
||||
versions: ErrorGroupActivityVersions;
|
||||
}) {
|
||||
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
|
||||
const colors = useMemo(
|
||||
() => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]),
|
||||
[versions]
|
||||
);
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
activity.map((d) => ({
|
||||
...d,
|
||||
__timestamp: d.date instanceof Date ? d.date.getTime() : new Date(d.date).getTime(),
|
||||
})),
|
||||
[activity]
|
||||
);
|
||||
|
||||
const midnightTicks = useMemo(() => {
|
||||
const ticks: number[] = [];
|
||||
for (const d of data) {
|
||||
const date = new Date(d.__timestamp);
|
||||
if (date.getHours() === 0 && date.getMinutes() === 0) {
|
||||
ticks.push(d.__timestamp);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}, [data]);
|
||||
|
||||
const xAxisFormatter = useMemo(() => {
|
||||
return (value: number) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="__timestamp"
|
||||
tickFormatter={xAxisFormatter}
|
||||
ticks={midnightTicks}
|
||||
height={24}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11, fill: "var(--color-text-dimmed)" }}
|
||||
/>
|
||||
<YAxis
|
||||
width={30}
|
||||
tickMargin={4}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11, fill: "var(--color-text-dimmed)" }}
|
||||
domain={["auto", (dataMax: number) => dataMax * 1.15]}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
|
||||
content={<ActivityTooltip versions={versions} colors={colors} />}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
animationDuration={0}
|
||||
/>
|
||||
{versions.map((version, i) => (
|
||||
<Bar
|
||||
key={version}
|
||||
dataKey={version}
|
||||
stackId="versions"
|
||||
fill={colors[i]}
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const ActivityTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
versions,
|
||||
colors,
|
||||
}: TooltipProps<number, string> & { versions: string[]; colors: string[] }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const timestamp = payload[0]?.payload?.__timestamp as number | undefined;
|
||||
if (!timestamp) return null;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const formattedDate = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipPortal active={active}>
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{payload.map((entry, i) => {
|
||||
const value = (entry.value as number) ?? 0;
|
||||
return (
|
||||
<div key={entry.dataKey} className="flex items-center gap-2 text-xs">
|
||||
<div className="size-2 rounded-[2px]" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-text-dimmed">{entry.dataKey}</span>
|
||||
<span className="tabular-nums text-text-bright">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPortal>
|
||||
);
|
||||
};
|
||||
|
||||
function ActivityChartBlankState() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
|
||||
{[...Array(42)].map((_, i) => (
|
||||
<div key={i} className="h-full flex-1 bg-background-dimmed" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+777
@@ -0,0 +1,777 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type ErrorGroupStatus } from "@trigger.dev/database";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
type TooltipProps,
|
||||
} from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BugIcon } from "~/assets/icons/BugIcon";
|
||||
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
|
||||
import {
|
||||
CustomIgnoreDialog,
|
||||
ErrorStatusMenuItems,
|
||||
statusActionToastMessage,
|
||||
} from "~/components/errors/ErrorStatusMenu";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { formatDateTime, RelativeDateTime } from "~/components/primitives/DateTime";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverSectionHeader } from "~/components/primitives/Popover";
|
||||
import { SearchInput } from "~/components/primitives/SearchInput";
|
||||
import {
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
CopyableTableCell,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import TooltipPortal from "~/components/primitives/TooltipPortal";
|
||||
import { appliedSummary, FilterMenuProvider, TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
ErrorsListPresenter,
|
||||
type ErrorGroup,
|
||||
type ErrorOccurrenceActivity,
|
||||
type ErrorOccurrences,
|
||||
type ErrorsList as ErrorsListData,
|
||||
} from "~/presenters/v3/ErrorsListPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { EnvironmentParamSchema, v3ErrorPath } from "~/utils/pathBuilder";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Errors | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
|
||||
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
|
||||
const statuses = url.searchParams
|
||||
.getAll("status")
|
||||
.filter(
|
||||
(s): s is ErrorGroupStatus => s === "UNRESOLVED" || s === "RESOLVED" || s === "IGNORED"
|
||||
);
|
||||
const search = url.searchParams.get("search") ?? undefined;
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
const from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
const to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||
const directionRaw = url.searchParams.get("direction");
|
||||
const direction =
|
||||
directionRaw === "forward" || directionRaw === "backward" ? directionRaw : undefined;
|
||||
|
||||
const plan = await getCurrentPlan(project.organizationId);
|
||||
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
|
||||
|
||||
const logsClickhouseClient = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"logs"
|
||||
);
|
||||
const presenter = new ErrorsListPresenter($replica, logsClickhouseClient);
|
||||
|
||||
const listPromise = presenter
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
versions: versions.length > 0 ? versions : undefined,
|
||||
statuses: statuses.length > 0 ? statuses : undefined,
|
||||
search,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
defaultPeriod: "1d",
|
||||
retentionLimitDays,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const occurrencesPromise = listPromise.then((result) => {
|
||||
if ("error" in result) return { data: {} };
|
||||
const fingerprints = result.errorGroups.map((g) => g.fingerprint);
|
||||
if (fingerprints.length === 0) return { data: {} };
|
||||
return presenter.getOccurrences(
|
||||
project.organizationId,
|
||||
project.id,
|
||||
environment.id,
|
||||
fingerprints,
|
||||
result.filters.from,
|
||||
result.filters.to
|
||||
);
|
||||
});
|
||||
|
||||
return typeddefer({
|
||||
data: listPromise,
|
||||
occurrences: occurrencesPromise,
|
||||
defaultPeriod: "1d",
|
||||
retentionLimitDays,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
data,
|
||||
occurrences,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
useInterval({
|
||||
interval: 60_000,
|
||||
onLoad: false,
|
||||
callback: useCallback(() => {
|
||||
if (revalidator.state === "idle") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}, [revalidator]),
|
||||
});
|
||||
|
||||
const location = useOptimisticLocation();
|
||||
const alertsHref = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("alerts", "true");
|
||||
return `?${params.toString()}`;
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle title="Errors" />
|
||||
</NavBar>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
|
||||
<div className="border-b border-grid-bright" />
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading errors…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
alertsHref={alertsHref}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load errors. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(result) => {
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
alertsHref={alertsHref}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
list={result}
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
alertsHref={alertsHref}
|
||||
/>
|
||||
<ErrorsList
|
||||
errorGroups={result.errorGroups}
|
||||
occurrences={occurrences}
|
||||
organizationSlug={organizationSlug}
|
||||
projectParam={projectParam}
|
||||
envParam={envParam}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const errorStatusOptions = [
|
||||
{ value: "UNRESOLVED", label: "Unresolved" },
|
||||
{ value: "RESOLVED", label: "Resolved" },
|
||||
{ value: "IGNORED", label: "Ignored" },
|
||||
] as const;
|
||||
|
||||
const statusIcon = <BugIcon className="size-4" />;
|
||||
const statusShortcut = { key: "s" };
|
||||
const timeShortcut = { key: "d" };
|
||||
const alertsShortcut = { key: "c" };
|
||||
|
||||
function StatusFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const selectedStatuses = values("status");
|
||||
|
||||
if (selectedStatuses.length === 0 || selectedStatuses.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ErrorStatusDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={statusIcon}
|
||||
variant="secondary/small"
|
||||
shortcut={statusShortcut}
|
||||
tooltipTitle="Filter by status"
|
||||
className="pl-1.5"
|
||||
>
|
||||
<span className="ml-1">Status</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ErrorStatusDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
icon={statusIcon}
|
||||
value={appliedSummary(
|
||||
selectedStatuses.map((s) => {
|
||||
const opt = errorStatusOptions.find((o) => o.value === s);
|
||||
return opt ? opt.label : s;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["status", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorStatusDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
status: values.length > 0 ? values : undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("status")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<SelectList>
|
||||
{errorStatusOptions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<ErrorStatusBadge status={item.value} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersBar({
|
||||
list,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
alertsHref,
|
||||
}: {
|
||||
list?: ErrorsListData;
|
||||
defaultPeriod?: string;
|
||||
retentionLimitDays: number;
|
||||
alertsHref: string;
|
||||
}) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("status") ||
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("versions") ||
|
||||
searchParams.has("search") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to");
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
{list ? (
|
||||
<>
|
||||
<SearchInput placeholder="Search errors…" />
|
||||
<StatusFilter />
|
||||
<LogsTaskFilter possibleTasks={list.filters.possibleTasks} />
|
||||
<LogsVersionFilter />
|
||||
<TimeFilter
|
||||
defaultPeriod={defaultPeriod}
|
||||
maxPeriodDays={retentionLimitDays}
|
||||
labelName="Occurred"
|
||||
shortcut={timeShortcut}
|
||||
/>
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SearchInput placeholder="Search errors…" />
|
||||
<StatusFilter />
|
||||
<LogsTaskFilter possibleTasks={[]} />
|
||||
<LogsVersionFilter />
|
||||
<TimeFilter
|
||||
defaultPeriod={defaultPeriod}
|
||||
maxPeriodDays={retentionLimitDays}
|
||||
shortcut={timeShortcut}
|
||||
/>
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<LinkButton
|
||||
to={alertsHref}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-alerts"
|
||||
shortcut={alertsShortcut}
|
||||
tooltip="Configure alerts"
|
||||
>
|
||||
Configure alerts…
|
||||
</LinkButton>
|
||||
{list && <ListPagination list={list} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorsList({
|
||||
errorGroups,
|
||||
occurrences,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
}: {
|
||||
errorGroups: ErrorGroup[];
|
||||
occurrences: Promise<ErrorOccurrences>;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
}) {
|
||||
if (errorGroups.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<BugIcon className="size-16 text-secondary" />
|
||||
<Paragraph className="text-center text-text-dimmed">
|
||||
No errors found for this time period.
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table containerClassName="max-h-full pb-10" showTopBorder={false}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Error</TableHeaderCell>
|
||||
<TableHeaderCell>Occurrences</TableHeaderCell>
|
||||
<TableHeaderCell>Activity</TableHeaderCell>
|
||||
<TableHeaderCell>First seen</TableHeaderCell>
|
||||
<TableHeaderCell>Last seen</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{errorGroups.map((errorGroup) => (
|
||||
<ErrorGroupRow
|
||||
key={`${errorGroup.taskIdentifier}::${errorGroup.fingerprint}`}
|
||||
errorGroup={errorGroup}
|
||||
occurrences={occurrences}
|
||||
organizationSlug={organizationSlug}
|
||||
projectParam={projectParam}
|
||||
envParam={envParam}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorGroupRow({
|
||||
errorGroup,
|
||||
occurrences,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
}: {
|
||||
errorGroup: ErrorGroup;
|
||||
occurrences: Promise<ErrorOccurrences>;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
}) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
const errorPath = useMemo(() => {
|
||||
const base = v3ErrorPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ fingerprint: errorGroup.fingerprint }
|
||||
);
|
||||
const carry = new URLSearchParams();
|
||||
const period = searchParams.get("period");
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
if (period) carry.set("period", period);
|
||||
if (from) carry.set("from", from);
|
||||
if (to) carry.set("to", to);
|
||||
for (const v of searchParams.getAll("versions")) {
|
||||
if (v) carry.append("versions", v);
|
||||
}
|
||||
const qs = carry.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]);
|
||||
|
||||
const errorMessage = `${errorGroup.errorMessage}`;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<CopyableTableCell to={errorPath} value={ErrorId.toFriendlyId(errorGroup.fingerprint)}>
|
||||
{errorGroup.fingerprint.slice(-8)}
|
||||
</CopyableTableCell>
|
||||
<TableCell to={errorPath}>
|
||||
<ErrorStatusBadge status={errorGroup.status} />
|
||||
</TableCell>
|
||||
<TableCell to={errorPath}>{errorGroup.taskIdentifier}</TableCell>
|
||||
<CopyableTableCell to={errorPath} className="font-mono" value={errorMessage}>
|
||||
{errorMessage.length > 128 ? `${errorMessage.slice(0, 128)}…` : errorMessage}
|
||||
</CopyableTableCell>
|
||||
<TableCell to={errorPath}>
|
||||
<span className="tabular-nums">{errorGroup.count.toLocaleString()}</span>
|
||||
</TableCell>
|
||||
<TableCell to={errorPath} actionClassName="py-1.5">
|
||||
<Suspense fallback={<ErrorActivityBlankState />}>
|
||||
<TypedAwait resolve={occurrences} errorElement={<ErrorActivityBlankState />}>
|
||||
{(result) => {
|
||||
const activity = result.data[errorGroup.fingerprint];
|
||||
return activity ? (
|
||||
<ErrorActivityGraph activity={activity} />
|
||||
) : (
|
||||
<ErrorActivityBlankState />
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={errorPath} className="tabular-nums">
|
||||
<RelativeDateTime date={errorGroup.firstSeen} />
|
||||
</TableCell>
|
||||
<TableCell to={errorPath} className="tabular-nums">
|
||||
<RelativeDateTime date={errorGroup.lastSeen} />
|
||||
</TableCell>
|
||||
<ErrorActionsCell
|
||||
errorGroup={errorGroup}
|
||||
organizationSlug={organizationSlug}
|
||||
projectParam={projectParam}
|
||||
envParam={envParam}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorActionsCell({
|
||||
errorGroup,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
}: {
|
||||
errorGroup: ErrorGroup;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
}) {
|
||||
const fetcher = useFetcher<{ ok?: boolean }>();
|
||||
const revalidator = useRevalidator();
|
||||
const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
const pendingToast = useRef<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) {
|
||||
toast.success(pendingToast.current);
|
||||
pendingToast.current = undefined;
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, toast, revalidator]);
|
||||
|
||||
const actionUrl = v3ErrorPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ fingerprint: errorGroup.fingerprint }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={(close) => (
|
||||
<>
|
||||
<PopoverSectionHeader title="Mark error as…" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<ErrorStatusMenuItems
|
||||
status={errorGroup.status}
|
||||
taskIdentifier={errorGroup.taskIdentifier}
|
||||
onAction={(data) => {
|
||||
close();
|
||||
pendingToast.current = statusActionToastMessage(data);
|
||||
fetcher.submit(data, { method: "post", action: actionUrl });
|
||||
}}
|
||||
onCustomIgnore={() => {
|
||||
close();
|
||||
setCustomIgnoreOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<CustomIgnoreDialog
|
||||
open={customIgnoreOpen}
|
||||
onOpenChange={setCustomIgnoreOpen}
|
||||
taskIdentifier={errorGroup.taskIdentifier}
|
||||
formAction={actionUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) {
|
||||
const maxCount = Math.max(...activity.map((d) => d.count));
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="h-6 w-28 rounded-sm">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={activity} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<YAxis domain={[0, maxCount || 1]} hide />
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
|
||||
content={<ErrorActivityTooltip />}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
animationDuration={0}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="#6366F1"
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
minPointSize={1}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
|
||||
{maxCount > 0 && (
|
||||
<ReferenceLine
|
||||
y={maxCount}
|
||||
stroke="var(--color-border-brighter)"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
button={
|
||||
<span className="-mt-1 text-xxs tabular-nums text-text-dimmed">
|
||||
{formatNumberCompact(maxCount)}
|
||||
</span>
|
||||
}
|
||||
content="Peak occurrences in a single time bucket"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ErrorActivityTooltip = ({ active, payload }: TooltipProps<number, string>) => {
|
||||
if (active && payload && payload.length > 0) {
|
||||
const entry = payload[0].payload as { date: Date; count: number };
|
||||
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
|
||||
const formattedDate = formatDateTime(date, "UTC", [], false, true);
|
||||
|
||||
return (
|
||||
<TooltipPortal active={active}>
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
|
||||
<div className="mt-2 text-xs text-text-bright">
|
||||
<span className="tabular-nums">{entry.count}</span>{" "}
|
||||
<span className="text-text-dimmed">
|
||||
{entry.count === 1 ? "occurrence" : "occurrences"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPortal>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function ErrorActivityBlankState() {
|
||||
return (
|
||||
<div className="flex h-6 w-28 items-end gap-px rounded-sm">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div key={i} className="h-full flex-1 bg-background-hover" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3ErrorsPath,
|
||||
v3ErrorsConnectToSlackPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const shouldReinstall = url.searchParams.get("reinstall") === "true";
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const integration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (integration && !shouldReinstall) {
|
||||
return redirectWithSuccessMessage(
|
||||
`${v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam })}?alerts`,
|
||||
request,
|
||||
"Successfully connected your Slack workspace"
|
||||
);
|
||||
}
|
||||
|
||||
return await OrgIntegrationRepository.redirectToAuthService(
|
||||
"SLACK",
|
||||
project.organizationId,
|
||||
request,
|
||||
v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam })
|
||||
);
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { Outlet, useNavigate } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { useCallback } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
import {
|
||||
ConfigureErrorAlerts,
|
||||
ErrorAlertsFormSchema,
|
||||
} from "~/components/errors/ConfigureErrorAlerts";
|
||||
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ErrorAlertChannelPresenter } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3ErrorsConnectToSlackPath,
|
||||
v3ErrorsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import {
|
||||
type CreateAlertChannelOptions,
|
||||
CreateAlertChannelService,
|
||||
} from "~/v3/services/alerts/createAlertChannel.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new ErrorAlertChannelPresenter();
|
||||
const alertData = await presenter.call(project.id, environment.type);
|
||||
|
||||
const connectToSlackHref = v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, {
|
||||
slug: envParam,
|
||||
});
|
||||
|
||||
const errorsPath = v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam });
|
||||
|
||||
return typedjson({
|
||||
alertData,
|
||||
projectRef: project.externalRef,
|
||||
projectId: project.id,
|
||||
environmentType: environment.type,
|
||||
connectToSlackHref,
|
||||
errorsPath,
|
||||
});
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ status: 405, error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema: ErrorAlertsFormSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const { emails, webhooks, slackChannel, slackIntegrationId } = submission.value;
|
||||
|
||||
const emailEnabled = env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
|
||||
const slackEnabled = !!slackIntegrationId;
|
||||
|
||||
const existingChannels = await prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
alertTypes: { has: "ERROR_GROUP" },
|
||||
environmentTypes: { has: environment.type },
|
||||
},
|
||||
});
|
||||
|
||||
const service = new CreateAlertChannelService();
|
||||
const environmentTypes = [environment.type];
|
||||
const processedChannelIds = new Set<string>();
|
||||
|
||||
if (emailEnabled) {
|
||||
for (const email of emails) {
|
||||
const options: CreateAlertChannelOptions = {
|
||||
name: `Error alert to ${email}`,
|
||||
alertTypes: ["ERROR_GROUP"],
|
||||
environmentTypes,
|
||||
deduplicationKey: `error-email:${email}:${environment.type}`,
|
||||
channel: { type: "EMAIL", email },
|
||||
};
|
||||
const channel = await service.call(project.externalRef, userId, options);
|
||||
processedChannelIds.add(channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (slackEnabled && slackChannel) {
|
||||
const [channelId, channelName] = slackChannel.split("/");
|
||||
if (channelId && channelName) {
|
||||
const options: CreateAlertChannelOptions = {
|
||||
name: `Error alert to #${channelName}`,
|
||||
alertTypes: ["ERROR_GROUP"],
|
||||
environmentTypes,
|
||||
deduplicationKey: `error-slack:${environment.type}`,
|
||||
channel: {
|
||||
type: "SLACK",
|
||||
channelId,
|
||||
channelName,
|
||||
integrationId: slackIntegrationId,
|
||||
},
|
||||
};
|
||||
const channel = await service.call(project.externalRef, userId, options);
|
||||
processedChannelIds.add(channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of webhooks) {
|
||||
const options: CreateAlertChannelOptions = {
|
||||
name: `Error alert to ${new URL(url).hostname}`,
|
||||
alertTypes: ["ERROR_GROUP"],
|
||||
environmentTypes,
|
||||
deduplicationKey: `error-webhook:${url}:${environment.type}`,
|
||||
channel: { type: "WEBHOOK", url },
|
||||
};
|
||||
try {
|
||||
const channel = await service.call(project.externalRef, userId, options);
|
||||
processedChannelIds.add(channel.id);
|
||||
} catch (error) {
|
||||
// CreateAlertChannelService rejects unsafe webhook URLs.
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json(submission.reply({ fieldErrors: { webhooks: [error.message] } }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const editableTypes = new Set<string>(["WEBHOOK"]);
|
||||
if (emailEnabled) {
|
||||
editableTypes.add("EMAIL");
|
||||
}
|
||||
if (slackEnabled) {
|
||||
editableTypes.add("SLACK");
|
||||
}
|
||||
|
||||
const channelsToDelete = existingChannels.filter(
|
||||
(ch) =>
|
||||
!processedChannelIds.has(ch.id) &&
|
||||
editableTypes.has(ch.type) &&
|
||||
ch.alertTypes.length === 1 &&
|
||||
ch.alertTypes[0] === "ERROR_GROUP"
|
||||
);
|
||||
|
||||
for (const ch of channelsToDelete) {
|
||||
await prisma.projectAlertChannel.delete({ where: { id: ch.id } });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { alertData, connectToSlackHref, errorsPath } = useTypedLoaderData<typeof loader>();
|
||||
const { has } = useSearchParams();
|
||||
const showAlerts = has("alerts") ?? false;
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
|
||||
const closeAlerts = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("alerts");
|
||||
const qs = params.toString();
|
||||
navigate(qs ? `?${qs}` : location.pathname, { replace: true });
|
||||
}, [location.search, location.pathname, navigate]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
|
||||
<Sheet open={showAlerts} onOpenChange={(open) => !open && closeAlerts()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[420px] min-w-[320px] max-w-[560px] p-0 sm:max-w-[560px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<ConfigureErrorAlerts
|
||||
{...alertData}
|
||||
connectToSlackHref={connectToSlackHref}
|
||||
formAction={errorsPath}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+889
@@ -0,0 +1,889 @@
|
||||
import { CheckIcon, BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { Gauge } from "lucide-react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector";
|
||||
import { AnimatedNumber } from "~/components/primitives/AnimatedNumber";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
LimitsPresenter,
|
||||
type FeatureInfo,
|
||||
type LimitsResult,
|
||||
type QuotaInfo,
|
||||
type RateLimitInfo,
|
||||
} from "~/presenters/v3/LimitsPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import {
|
||||
concurrencyPath,
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
organizationBillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Limits | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new LimitsPresenter();
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
environmentApiKey: environment.apiKey,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Response(error.message, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Match the queues page pattern: pass a poll interval from the loader
|
||||
const autoReloadPollIntervalMs = 5000;
|
||||
|
||||
return typedjson({
|
||||
...result,
|
||||
autoReloadPollIntervalMs,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-revalidate using the loader-provided interval and refresh on focus
|
||||
useAutoRevalidate({ interval: data.autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Limits" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Plan</Property.Label>
|
||||
<Property.Value>{data.planName ?? "No plan"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Organization ID</Property.Label>
|
||||
<Property.Value>{data.organizationId}</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/limits")}>
|
||||
Limits docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<div className="mx-auto mt-10 max-w-2xl p-4">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Current Plan Section */}
|
||||
{data.planName && (
|
||||
<CurrentPlanSection
|
||||
planName={data.planName}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Concurrency Section */}
|
||||
<ConcurrencySection
|
||||
concurrencyPath={concurrencyPath(organization, project, environment)}
|
||||
/>
|
||||
|
||||
{/* Rate Limits Section */}
|
||||
<RateLimitsSection
|
||||
rateLimits={data.rateLimits}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
/>
|
||||
|
||||
{/* Quotas Section */}
|
||||
<QuotasSection
|
||||
quotas={data.quotas}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
/>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection
|
||||
features={data.features}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentPlanSection({
|
||||
planName,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
}: {
|
||||
planName: string;
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1">
|
||||
<IconCardsFilled className="size-5 text-amber-400" />
|
||||
Current plan
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="w-full text-sm">{planName}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{isOnTopPlan ? (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request Enterprise</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
) : showSelfServe ? (
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1.5">
|
||||
<ConcurrencyIcon className="size-5 text-orange-500" />
|
||||
Concurrency limits
|
||||
<InfoIconTooltip
|
||||
content="Concurrency limits control how many runs execute at the same time."
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="w-full text-sm">Concurrency</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<LinkButton to={concurrencyPath} variant="secondary/small">
|
||||
Manage concurrency
|
||||
</LinkButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitsSection({
|
||||
rateLimits,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
}: {
|
||||
rateLimits: LimitsResult["rateLimits"];
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
organization: ReturnType<typeof useOrganization>;
|
||||
project: ReturnType<typeof useProject>;
|
||||
environment: ReturnType<typeof useEnvironment>;
|
||||
}) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2 className="flex items-center gap-1.5">
|
||||
<Gauge className="size-5 text-indigo-500" />
|
||||
Rate limits
|
||||
<InfoIconTooltip
|
||||
content="Rate limits control how many API requests can be made within a time window. They are tracked per API key."
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Header2>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Rate limit</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Type
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<div className="flex max-w-xs flex-col gap-3 py-1">
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<RateLimitTypeBadge type="tokenBucket" />
|
||||
<span className="pl-0.5 text-text-bright">
|
||||
Requests consume tokens from a bucket that refills over time. When empty,
|
||||
requests are rate limited.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<RateLimitTypeBadge type="fixedWindow" />
|
||||
<span className="pl-0.5 text-text-bright">
|
||||
Allows a set number of requests per time window. The window resets at
|
||||
fixed intervals.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<RateLimitTypeBadge type="slidingWindow" />
|
||||
<span className="pl-0.5 text-text-bright">
|
||||
Allows a set number of requests per rolling time window. The limit is
|
||||
continuously evaluated.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Configuration</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Available
|
||||
<InfoIconTooltip
|
||||
content="Current available tokens for this environment's API key"
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<RateLimitRow
|
||||
info={rateLimits.api}
|
||||
isOnTopPlan={isOnTopPlan}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
<RateLimitRow
|
||||
info={rateLimits.batch}
|
||||
isOnTopPlan={isOnTopPlan}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitRow({
|
||||
info,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
showSelfServe,
|
||||
}: {
|
||||
info: RateLimitInfo;
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens;
|
||||
const percentage =
|
||||
info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
{info.name}
|
||||
<InfoIconTooltip content={info.description} disableHoverableContent />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<div className="flex justify-end">
|
||||
<RateLimitTypeBadge config={info.config} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<RateLimitConfigDisplay config={info.config} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{info.currentTokens !== null ? (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
getUsageColorClass(percentage, "remaining")
|
||||
)}
|
||||
>
|
||||
<AnimatedNumber value={info.currentTokens} />
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-text-dimmed">
|
||||
of {formatNumber(maxTokens)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{showSelfServe ? (
|
||||
<TableCell alignment="right">
|
||||
<div className="flex justify-end">
|
||||
{info.name === "Batch rate limit" ? (
|
||||
isOnTopPlan ? (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
) : (
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
)
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitTypeBadge({
|
||||
config,
|
||||
type,
|
||||
}: {
|
||||
config?: RateLimitInfo["config"];
|
||||
type?: "tokenBucket" | "fixedWindow" | "slidingWindow";
|
||||
}) {
|
||||
const rateLimitType = type ?? config?.type;
|
||||
switch (rateLimitType) {
|
||||
case "tokenBucket":
|
||||
return (
|
||||
<Badge variant="small" className="w-fit bg-blue-500/20 text-blue-400">
|
||||
Token bucket
|
||||
</Badge>
|
||||
);
|
||||
case "fixedWindow":
|
||||
return (
|
||||
<Badge variant="small" className="w-fit bg-purple-500/20 text-purple-400">
|
||||
Fixed window
|
||||
</Badge>
|
||||
);
|
||||
case "slidingWindow":
|
||||
return (
|
||||
<Badge variant="small" className="w-fit bg-green-500/20 text-green-400">
|
||||
Sliding window
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) {
|
||||
if (config.type === "tokenBucket") {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs">
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Max tokens:</span>{" "}
|
||||
<span className="font-medium text-text-bright">{formatNumber(config.maxTokens)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Refill:</span>{" "}
|
||||
<span className="font-medium text-text-bright">
|
||||
{formatNumber(config.refillRate)}/{config.interval}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === "fixedWindow" || config.type === "slidingWindow") {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs">
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Tokens:</span>{" "}
|
||||
<span className="font-medium text-text-bright">{formatNumber(config.tokens)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Window:</span>{" "}
|
||||
<span className="font-medium text-text-bright">{config.window}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
|
||||
function QuotasSection({
|
||||
quotas,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
}: {
|
||||
quotas: LimitsResult["quotas"];
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
// Collect all quotas that should be shown
|
||||
const quotaRows: QuotaInfo[] = [];
|
||||
|
||||
// Always show projects
|
||||
quotaRows.push(quotas.projects);
|
||||
|
||||
// Add plan-based quotas if they exist
|
||||
if (quotas.teamMembers) quotaRows.push(quotas.teamMembers);
|
||||
if (quotas.schedules) quotaRows.push(quotas.schedules);
|
||||
if (quotas.alerts) quotaRows.push(quotas.alerts);
|
||||
if (quotas.branches) quotaRows.push(quotas.branches);
|
||||
if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections);
|
||||
if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays);
|
||||
|
||||
// Include batch processing concurrency
|
||||
quotaRows.push(quotas.batchProcessingConcurrency);
|
||||
|
||||
// Add queue size quota if set
|
||||
if (quotas.queueSize.limit !== null) quotaRows.push(quotas.queueSize);
|
||||
|
||||
// Metric & query quotas
|
||||
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
|
||||
if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard);
|
||||
if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays);
|
||||
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1">
|
||||
<IconTallymark4 className="size-6 text-blue-500" />
|
||||
Quotas
|
||||
<InfoIconTooltip
|
||||
content="Quotas define the maximum resources available to your organization."
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Quota</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Current</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Source</TableHeaderCell>
|
||||
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{quotaRows.map((quota) => (
|
||||
<QuotaRow
|
||||
key={quota.name}
|
||||
quota={quota}
|
||||
isOnTopPlan={isOnTopPlan}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaRow({
|
||||
quota,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
showSelfServe,
|
||||
}: {
|
||||
quota: QuotaInfo;
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
// For log retention and query period, we don't show current usage as it's a duration, not a count
|
||||
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
|
||||
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
|
||||
const isPerItemQuota = quota.name === "Charts per dashboard";
|
||||
const isRetentionQuota = isDurationQuota || isPerItemQuota;
|
||||
const isQueueSizeQuota = quota.name === "Max queued runs";
|
||||
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;
|
||||
|
||||
const percentage =
|
||||
!hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
|
||||
|
||||
// Special handling for duration-based quotas (Log retention, Query period)
|
||||
if (isDurationQuota) {
|
||||
const canUpgrade = !isOnTopPlan;
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="flex w-full items-center gap-1">
|
||||
<span className="text-sm">{quota.name}</span>
|
||||
<InfoIconTooltip content={quota.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="font-medium tabular-nums">
|
||||
{quota.limit !== null
|
||||
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
|
||||
–
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<SourceBadge source={quota.source} />
|
||||
</TableCell>
|
||||
{showSelfServe ? (
|
||||
<TableCell alignment="right">
|
||||
<div className="flex justify-end">
|
||||
{canUpgrade ? (
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const renderUpgrade = () => {
|
||||
// Projects always show Contact us (regardless of upgrade flags)
|
||||
if (quota.name === "Projects") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quota.isUpgradable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not on top plan - show View plans
|
||||
if (!isOnTopPlan) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On top plan - show Contact us if canExceed is true
|
||||
if (quota.canExceed) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On top plan but cannot exceed - no upgrade option
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="flex w-full items-center gap-1">
|
||||
<span className="text-sm">{quota.name}</span>
|
||||
<InfoIconTooltip content={quota.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="font-medium tabular-nums">
|
||||
{quota.limit !== null
|
||||
? isDurationQuota
|
||||
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
|
||||
: formatNumber(quota.limit)
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
hideCurrentUsage ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
|
||||
)}
|
||||
>
|
||||
{hideCurrentUsage ? "–" : formatNumber(quota.currentUsage)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<SourceBadge source={quota.source} />
|
||||
</TableCell>
|
||||
{showSelfServe ? <TableCell alignment="right">{renderUpgrade()}</TableCell> : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesSection({
|
||||
features,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
}: {
|
||||
features: LimitsResult["features"];
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
// For staging environment: show View plans if not enabled (i.e., on Free plan)
|
||||
const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans";
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1.5">
|
||||
<IconDiamondFilled className="size-5 text-green-600" />
|
||||
Plan features
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Feature</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Status</TableHeaderCell>
|
||||
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<FeatureRow
|
||||
feature={features.hasStagingEnvironment}
|
||||
upgradeType={stagingUpgradeType}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature={features.support}
|
||||
upgradeType={isOnTopPlan ? "contact-us" : "view-plans"}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature={features.includedUsage}
|
||||
upgradeType={isOnTopPlan ? "contact-us" : "view-plans"}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureRow({
|
||||
feature,
|
||||
upgradeType,
|
||||
billingPath,
|
||||
showSelfServe,
|
||||
}: {
|
||||
feature: FeatureInfo;
|
||||
upgradeType: "view-plans" | "contact-us" | "none";
|
||||
billingPath: string;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
const displayValue = () => {
|
||||
if (feature.name === "Included compute" && typeof feature.value === "number") {
|
||||
if (!feature.enabled || feature.value === 0) {
|
||||
return <span className="text-text-dimmed">None</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-medium text-text-bright">${formatNumber(feature.value / 100)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (feature.value !== undefined) {
|
||||
return <span className="font-medium text-text-bright">{feature.value}</span>;
|
||||
}
|
||||
|
||||
return feature.enabled ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<CheckIcon className="size-4" />
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-text-dimmed">Not available</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpgrade = () => {
|
||||
switch (upgradeType) {
|
||||
case "view-plans":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
case "contact-us":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="flex w-full items-center gap-1">
|
||||
<span className="text-sm">{feature.name}</span>
|
||||
<InfoIconTooltip content={feature.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{displayValue()}</TableCell>
|
||||
{showSelfServe ? <TableCell alignment="right">{renderUpgrade()}</TableCell> : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate color class based on usage percentage.
|
||||
* @param percentage - The usage percentage (0-1 scale)
|
||||
* @param mode - "usage" means higher is worse (quotas), "remaining" means lower is worse (rate limits)
|
||||
* @returns Tailwind color class
|
||||
*/
|
||||
function getUsageColorClass(
|
||||
percentage: number | null,
|
||||
mode: "usage" | "remaining" = "usage"
|
||||
): string {
|
||||
if (percentage === null) return "text-text-dimmed";
|
||||
|
||||
if (mode === "remaining") {
|
||||
// For remaining tokens: 0 = bad (red), <=10% = warning (orange)
|
||||
if (percentage <= 0) return "text-error";
|
||||
if (percentage <= 0.1) return "text-warning";
|
||||
return "text-text-bright";
|
||||
} else {
|
||||
// For usage: 100% = bad (red), >=90% = warning (orange)
|
||||
if (percentage >= 1) return "text-error";
|
||||
if (percentage >= 0.9) return "text-warning";
|
||||
return "text-text-bright";
|
||||
}
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: "default" | "plan" | "override" }) {
|
||||
const variants: Record<typeof source, { label: string; className: string }> = {
|
||||
default: {
|
||||
label: "Default",
|
||||
className: "bg-indigo-500/20 text-indigo-400",
|
||||
},
|
||||
plan: {
|
||||
label: "Plan",
|
||||
className: "bg-purple-500/20 text-purple-400",
|
||||
},
|
||||
override: {
|
||||
label: "Override",
|
||||
className: "bg-amber-500/20 text-amber-400",
|
||||
},
|
||||
};
|
||||
|
||||
const variant = variants[source];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
variant.className
|
||||
)}
|
||||
>
|
||||
{variant.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react";
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import {
|
||||
TypedAwait,
|
||||
typeddefer,
|
||||
type UseDataFunctionReturn,
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import type { LogLevel } from "~/utils/logUtils";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { LogsTable } from "~/components/logs/LogsTable";
|
||||
import { LogDetailView } from "~/components/logs/LogDetailView";
|
||||
import { SearchInput } from "~/components/primitives/SearchInput";
|
||||
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
collapsibleHandleClassName,
|
||||
useFrozenValue,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags";
|
||||
|
||||
// Valid log levels for filtering
|
||||
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
|
||||
if (levelParams.length === 0) return undefined;
|
||||
return levelParams.filter((l): l is LogLevel => validLevels.includes(l as LogLevel));
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Logs | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// TODO: Move this to a more appropriate shared location
|
||||
async function hasLogsPageAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
isImpersonating: boolean,
|
||||
organizationSlug: string
|
||||
): Promise<boolean> {
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check organization feature flags
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization?.featureFlags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flags = organization.featureFlags as Record<string, unknown>;
|
||||
const hasLogsPageAccessResult = validateFeatureFlagValue(
|
||||
FEATURE_FLAG.hasLogsPageAccess,
|
||||
flags.hasLogsPageAccess
|
||||
);
|
||||
|
||||
return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true;
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasLogsPageAccess(
|
||||
userId,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Get filters from query params
|
||||
const url = new URL(request.url);
|
||||
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
|
||||
const runId = url.searchParams.get("runId") ?? undefined;
|
||||
const search = url.searchParams.get("search") ?? undefined;
|
||||
const levels = parseLevelsFromUrl(url);
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
const from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
const to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
|
||||
// Get the user's plan to determine log retention limit
|
||||
const plan = await getCurrentPlan(project.organizationId);
|
||||
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
|
||||
|
||||
const logsClickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"logs"
|
||||
);
|
||||
const presenter = new LogsListPresenter($replica, logsClickhouse);
|
||||
|
||||
const listPromise = presenter
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
runId,
|
||||
search,
|
||||
levels,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
defaultPeriod: "1h",
|
||||
retentionLimitDays,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return typeddefer({
|
||||
data: listPromise,
|
||||
defaultPeriod: "1h",
|
||||
retentionLimitDays,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data, defaultPeriod, retentionLimitDays } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Logs" />
|
||||
</NavBar>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
|
||||
<div className="border-b border-grid-bright" />
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading logs…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} />
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your logs. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(result) => {
|
||||
// Check if result contains an error
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
list={result}
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
/>
|
||||
<LogsList list={result} defaultPeriod={defaultPeriod} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersBar({
|
||||
list,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
}: {
|
||||
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
|
||||
defaultPeriod?: string;
|
||||
retentionLimitDays: number;
|
||||
}) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("runId") ||
|
||||
searchParams.has("search") ||
|
||||
searchParams.has("levels") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to");
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
{list ? (
|
||||
<>
|
||||
<SearchInput />
|
||||
<LogsTaskFilter possibleTasks={list.possibleTasks} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
|
||||
<LogsLevelFilter />
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogsTaskFilter possibleTasks={[]} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
|
||||
<LogsLevelFilter />
|
||||
<SearchInput />
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsList({
|
||||
list,
|
||||
}: {
|
||||
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
|
||||
defaultPeriod?: string;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
const fetcher = useFetcher<{ logs: LogEntry[]; pagination: { next?: string } }>();
|
||||
const [, startTransition] = useTransition();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
|
||||
// Accumulated logs state
|
||||
const [accumulatedLogs, setAccumulatedLogs] = useState<LogEntry[]>(list.logs);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(list.pagination.next);
|
||||
|
||||
// Selected log state - managed locally to avoid triggering navigation
|
||||
const [selectedLogId, setSelectedLogId] = useState<string | undefined>(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("log") ?? undefined;
|
||||
});
|
||||
|
||||
// Track which filter state (search params) the current fetcher request corresponds to
|
||||
const fetcherFilterStateRef = useRef<string>(location.search);
|
||||
// Track whether the current fetch is a "check for new" request vs "load more"
|
||||
const isCheckingForNewRef = useRef<boolean>(false);
|
||||
|
||||
// Clear accumulated logs immediately when filters change (for instant visual feedback)
|
||||
useEffect(() => {
|
||||
setAccumulatedLogs([]);
|
||||
setNextCursor(undefined);
|
||||
// Preserve log selection from URL param, clear if not present
|
||||
const params = new URLSearchParams(location.search);
|
||||
setSelectedLogId(params.get("log") ?? undefined);
|
||||
}, [location.search]);
|
||||
|
||||
// Populate accumulated logs when new data arrives
|
||||
useEffect(() => {
|
||||
setAccumulatedLogs(list.logs);
|
||||
setNextCursor(list.pagination.next);
|
||||
}, [list.logs, list.pagination.next]);
|
||||
|
||||
// Clear log parameter from URL when selectedLogId is cleared
|
||||
useEffect(() => {
|
||||
if (!selectedLogId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("log")) {
|
||||
url.searchParams.delete("log");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}
|
||||
}, [selectedLogId]);
|
||||
|
||||
// Append/prepend new logs when fetcher completes (with deduplication)
|
||||
useEffect(() => {
|
||||
if (fetcher.data && fetcher.state === "idle") {
|
||||
// Ignore fetcher data if it was loaded for a different filter state
|
||||
if (fetcherFilterStateRef.current !== location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCheckingForNewRef.current) {
|
||||
// "Check for new" - prepend new logs, don't update cursor
|
||||
setAccumulatedLogs((prev) => {
|
||||
const existingIds = new Set(prev.map((log) => log.id));
|
||||
const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id));
|
||||
return newLogs.length > 0 ? [...newLogs, ...prev] : prev;
|
||||
});
|
||||
isCheckingForNewRef.current = false;
|
||||
} else {
|
||||
// "Load more" - append logs and update cursor
|
||||
setAccumulatedLogs((prev) => {
|
||||
const existingIds = new Set(prev.map((log) => log.id));
|
||||
const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id));
|
||||
return newLogs.length > 0 ? [...prev, ...newLogs] : prev;
|
||||
});
|
||||
setNextCursor(fetcher.data.pagination.next);
|
||||
}
|
||||
}
|
||||
}, [fetcher.data, fetcher.state, location.search]);
|
||||
|
||||
// Build resource URL for loading more
|
||||
const loadMoreUrl = useMemo(() => {
|
||||
if (!nextCursor) return null;
|
||||
const resourcePath = `/resources${location.pathname}`;
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("cursor", nextCursor);
|
||||
params.delete("log");
|
||||
return `${resourcePath}?${params.toString()}`;
|
||||
}, [location.pathname, location.search, nextCursor]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (loadMoreUrl && fetcher.state === "idle") {
|
||||
// Store the current filter state before loading
|
||||
fetcherFilterStateRef.current = location.search;
|
||||
fetcher.load(loadMoreUrl);
|
||||
}
|
||||
}, [loadMoreUrl, fetcher, location.search]);
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogId) return undefined;
|
||||
return accumulatedLogs.find((log) => log.id === selectedLogId);
|
||||
}, [selectedLogId, accumulatedLogs]);
|
||||
|
||||
const frozenLogId = useFrozenValue(selectedLogId);
|
||||
const frozenLog = useFrozenValue(selectedLog);
|
||||
const displayLogId = selectedLogId ?? frozenLogId;
|
||||
const displayLog = selectedLog ?? frozenLog ?? undefined;
|
||||
|
||||
const updateUrlWithLog = useCallback((logId: string | undefined) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (logId) {
|
||||
url.searchParams.set("log", logId);
|
||||
} else {
|
||||
url.searchParams.delete("log");
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}, []);
|
||||
|
||||
const handleLogSelect = useCallback(
|
||||
(logId: string) => {
|
||||
startTransition(() => {
|
||||
setSelectedLogId(logId);
|
||||
});
|
||||
updateUrlWithLog(logId);
|
||||
},
|
||||
[updateUrlWithLog, startTransition]
|
||||
);
|
||||
|
||||
const handleClosePanel = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSelectedLogId(undefined);
|
||||
});
|
||||
updateUrlWithLog(undefined);
|
||||
}, [updateUrlWithLog, startTransition]);
|
||||
|
||||
const handleCheckForMore = useCallback(() => {
|
||||
if (fetcher.state !== "idle") return;
|
||||
// Fetch without cursor to check for new logs
|
||||
const resourcePath = `/resources${location.pathname}`;
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("cursor");
|
||||
params.delete("log");
|
||||
fetcherFilterStateRef.current = location.search;
|
||||
isCheckingForNewRef.current = true;
|
||||
fetcher.load(`${resourcePath}?${params.toString()}`);
|
||||
}, [fetcher, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="logs-main" min="200px">
|
||||
<LogsTable
|
||||
key={location.search}
|
||||
logs={accumulatedLogs}
|
||||
searchTerm={list.searchTerm}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={fetcher.state === "loading"}
|
||||
hasMore={!!nextCursor}
|
||||
onLoadMore={handleLoadMore}
|
||||
onCheckForMore={handleCheckForMore}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogSelect={handleLogSelect}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="logs-handle" className={collapsibleHandleClassName(!!selectedLogId)} />
|
||||
<ResizablePanel
|
||||
id="log-detail"
|
||||
default="430px"
|
||||
min="430px"
|
||||
max="600px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!selectedLogId}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 430 }}>
|
||||
{displayLogId && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LogDetailView
|
||||
logId={displayLogId}
|
||||
initialLog={displayLog}
|
||||
onClose={handleClosePanel}
|
||||
searchTerm={list.searchTerm}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardKey: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug, projectParam, envParam, dashboardKey } = ParamSchema.parse(params);
|
||||
return redirect(
|
||||
v3BuiltInDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
dashboardKey
|
||||
),
|
||||
301
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug, projectParam, envParam, dashboardId } = ParamSchema.parse(params);
|
||||
return redirect(
|
||||
v3CustomDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: dashboardId }
|
||||
),
|
||||
301
|
||||
);
|
||||
};
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { ModelRegistryPresenter } from "~/presenters/v3/ModelRegistryPresenter.server";
|
||||
import { MetricWidget } from "~/routes/resources.metric";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
formatFeature,
|
||||
formatModelCost,
|
||||
formatModelPrice,
|
||||
formatProviderName,
|
||||
formatTokenCount,
|
||||
} from "~/utils/modelFormatters";
|
||||
import { EnvironmentParamSchema, v3ModelComparePath, v3ModelsPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Model Detail | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam, modelId } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new ModelRegistryPresenter(clickhouse);
|
||||
const model = await presenter.getModelDetail(modelId);
|
||||
|
||||
if (!model) {
|
||||
throw new Response("Model not found", { status: 404 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const userMetrics = await presenter.getUserMetrics(
|
||||
model.modelName,
|
||||
project.id,
|
||||
environment.id,
|
||||
sevenDaysAgo,
|
||||
now
|
||||
);
|
||||
|
||||
return typedjson({
|
||||
model,
|
||||
userMetrics,
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
};
|
||||
|
||||
/** Escape a value for safe interpolation into a TSQL single-quoted string. */
|
||||
function escapeTSQL(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function bignumberConfig(
|
||||
column: string,
|
||||
opts?: { aggregation?: "sum" | "avg" | "first"; suffix?: string; abbreviate?: boolean }
|
||||
): QueryWidgetConfig {
|
||||
return {
|
||||
type: "bignumber",
|
||||
column,
|
||||
aggregation: opts?.aggregation ?? "sum",
|
||||
abbreviate: opts?.abbreviate ?? false,
|
||||
suffix: opts?.suffix,
|
||||
};
|
||||
}
|
||||
|
||||
function chartConfig(opts: {
|
||||
chartType: "bar" | "line";
|
||||
xAxisColumn: string;
|
||||
yAxisColumns: string[];
|
||||
aggregation?: "sum" | "avg";
|
||||
}): QueryWidgetConfig {
|
||||
return {
|
||||
type: "chart",
|
||||
chartType: opts.chartType,
|
||||
xAxisColumn: opts.xAxisColumn,
|
||||
yAxisColumns: opts.yAxisColumns,
|
||||
groupByColumn: null,
|
||||
stacked: false,
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: opts.aggregation ?? "sum",
|
||||
};
|
||||
}
|
||||
|
||||
type Tab = "overview" | "global" | "usage";
|
||||
|
||||
const TAB_CONFIG: { id: Tab; label: string }[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "usage", label: "Metrics" },
|
||||
{ id: "global", label: "Global metrics" },
|
||||
];
|
||||
|
||||
export default function ModelDetailPage() {
|
||||
const { model, userMetrics, organizationId, projectId, environmentId } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("overview");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
title={model.displayId}
|
||||
backButton={{
|
||||
to: v3ModelsPath(organization, project, environment),
|
||||
text: "Models",
|
||||
}}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant="tertiary/small"
|
||||
to={`${v3ModelComparePath(organization, project, environment)}?models=${model.modelName}`}
|
||||
LeadingIcon={ArrowsRightLeftIcon}
|
||||
>
|
||||
Compare with...
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable>
|
||||
<TabContainer>
|
||||
{TAB_CONFIG.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
isActive={activeTab === tab.id}
|
||||
layoutId="model-detail-tabs"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</TabButton>
|
||||
))}
|
||||
</TabContainer>
|
||||
|
||||
<div className="p-4">
|
||||
{activeTab === "overview" && <OverviewTab model={model} userMetrics={userMetrics} />}
|
||||
{activeTab === "global" && (
|
||||
<GlobalMetricsTab
|
||||
modelName={model.modelName}
|
||||
organizationId={organizationId}
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "usage" && (
|
||||
<YourUsageTab
|
||||
modelName={model.modelName}
|
||||
organizationId={organizationId}
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Cost Estimator ---
|
||||
|
||||
function CostEstimator({
|
||||
inputPrice,
|
||||
outputPrice,
|
||||
defaultInputTokens,
|
||||
defaultOutputTokens,
|
||||
}: {
|
||||
inputPrice: number | null;
|
||||
outputPrice: number | null;
|
||||
defaultInputTokens?: number;
|
||||
defaultOutputTokens?: number;
|
||||
}) {
|
||||
const [inputTokens, setInputTokens] = useState(defaultInputTokens ?? 1000);
|
||||
const [outputTokens, setOutputTokens] = useState(defaultOutputTokens ?? 500);
|
||||
const [numCalls, setNumCalls] = useState(1000);
|
||||
|
||||
if (inputPrice === null && outputPrice === null) return null;
|
||||
|
||||
const inputCost = inputTokens * (inputPrice ?? 0) * numCalls;
|
||||
const outputCost = outputTokens * (outputPrice ?? 0) * numCalls;
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-grid-dimmed p-4">
|
||||
<Header2 className="mb-3">Cost Estimator</Header2>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="input-tokens">Input tokens/call</Label>
|
||||
<Input
|
||||
id="input-tokens"
|
||||
variant="medium"
|
||||
fullWidth
|
||||
type="number"
|
||||
value={inputTokens}
|
||||
onChange={(e) => setInputTokens(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="output-tokens">Output tokens/call</Label>
|
||||
<Input
|
||||
id="output-tokens"
|
||||
variant="medium"
|
||||
fullWidth
|
||||
type="number"
|
||||
value={outputTokens}
|
||||
onChange={(e) => setOutputTokens(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="num-calls">Number of calls</Label>
|
||||
<Input
|
||||
id="num-calls"
|
||||
variant="medium"
|
||||
fullWidth
|
||||
type="number"
|
||||
value={numCalls}
|
||||
onChange={(e) => setNumCalls(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Callout variant="info">
|
||||
<div className="text-lg font-semibold tabular-nums text-text-bright">
|
||||
{formatModelCost(totalCost)}
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-xs tabular-nums text-text-dimmed">
|
||||
<div>
|
||||
Input: {formatModelCost(inputCost)} ({formatTokenCount(inputTokens * numCalls)} tokens
|
||||
x {formatModelPrice(inputPrice)}/1M)
|
||||
</div>
|
||||
<div>
|
||||
Output: {formatModelCost(outputCost)} ({formatTokenCount(outputTokens * numCalls)}{" "}
|
||||
tokens x {formatModelPrice(outputPrice)}/1M)
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Overview Tab ---
|
||||
|
||||
function OverviewTab({
|
||||
model,
|
||||
userMetrics,
|
||||
}: {
|
||||
model: ReturnType<typeof useTypedLoaderData<typeof loader>>["model"];
|
||||
userMetrics: ReturnType<typeof useTypedLoaderData<typeof loader>>["userMetrics"];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Model Info */}
|
||||
<div className="rounded-md border border-grid-dimmed p-4">
|
||||
<Header2 className="mb-3">Model Info</Header2>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Provider</Property.Label>
|
||||
<Property.Value>{formatProviderName(model.provider)}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Model Name</Property.Label>
|
||||
<Property.Value>
|
||||
<InlineCode variant="small">{model.modelName}</InlineCode>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{model.description && (
|
||||
<Property.Item>
|
||||
<Property.Label>Description</Property.Label>
|
||||
<Property.Value>{model.description}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{model.contextWindow && (
|
||||
<Property.Item>
|
||||
<Property.Label>Context Window</Property.Label>
|
||||
<Property.Value>{formatTokenCount(model.contextWindow)} tokens</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{model.maxOutputTokens && (
|
||||
<Property.Item>
|
||||
<Property.Label>Max Output</Property.Label>
|
||||
<Property.Value>{formatTokenCount(model.maxOutputTokens)} tokens</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{model.features.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Features</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{model.features.map((f) => (
|
||||
<Badge key={f} variant="outline-rounded">
|
||||
{formatFeature(f)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Match Pattern</Property.Label>
|
||||
<Property.Value>
|
||||
<InlineCode variant="small">{model.matchPattern}</InlineCode>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="rounded-md border border-grid-dimmed p-4">
|
||||
<Header2 className="mb-3">Pricing</Header2>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Input</Property.Label>
|
||||
<Property.Value>{formatModelPrice(model.inputPrice)} / 1M tokens</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Output</Property.Label>
|
||||
<Property.Value>{formatModelPrice(model.outputPrice)} / 1M tokens</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
{model.pricingTiers.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-xs font-medium text-text-dimmed">All pricing tiers</p>
|
||||
{model.pricingTiers.map((tier) => (
|
||||
<div key={tier.name} className="mb-2 rounded border border-grid-dimmed p-2 text-xs">
|
||||
<span className="font-medium text-text-bright">{tier.name}</span>
|
||||
{tier.isDefault && (
|
||||
<Badge variant="outline-rounded" className="ml-2">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mt-1 space-y-0.5 text-text-dimmed">
|
||||
{Object.entries(tier.prices).map(([usage, price]) => (
|
||||
<div key={usage}>
|
||||
{usage}: ${(price * 1_000_000).toFixed(4)} / 1M
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Estimator */}
|
||||
<CostEstimator
|
||||
inputPrice={model.inputPrice}
|
||||
outputPrice={model.outputPrice}
|
||||
defaultInputTokens={
|
||||
userMetrics.totalCalls > 0
|
||||
? Math.round(userMetrics.totalInputTokens / userMetrics.totalCalls)
|
||||
: undefined
|
||||
}
|
||||
defaultOutputTokens={
|
||||
userMetrics.totalCalls > 0
|
||||
? Math.round(userMetrics.totalOutputTokens / userMetrics.totalCalls)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Global Metrics Tab ---
|
||||
|
||||
function GlobalMetricsTab({
|
||||
modelName,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
}: {
|
||||
modelName: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
}) {
|
||||
const widgetProps = {
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
scope: "environment" as const,
|
||||
period: "7d",
|
||||
from: null,
|
||||
to: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Big numbers */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-ttfc-p50`}
|
||||
title="p50 TTFC"
|
||||
query={`SELECT round(quantilesMerge(0.5)(ttfc_quantiles)[1], 0) AS ttfc_p50 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
|
||||
config={bignumberConfig("ttfc_p50", { aggregation: "avg", suffix: "ms" })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-ttfc-p90`}
|
||||
title="p90 TTFC"
|
||||
query={`SELECT round(quantilesMerge(0.9)(ttfc_quantiles)[1], 0) AS ttfc_p90 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
|
||||
config={bignumberConfig("ttfc_p90", { aggregation: "avg", suffix: "ms" })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-tps`}
|
||||
title="Tokens/sec (p50)"
|
||||
query={`SELECT round(quantilesMerge(0.5)(tps_quantiles)[1], 0) AS tps_p50 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
|
||||
config={bignumberConfig("tps_p50", { aggregation: "avg" })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="h-[300px]">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-ttfc-time`}
|
||||
title="TTFC over time"
|
||||
query={`SELECT timeBucket(), round(quantilesMerge(0.5)(ttfc_quantiles)[1], 0) AS ttfc_p50, round(quantilesMerge(0.9)(ttfc_quantiles)[1], 0) AS ttfc_p90 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
|
||||
config={chartConfig({
|
||||
chartType: "line",
|
||||
xAxisColumn: "timebucket",
|
||||
yAxisColumns: ["ttfc_p50", "ttfc_p90"],
|
||||
aggregation: "avg",
|
||||
})}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout variant="info">
|
||||
Aggregated across all Trigger.dev users. No tenant-specific data is exposed.
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Your Usage Tab ---
|
||||
|
||||
function YourUsageTab({
|
||||
modelName,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
}: {
|
||||
modelName: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
}) {
|
||||
const widgetProps = {
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
scope: "environment" as const,
|
||||
period: "7d",
|
||||
from: null,
|
||||
to: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Big numbers */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-calls`}
|
||||
title="Total calls (7d)"
|
||||
query={`SELECT count() AS total_calls FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}'`}
|
||||
config={bignumberConfig("total_calls", { abbreviate: true })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-cost`}
|
||||
title="Total cost (7d)"
|
||||
query={`SELECT sum(total_cost) AS total_cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}'`}
|
||||
config={bignumberConfig("total_cost", { aggregation: "sum" })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-ttfc`}
|
||||
title="Avg TTFC"
|
||||
query={`SELECT round(avg(ms_to_first_chunk), 0) AS avg_ttfc FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' AND ms_to_first_chunk > 0`}
|
||||
config={bignumberConfig("avg_ttfc", { aggregation: "avg", suffix: "ms" })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-24">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-tps`}
|
||||
title="Avg Tokens/sec"
|
||||
query={`SELECT round(avg(tokens_per_second), 0) AS avg_tps FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' AND tokens_per_second > 0`}
|
||||
config={bignumberConfig("avg_tps", { aggregation: "avg" })}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div className="h-[300px]">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-cost-time`}
|
||||
title="Cost over time"
|
||||
query={`SELECT timeBucket(), sum(total_cost) AS cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
|
||||
config={chartConfig({
|
||||
chartType: "bar",
|
||||
xAxisColumn: "timebucket",
|
||||
yAxisColumns: ["cost"],
|
||||
})}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-tokens-time`}
|
||||
title="Tokens over time"
|
||||
query={`SELECT timeBucket(), sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
|
||||
config={chartConfig({
|
||||
chartType: "bar",
|
||||
xAxisColumn: "timebucket",
|
||||
yAxisColumns: ["input_tokens", "output_tokens"],
|
||||
})}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task breakdown */}
|
||||
<div className="h-[300px]">
|
||||
<MetricWidget
|
||||
widgetKey={`${modelName}-user-tasks`}
|
||||
title="Cost by task"
|
||||
query={`SELECT task_identifier, count() AS calls, sum(total_cost) AS cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY task_identifier ORDER BY cost DESC LIMIT 20`}
|
||||
config={{ type: "table", prettyFormatting: true, sorting: [] }}
|
||||
{...widgetProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1622
File diff suppressed because it is too large
Load Diff
+223
@@ -0,0 +1,223 @@
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type ModelComparisonItem,
|
||||
ModelRegistryPresenter,
|
||||
} from "~/presenters/v3/ModelRegistryPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { EnvironmentParamSchema, v3ModelsPath } from "~/utils/pathBuilder";
|
||||
import { formatModelCost } from "~/utils/modelFormatters";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Compare Models | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const modelsParam = url.searchParams.get("models") ?? "";
|
||||
const responseModels = modelsParam.split(",").filter(Boolean).slice(0, 4);
|
||||
|
||||
if (responseModels.length < 2) {
|
||||
return typedjson({ comparison: [] as ModelComparisonItem[], models: responseModels });
|
||||
}
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new ModelRegistryPresenter(clickhouse);
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const comparison = await presenter.getModelComparison(responseModels, sevenDaysAgo, now);
|
||||
|
||||
return typedjson({ comparison, models: responseModels });
|
||||
};
|
||||
|
||||
type ComparisonRow = {
|
||||
label: string;
|
||||
values: string[];
|
||||
bestIndex?: number;
|
||||
};
|
||||
|
||||
function buildRows(models: string[], comparison: ModelComparisonItem[]): ComparisonRow[] {
|
||||
const dataMap = new Map<string, ModelComparisonItem>();
|
||||
for (const item of comparison) {
|
||||
dataMap.set(item.responseModel, item);
|
||||
}
|
||||
|
||||
const getValue = (model: string, key: keyof ModelComparisonItem) => {
|
||||
const d = dataMap.get(model);
|
||||
return d ? d[key] : 0;
|
||||
};
|
||||
|
||||
const findBest = (values: number[], lowerIsBetter: boolean) => {
|
||||
if (values.every((v) => v === 0)) return undefined;
|
||||
const filtered = values.map((v, i) => ({ v, i })).filter(({ v }) => v > 0);
|
||||
if (filtered.length === 0) return undefined;
|
||||
const best = lowerIsBetter
|
||||
? filtered.reduce((a, b) => (a.v < b.v ? a : b))
|
||||
: filtered.reduce((a, b) => (a.v > b.v ? a : b));
|
||||
return best.i;
|
||||
};
|
||||
|
||||
const callValues = models.map((m) => Number(getValue(m, "callCount")));
|
||||
const ttfcP50Values = models.map((m) => Number(getValue(m, "ttfcP50")));
|
||||
const ttfcP90Values = models.map((m) => Number(getValue(m, "ttfcP90")));
|
||||
const tpsP50Values = models.map((m) => Number(getValue(m, "tpsP50")));
|
||||
const tpsP90Values = models.map((m) => Number(getValue(m, "tpsP90")));
|
||||
const costValues = models.map((m) => Number(getValue(m, "totalCost")));
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Provider",
|
||||
values: models.map((m) => dataMap.get(m)?.genAiSystem ?? "—"),
|
||||
},
|
||||
{
|
||||
label: "Total Calls (7d)",
|
||||
values: callValues.map((v) => formatNumberCompact(v)),
|
||||
bestIndex: findBest(callValues, false),
|
||||
},
|
||||
{
|
||||
label: "p50 TTFC",
|
||||
values: ttfcP50Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")),
|
||||
bestIndex: findBest(ttfcP50Values, true),
|
||||
},
|
||||
{
|
||||
label: "p90 TTFC",
|
||||
values: ttfcP90Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")),
|
||||
bestIndex: findBest(ttfcP90Values, true),
|
||||
},
|
||||
{
|
||||
label: "Tokens/sec (p50)",
|
||||
values: tpsP50Values.map((v) => (v > 0 ? v.toFixed(0) : "—")),
|
||||
bestIndex: findBest(tpsP50Values, false),
|
||||
},
|
||||
{
|
||||
label: "Tokens/sec (p90)",
|
||||
values: tpsP90Values.map((v) => (v > 0 ? v.toFixed(0) : "—")),
|
||||
bestIndex: findBest(tpsP90Values, false),
|
||||
},
|
||||
{
|
||||
label: "Total Cost (7d)",
|
||||
values: costValues.map((v) => (v > 0 ? formatModelCost(v) : "—")),
|
||||
bestIndex: findBest(costValues, true),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function ModelComparePage() {
|
||||
const { comparison, models } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const rows = buildRows(models, comparison);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
title="Compare Models"
|
||||
backButton={{
|
||||
to: v3ModelsPath(organization, project, environment),
|
||||
text: "Models",
|
||||
}}
|
||||
/>
|
||||
</NavBar>
|
||||
<PageBody scrollable>
|
||||
{models.length < 2 ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
title="Compare models side by side"
|
||||
icon={ArrowsRightLeftIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
panelClassName="max-w-md"
|
||||
>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
Select 2-4 models from the catalog to compare their pricing, capabilities, and
|
||||
performance metrics side by side.
|
||||
</p>
|
||||
<LinkButton
|
||||
to={v3ModelsPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
className="mt-3"
|
||||
>
|
||||
Browse models
|
||||
</LinkButton>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Metric</TableHeaderCell>
|
||||
{models.map((model) => (
|
||||
<TableHeaderCell key={model} alignment="right">
|
||||
{model}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.label}>
|
||||
<TableCell>
|
||||
<span className="text-xs font-medium text-text-dimmed">{row.label}</span>
|
||||
</TableCell>
|
||||
{row.values.map((value, i) => (
|
||||
<TableCell key={i} alignment="right">
|
||||
<span
|
||||
className={`tabular-nums ${
|
||||
row.bestIndex === i ? "font-medium text-success" : "text-text-bright"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+1358
File diff suppressed because it is too large
Load Diff
+234
@@ -0,0 +1,234 @@
|
||||
import { BookOpenIcon, ChevronUpDownIcon, CpuChipIcon } from "@heroicons/react/20/solid";
|
||||
import { json, type MetaFunction } from "@remix-run/node";
|
||||
import { Outlet, useLoaderData, useNavigate, useParams } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Table, TableBody, TableCell, TableRow } from "~/components/primitives/Table";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
|
||||
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Playground | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
const [agents, backgroundWorkers, regionsResult] = await Promise.all([
|
||||
playgroundPresenter.listAgents({
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
}),
|
||||
$replica.backgroundWorker.findMany({
|
||||
where: { runtimeEnvironmentId: environment.id },
|
||||
select: { version: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
new RegionsPresenter().call({
|
||||
userId: user.id,
|
||||
projectSlug: projectParam,
|
||||
isAdmin: user.admin || user.isImpersonating,
|
||||
}),
|
||||
]);
|
||||
|
||||
return json({
|
||||
agents,
|
||||
versions: backgroundWorkers.map((w) => w.version),
|
||||
regions: regionsResult.regions,
|
||||
isDev: environment.type === "DEVELOPMENT",
|
||||
});
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const { agents } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const selectedAgent = params.agentParam ?? "";
|
||||
const selectedAgentType = (() => {
|
||||
if (!selectedAgent) return null;
|
||||
const agent = agents.find((a) => a.slug === selectedAgent);
|
||||
const config = (agent?.config ?? null) as { type?: string } | null;
|
||||
return config?.type ?? null;
|
||||
})();
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Test" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer className="max-w-2xl">
|
||||
<InfoPanel
|
||||
title="Create your first agent"
|
||||
icon={CpuChipIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
panelClassName="max-w-2xl"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("ai-chat/overview")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Agent docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Test lets you exercise your AI agents with an interactive chat interface, realtime
|
||||
streaming, and conversation history.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
Define a chat agent using <InlineCode variant="small">chat.agent()</InlineCode>:
|
||||
</Paragraph>
|
||||
<CodeBlock
|
||||
code={`import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const myAgent = chat.agent({
|
||||
id: "my-agent",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model: openai("gpt-4o"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
});
|
||||
},
|
||||
});`}
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
/>
|
||||
<Paragraph variant="small" className="mt-2">
|
||||
Deploy your project and your agents will appear here ready to test.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
{selectedAgent ? (
|
||||
<PageTitle
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
value={selectedAgent}
|
||||
setValue={(slug) => {
|
||||
if (slug && typeof slug === "string" && slug !== selectedAgent) {
|
||||
navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
|
||||
}
|
||||
}}
|
||||
icon={<CubeSparkleIcon className="mr-1 size-4 text-agents" />}
|
||||
text={(val) => val || undefined}
|
||||
variant="minimal/small"
|
||||
items={agents}
|
||||
filter={(item, search) => item.slug.toLowerCase().includes(search.toLowerCase())}
|
||||
className="-ml-2"
|
||||
dropdownIcon={
|
||||
<ChevronUpDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright" />
|
||||
}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((a) => (
|
||||
<SelectItem key={a.slug} value={a.slug}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CubeSparkleIcon className="size-4 text-agents" />
|
||||
<span className="text-text-bright">{a.slug}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
{selectedAgentType && (
|
||||
<Badge variant="extra-small">{formatAgentType(selectedAgentType)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PageTitle title="Test" />
|
||||
)}
|
||||
</NavBar>
|
||||
<PageBody scrollable={!selectedAgent}>
|
||||
{selectedAgent ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-xl">
|
||||
<div className="flex flex-col gap-4 py-8">
|
||||
<Header2>Choose an agent to start a conversation</Header2>
|
||||
<Table containerClassName="overflow-hidden rounded-md border-l border-r border-b border-grid-dimmed [&_tbody_tr:last-child]:after:hidden">
|
||||
<TableBody>
|
||||
{agents.map((agent) => {
|
||||
const path = v3PlaygroundAgentPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
agent.slug
|
||||
);
|
||||
return (
|
||||
<TableRow key={agent.slug}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<CubeSparkleIcon className="size-5 text-agents" />
|
||||
<span className="text-sm">{agent.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAgentType(type: string): string {
|
||||
switch (type) {
|
||||
case "ai-sdk-chat":
|
||||
return "AI SDK Chat";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
+2236
File diff suppressed because it is too large
Load Diff
+287
@@ -0,0 +1,287 @@
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
type TooltipProps,
|
||||
} from "recharts";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PromptsNone } from "~/components/BlankStatePanels";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import TooltipPortal from "~/components/primitives/TooltipPortal";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3PromptsPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Prompts | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new PromptPresenter(clickhouse);
|
||||
const prompts = await presenter.listPrompts(project.id, environment.id);
|
||||
|
||||
const sparklines = await presenter.getUsageSparklines(
|
||||
environment.id,
|
||||
prompts.map((p) => p.slug)
|
||||
);
|
||||
|
||||
return typedjson({ prompts, sparklines });
|
||||
};
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { prompts, sparklines } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Prompts" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer className="max-w-lg">
|
||||
<PromptsNone />
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Prompts" />
|
||||
<PageAccessories>
|
||||
<LinkButton variant="docs/small" LeadingIcon={BookOpenIcon} to={docsPath("ai/prompts")}>
|
||||
Prompts docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<Table containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Slug</TableHeaderCell>
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
<TableHeaderCell>Model</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex max-w-64 flex-col gap-3 p-1 pb-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-success" />
|
||||
<Paragraph variant="small" className="text-wrap! text-text-bright">
|
||||
Latest version
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! pl-3.5 text-text-dimmed">
|
||||
Running the most recently published version.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-warning" />
|
||||
<Paragraph variant="small" className="text-wrap! text-text-bright">
|
||||
Version overridden
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! pl-3.5 text-text-dimmed">
|
||||
Pinned to an older version instead of the latest.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
disableTooltipHoverableContent
|
||||
>
|
||||
Version
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Usage (24h)</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Last updated</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{prompts.length === 0 ? (
|
||||
<TableBlankRow colSpan={7}>No prompts found</TableBlankRow>
|
||||
) : (
|
||||
prompts.map((prompt) => {
|
||||
const path = `${v3PromptsPath(organization, project, environment)}/${prompt.slug}`;
|
||||
const activeVersion = prompt.overrideVersion ?? prompt.currentVersion;
|
||||
const isOverride = !!prompt.overrideVersion;
|
||||
|
||||
return (
|
||||
<TableRow key={prompt.id}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TruncatedCopyableValue value={prompt.friendlyId} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>{prompt.slug}</TableCell>
|
||||
<TableCell to={path}>
|
||||
{prompt.description ? (
|
||||
<span
|
||||
title={prompt.description.length > 80 ? prompt.description : undefined}
|
||||
>
|
||||
{prompt.description.length > 80
|
||||
? prompt.description.slice(0, 80) + "…"
|
||||
: prompt.description}
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<span>{prompt.defaultModel ?? <span>-</span>}</span>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{activeVersion ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${
|
||||
isOverride ? "bg-warning" : "bg-success"
|
||||
}`}
|
||||
/>
|
||||
<span>v{activeVersion.version}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<UsageSparkline data={sparklines[prompt.slug]} />
|
||||
</TableCell>
|
||||
<TableCell to={path} alignment="right">
|
||||
<DateTime date={prompt.updatedAt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
type UsageDatum = { date: Date; count: number };
|
||||
|
||||
function UsageSparkline({ data }: { data?: number[] }) {
|
||||
if (!data || data.every((v) => v === 0)) {
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
const max = Math.max(...data);
|
||||
|
||||
// Map the 24-bucket array to dated points so the tooltip can show the
|
||||
// hour each bar represents. Bucket i is `23 - i` hours before now.
|
||||
const now = new Date();
|
||||
const chartData: UsageDatum[] = data.map((count, i) => ({
|
||||
date: new Date(now.getTime() - (data.length - 1 - i) * 3600_000),
|
||||
count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="h-6 w-28 rounded-sm">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<YAxis domain={[0, max || 1]} hide />
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
|
||||
content={<UsageSparklineTooltip />}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
animationDuration={0}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--color-pending)"
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
minPointSize={1}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
|
||||
{max > 0 && (
|
||||
<ReferenceLine
|
||||
y={max}
|
||||
stroke="var(--color-border-brighter)"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<span className={cn("-mt-1 text-xs tabular-nums text-blue-400")}>
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageSparklineTooltip({ active, payload }: TooltipProps<number, string>) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const entry = payload[0].payload as UsageDatum;
|
||||
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
|
||||
const formattedDate = formatDateTime(date, "UTC", [], false, true);
|
||||
return (
|
||||
<TooltipPortal active={active}>
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
|
||||
<div className="mt-2 text-xs text-text-bright">
|
||||
<span className="tabular-nums">{entry.count.toLocaleString()}</span>{" "}
|
||||
<span className="text-text-dimmed">{entry.count === 1 ? "call" : "calls"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPortal>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { AIQueryInput } from "~/components/code/AIQueryInput";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import type { AITimeFilter } from "./types";
|
||||
|
||||
export function AITabContent({
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
getCurrentQuery,
|
||||
aiFixRequest,
|
||||
}: {
|
||||
onQueryGenerated: (query: string) => void;
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
getCurrentQuery: () => string;
|
||||
aiFixRequest: { prompt: string; key: number } | null;
|
||||
}) {
|
||||
const [examplePromptRequest, setExamplePromptRequest] = useState<{
|
||||
prompt: string;
|
||||
key: number;
|
||||
} | null>(null);
|
||||
|
||||
// Use aiFixRequest if present, otherwise use example prompt request
|
||||
const activeRequest = aiFixRequest ?? examplePromptRequest;
|
||||
|
||||
const examplePrompts = [
|
||||
"Show me failed runs by hour for the past 7 days",
|
||||
"Count of runs by status by hour for the past 48h",
|
||||
"Top 50 most expensive runs this week",
|
||||
"Average execution duration by task this week",
|
||||
"Run counts by tag in the past 7 days",
|
||||
"CPU utilization over time by task",
|
||||
"Peak memory usage per run",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AIQueryInput
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
autoSubmitPrompt={activeRequest?.prompt}
|
||||
autoSubmitKey={activeRequest?.key}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examplePrompts.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExamplePromptRequest((prev) => ({
|
||||
prompt: example,
|
||||
key: (prev?.key ?? 0) + 1,
|
||||
}));
|
||||
}}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:rounded-full!"
|
||||
>
|
||||
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="text-left transition group-hover:text-text-bright"
|
||||
>
|
||||
{example}
|
||||
</Paragraph>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { useState } from "react";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
import { TryableCodeBlock } from "./TRQLGuideContent";
|
||||
|
||||
// Example queries for the Examples tab
|
||||
export const exampleQueries: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
query: string;
|
||||
scope: QueryScope;
|
||||
table: string;
|
||||
}> = [
|
||||
{
|
||||
title: "Failed runs by task (past 7 days)",
|
||||
description: "Count of failed runs grouped by task identifier over the last 7 days.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
count() AS failed_count
|
||||
FROM runs
|
||||
WHERE status = 'Failed'
|
||||
AND triggered_at > now() - INTERVAL 7 DAY
|
||||
GROUP BY task_identifier
|
||||
ORDER BY failed_count DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Execution duration p50 by task (past 7d)",
|
||||
description: "Median (50th percentile) execution duration for each task.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
quantile(0.5)(execution_duration) AS p50_duration_ms
|
||||
FROM runs
|
||||
WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
AND execution_duration IS NOT NULL
|
||||
GROUP BY task_identifier
|
||||
ORDER BY p50_duration_ms DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Runs over time",
|
||||
description:
|
||||
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
count() AS run_count
|
||||
FROM runs
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Most expensive 100 runs (past 7d)",
|
||||
description: "Top 100 runs by cost over the last 7 days.",
|
||||
query: `SELECT
|
||||
run_id,
|
||||
task_identifier,
|
||||
status,
|
||||
total_cost,
|
||||
usage_duration,
|
||||
machine,
|
||||
triggered_at
|
||||
FROM runs
|
||||
WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 100`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "CPU utilization over time",
|
||||
description: "Track process CPU utilization bucketed over time.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
avg(metric_value) AS avg_cpu
|
||||
FROM metrics
|
||||
WHERE metric_name = 'process.cpu.utilization'
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "Memory usage by task (past 7d)",
|
||||
description: "Average memory usage per task identifier over the last 7 days.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
avg(metric_value) AS avg_memory
|
||||
FROM metrics
|
||||
WHERE metric_name = 'system.memory.usage'
|
||||
AND bucket_start > now() - INTERVAL 7 DAY
|
||||
GROUP BY task_identifier
|
||||
ORDER BY avg_memory DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "Available metric names",
|
||||
description: "List all distinct metric names collected in your environment.",
|
||||
query: `SELECT
|
||||
metric_name,
|
||||
count() AS sample_count
|
||||
FROM metrics
|
||||
GROUP BY metric_name
|
||||
ORDER BY sample_count DESC
|
||||
LIMIT 100`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost by model (past 7d)",
|
||||
description:
|
||||
"Total cost, input tokens, and output tokens grouped by model over the last 7 days.",
|
||||
query: `SELECT
|
||||
response_model,
|
||||
SUM(total_cost) AS total_cost,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens
|
||||
FROM llm_metrics
|
||||
WHERE start_time > now() - INTERVAL 7 DAY
|
||||
GROUP BY response_model
|
||||
ORDER BY total_cost DESC`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost over time",
|
||||
description: "Total LLM cost bucketed over time. The bucket size adjusts automatically.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
SUM(total_cost) AS total_cost
|
||||
FROM llm_metrics
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "Most expensive runs by LLM cost (top 50)",
|
||||
description: "Top 50 runs by total LLM cost with token breakdown.",
|
||||
query: `SELECT
|
||||
run_id,
|
||||
task_identifier,
|
||||
SUM(total_cost) AS llm_cost,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens
|
||||
FROM llm_metrics
|
||||
GROUP BY run_id, task_identifier
|
||||
ORDER BY llm_cost DESC
|
||||
LIMIT 50`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM calls by provider",
|
||||
description: "Count and cost of LLM calls grouped by AI provider.",
|
||||
query: `SELECT
|
||||
gen_ai_system,
|
||||
count() AS call_count,
|
||||
SUM(total_cost) AS total_cost
|
||||
FROM llm_metrics
|
||||
GROUP BY gen_ai_system
|
||||
ORDER BY total_cost DESC`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost by user",
|
||||
description:
|
||||
"Total LLM cost per user from run tags or AI SDK telemetry metadata. Uses metadata.userId which comes from experimental_telemetry metadata or run tags like user:123.",
|
||||
query: `SELECT
|
||||
metadata.userId AS user_id,
|
||||
SUM(total_cost) AS total_cost,
|
||||
SUM(total_tokens) AS total_tokens,
|
||||
count() AS call_count
|
||||
FROM llm_metrics
|
||||
WHERE metadata.userId != ''
|
||||
GROUP BY metadata.userId
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 50`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost by metadata key",
|
||||
description:
|
||||
"Browse all metadata keys and their LLM cost. Metadata comes from run tags (key:value) and AI SDK telemetry metadata.",
|
||||
query: `SELECT
|
||||
metadata,
|
||||
response_model,
|
||||
total_cost,
|
||||
total_tokens,
|
||||
run_id
|
||||
FROM llm_metrics
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
];
|
||||
|
||||
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
|
||||
|
||||
export function ExamplesContent({
|
||||
onTryExample,
|
||||
}: {
|
||||
onTryExample: (query: string, scope: QueryScope) => void;
|
||||
}) {
|
||||
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
|
||||
const filtered = exampleQueries.filter((e) => e.table === selectedTable);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="sticky top-0 z-10 bg-background-bright pb-3">
|
||||
<SegmentedControl
|
||||
name="examples-table-selector"
|
||||
value={selectedTable}
|
||||
options={tableOptions}
|
||||
variant="secondary/small"
|
||||
fullWidth
|
||||
onChange={setSelectedTable}
|
||||
/>
|
||||
</div>
|
||||
{filtered.map((example) => (
|
||||
<div key={example.title}>
|
||||
<Header3 className="mb-1 text-text-bright">{example.title}</Header3>
|
||||
<Paragraph variant="small" className="mb-2 text-text-dimmed">
|
||||
{example.description}
|
||||
</Paragraph>
|
||||
<TryableCodeBlock
|
||||
code={example.query}
|
||||
onTry={() => onTryExample(example.query, example.scope)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { AITabContent } from "./AITabContent";
|
||||
import { ExamplesContent } from "./ExamplesContent";
|
||||
import { TableSchemaContent } from "./TableSchemaContent";
|
||||
import { TRQLGuideContent } from "./TRQLGuideContent";
|
||||
import type { AITimeFilter } from "./types";
|
||||
|
||||
export function QueryHelpSidebar({
|
||||
onTryExample,
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
getCurrentQuery,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
aiFixRequest,
|
||||
}: {
|
||||
onTryExample: (query: string, scope: QueryScope) => void;
|
||||
onQueryGenerated: (query: string) => void;
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
getCurrentQuery: () => string;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
aiFixRequest: { prompt: string; key: number } | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<ClientTabs
|
||||
value={activeTab}
|
||||
onValueChange={onTabChange}
|
||||
className="flex min-h-0 flex-col overflow-hidden pt-1"
|
||||
>
|
||||
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger
|
||||
value="ai"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</div>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="guide"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Writing TRQL
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="schema"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Table schema
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="examples"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Examples
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent
|
||||
value="ai"
|
||||
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<AITabContent
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
aiFixRequest={aiFixRequest}
|
||||
/>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="guide"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<TRQLGuideContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="schema"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<TableSchemaContent />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="examples"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<ExamplesContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"ORDER BY",
|
||||
"LIMIT",
|
||||
"GROUP BY",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"LEFT JOIN",
|
||||
"RIGHT JOIN",
|
||||
"INNER JOIN",
|
||||
"OUTER JOIN",
|
||||
"AND",
|
||||
"OR",
|
||||
"AS",
|
||||
"ON",
|
||||
"IN",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"DESC",
|
||||
"ASC",
|
||||
"DISTINCT",
|
||||
"COUNT",
|
||||
"SUM",
|
||||
"AVG",
|
||||
"MIN",
|
||||
"MAX",
|
||||
];
|
||||
|
||||
function highlightSQL(query: string): React.ReactNode[] {
|
||||
// Normalize: collapse multiple spaces/tabs to single space, but preserve newlines
|
||||
// Then trim each line and limit total length
|
||||
const normalized = query
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n")
|
||||
.slice(0, 500);
|
||||
|
||||
// Create a regex pattern that matches keywords as whole words (case insensitive)
|
||||
const keywordPattern = new RegExp(
|
||||
`\\b(${SQL_KEYWORDS.map((k) => k.replace(/\s+/g, "\\s+")).join("|")})\\b`,
|
||||
"gi"
|
||||
);
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = keywordPattern.exec(normalized)) !== null) {
|
||||
// Add text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(normalized.slice(lastIndex, match.index));
|
||||
}
|
||||
// Add the highlighted keyword
|
||||
parts.push(
|
||||
<span key={match.index} className="text-[#c678dd]">
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
lastIndex = keywordPattern.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < normalized.length) {
|
||||
parts.push(normalized.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function QueryHistoryPopover({
|
||||
history,
|
||||
onQuerySelected,
|
||||
}: {
|
||||
history: QueryHistoryItem[];
|
||||
onQuerySelected: (item: QueryHistoryItem) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ClockRotateLeftIcon}
|
||||
leadingIconClassName="-mr-1.5"
|
||||
disabled={history.length === 0}
|
||||
tooltip="Query history"
|
||||
shortcut={{ key: "h" }}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPrimitive.Content
|
||||
className={cn(
|
||||
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-grid-bright bg-background-bright p-0 shadow-md outline-hidden animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
)}
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
|
||||
>
|
||||
<div className="max-h-160 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="p-1">
|
||||
{history.map((item) => {
|
||||
// Format time filter display
|
||||
const { valueLabel } = timeFilterRenderValues({
|
||||
period: item.filterPeriod ?? undefined,
|
||||
from: item.filterFrom ?? undefined,
|
||||
to: item.filterTo ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onQuerySelected(item);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-hidden transition-colors focus-custom hover:bg-background-hover"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start">
|
||||
{item.title ? (
|
||||
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
|
||||
{item.query.split("\n")[0].slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
|
||||
<span className="capitalize">{item.scope}</span>
|
||||
{valueLabel && <span>· {valueLabel}</span>}
|
||||
{item.userName && <span>· {item.userName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full border-l-2 border-border-bright pl-2.5">
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPrimitive.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
+1208
File diff suppressed because it is too large
Load Diff
+78
@@ -0,0 +1,78 @@
|
||||
import type { ColumnSchema } from "@internal/tsql";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
|
||||
function ColumnHelpItem({ col }: { col: ColumnSchema }) {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyableText value={col.name} className="text-sm text-indigo-400" />
|
||||
<Badge className="font-mono text-xxs">{col.type}</Badge>
|
||||
</div>
|
||||
{col.description && (
|
||||
<Paragraph variant="extra-small" className="mt-1 text-text-dimmed">
|
||||
{col.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
{col.example && (
|
||||
<div className="mt-1 flex items-baseline gap-0.5">
|
||||
<span className="text-xs text-text-dimmed">Example:</span>
|
||||
<CopyableText
|
||||
value={col.example}
|
||||
className="rounded-sm bg-background-hover px-1.5 py-0.5 font-mono text-xxs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{col.allowedValues && col.allowedValues.length > 0 && (
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
<span className="text-xs text-text-dimmed">Available options:</span>
|
||||
{col.allowedValues.map((value) => (
|
||||
<CopyableText
|
||||
key={value}
|
||||
value={col.valueMap?.[value] ?? value}
|
||||
className="rounded-sm bg-background-hover px-1.5 py-0.5 font-mono text-xxs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
|
||||
|
||||
export function TableSchemaContent() {
|
||||
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
|
||||
const table = querySchemas.find((s) => s.name === selectedTable) ?? querySchemas[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-background-bright pb-3">
|
||||
<SegmentedControl
|
||||
name="table-schema-selector"
|
||||
value={selectedTable}
|
||||
options={tableOptions}
|
||||
variant="secondary/small"
|
||||
fullWidth
|
||||
onChange={setSelectedTable}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
{table.description && (
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{table.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
|
||||
{Object.values(table.columns).map((col) => (
|
||||
<ColumnHelpItem key={col.name} col={col} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
redirect,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { QueryEditor } from "~/components/query/QueryEditor";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { executeQuery, getDefaultPeriod } from "~/services/queryService.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, queryPath } from "~/utils/pathBuilder";
|
||||
import { canAccessQuery } from "~/v3/canAccessQuery.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
|
||||
/** Convert a Date or ISO string to ISO string format */
|
||||
function toISOString(value: Date | string): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new QueryPresenter();
|
||||
const { defaultQuery, history } = await presenter.call({
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
// Admins and impersonating users can use EXPLAIN
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
return typedjson({
|
||||
defaultQuery,
|
||||
defaultPeriod: await getDefaultPeriod(project.organizationId),
|
||||
history,
|
||||
isAdmin,
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
query: z.string().min(1, "Query is required"),
|
||||
scope: z.enum(["environment", "project", "organization"]),
|
||||
explain: z.enum(["true", "false"]).nullable().optional(),
|
||||
period: z.string().nullable().optional(),
|
||||
from: z.string().nullable().optional(),
|
||||
to: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
|
||||
if (!canAccess) {
|
||||
return typedjson(
|
||||
{
|
||||
error: "Unauthorized",
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
return typedjson(
|
||||
{
|
||||
error: "Project not found",
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
return typedjson(
|
||||
{
|
||||
error: "Environment not found",
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const parsed = ActionSchema.safeParse({
|
||||
query: formData.get("query"),
|
||||
scope: formData.get("scope"),
|
||||
explain: formData.get("explain"),
|
||||
period: formData.get("period"),
|
||||
from: formData.get("from"),
|
||||
to: formData.get("to"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return typedjson(
|
||||
{
|
||||
error: parsed.error.errors.map((e) => e.message).join(", "),
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { query, scope, explain: explainParam, period, from, to } = parsed.data;
|
||||
// Only allow explain for admins/impersonating users
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
const explain = explainParam === "true" && isAdmin;
|
||||
|
||||
try {
|
||||
const queryResult = await executeQuery({
|
||||
name: "query-page",
|
||||
query,
|
||||
scope,
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
explain,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
history: {
|
||||
source: "DASHBOARD",
|
||||
userId: user.id,
|
||||
skip: user.isImpersonating,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
return typedjson(
|
||||
{
|
||||
error: queryResult.error.message,
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
error: null,
|
||||
rows: queryResult.result.rows,
|
||||
columns: queryResult.result.columns,
|
||||
stats: queryResult.result.stats,
|
||||
hiddenColumns: queryResult.result.hiddenColumns ?? null,
|
||||
reachedMaxRows: queryResult.result.reachedMaxRows,
|
||||
explainOutput: queryResult.result.explainOutput ?? null,
|
||||
generatedSql: queryResult.result.generatedSql ?? null,
|
||||
queryId: queryResult.queryId,
|
||||
periodClipped: queryResult.periodClipped,
|
||||
maxQueryPeriod: queryResult.maxQueryPeriod,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error executing query";
|
||||
return typedjson(
|
||||
{
|
||||
error: errorMessage,
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
// Use most recent history item if available, otherwise fall back to defaults
|
||||
const initialQuery = history.length > 0 ? history[0].query : defaultQuery;
|
||||
const initialScope = history.length > 0 ? history[0].scope : "environment";
|
||||
const initialTimeFilter =
|
||||
history.length > 0
|
||||
? {
|
||||
period: history[0].filterPeriod ?? undefined,
|
||||
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
|
||||
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build the query action URL for this page
|
||||
const queryActionUrl = queryPath(
|
||||
{ slug: organization.slug },
|
||||
{ slug: project.slug },
|
||||
{ slug: environment.slug }
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryEditor
|
||||
defaultQuery={initialQuery}
|
||||
defaultScope={initialScope}
|
||||
defaultPeriod={defaultPeriod}
|
||||
defaultTimeFilter={initialTimeFilter}
|
||||
history={history}
|
||||
isAdmin={isAdmin}
|
||||
maxRows={maxRows}
|
||||
queryActionUrl={queryActionUrl}
|
||||
mode={{ type: "standalone" }}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Time filter configuration that can be set by the AI.
|
||||
* Used across both server and client code for AI query generation.
|
||||
*/
|
||||
export type AITimeFilter = {
|
||||
period?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
+1077
File diff suppressed because it is too large
Load Diff
+502
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowUpCircleIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
InformationCircleIcon,
|
||||
MapPinIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { CloudProviderIcon } from "~/assets/icons/CloudProviderIcon";
|
||||
import { FlagIcon } from "~/assets/icons/RegionIcons";
|
||||
import { cloudProviderTitle } from "~/components/CloudProvider";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
regionsPath,
|
||||
v3BillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const presenter = new RegionsPresenter();
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: projectParam,
|
||||
isAdmin: user.admin || user.isImpersonating,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
};
|
||||
|
||||
const FormSchema = z.object({
|
||||
regionId: z.string(),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
|
||||
const redirectPath = regionsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
|
||||
|
||||
if (!parsedFormData.success) {
|
||||
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
|
||||
}
|
||||
|
||||
const service = new SetDefaultRegionService();
|
||||
const [error, result] = await tryCatch(
|
||||
service.call({
|
||||
projectId: project.id,
|
||||
regionId: parsedFormData.data.regionId,
|
||||
isAdmin: user.admin || user.isImpersonating,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return redirectWithErrorMessage(redirectPath, request, error.message);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Regions" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
{regions.map((region) => (
|
||||
<Property.Item key={region.id}>
|
||||
<Property.Label>{region.name}</Property.Label>
|
||||
<Property.Value>{region.id}</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[1fr]">
|
||||
{regions.length === 0 ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<div className="text-center">
|
||||
<Paragraph>No regions found for this project.</Paragraph>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Region</TableHeaderCell>
|
||||
<TableHeaderCell>Cloud Provider</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="flex items-center gap-1">
|
||||
Location
|
||||
<InfoIconTooltip
|
||||
content="Region location is where your runs execute, not where your data is stored."
|
||||
contentClassName="normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Static IPs</TableHeaderCell>
|
||||
{isAdmin && <TableHeaderCell>Admin</TableHeaderCell>}
|
||||
<TableHeaderCell
|
||||
alignment="right"
|
||||
tooltip={
|
||||
<div className="max-w-48">
|
||||
<Paragraph variant="small">
|
||||
When you trigger a run it will execute in your default region, unless
|
||||
you override the region when triggering.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("triggering#region")}
|
||||
className="mb-1 mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Default region
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{regions.length === 0 ? (
|
||||
<TableBlankRow colSpan={5}>
|
||||
<Paragraph>There are no regions for this project</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
regions.map((region) => {
|
||||
return (
|
||||
<TableRow key={region.id}>
|
||||
<TableCell isTabbableCell>
|
||||
<span className="flex items-center gap-2">
|
||||
<CopyableText value={region.name} />
|
||||
{region.workloadType === "MICROVM" && (
|
||||
<Badge variant="small">MicroVM</Badge>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{region.cloudProvider ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CloudProviderIcon
|
||||
provider={region.cloudProvider}
|
||||
className="size-6"
|
||||
/>
|
||||
{cloudProviderTitle(region.cloudProvider)}
|
||||
</span>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2">
|
||||
{region.location ? (
|
||||
<FlagIcon region={region.location} className="size-5" />
|
||||
) : null}
|
||||
{region.description ?? "–"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{region.staticIPs === null ? (
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to={v3BillingPath(
|
||||
organization,
|
||||
"Upgrade your plan to unlock static IPs"
|
||||
)}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Unlock static IPs
|
||||
</LinkButton>
|
||||
) : region.staticIPs !== undefined ? (
|
||||
<ClipboardField
|
||||
value={region.staticIPs}
|
||||
variant={"secondary/small"}
|
||||
/>
|
||||
) : (
|
||||
"Not available"
|
||||
)}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell>{region.isHidden ? "Hidden" : "Visible"}</TableCell>
|
||||
)}
|
||||
{region.isDefault ? (
|
||||
<TableCell alignment="right">
|
||||
<Badge variant="small" className="inline-grid">
|
||||
Default
|
||||
</Badge>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
isSticky
|
||||
hiddenButtons={
|
||||
<SetDefaultDialog regions={regions} newDefaultRegion={region} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<TableRow className="h-12.5">
|
||||
<TableCell colSpan={isAdmin ? 5 : 4}>
|
||||
<Paragraph variant="extra-small">Suggest a new region</Paragraph>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
alignment="right"
|
||||
isSticky
|
||||
visibleButtons={
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ChatBubbleLeftEllipsisIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Suggest a region…
|
||||
</Button>
|
||||
}
|
||||
defaultValue="region"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{isManagedCloud && (
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="size-4"
|
||||
variant="minimal"
|
||||
panelClassName="max-w-full gap-1"
|
||||
>
|
||||
<Paragraph variant="extra-small">
|
||||
Trigger.dev is fully{" "}
|
||||
<TextLink to="https://security.trigger.dev/gdpr?tab=securityControls&frameworks=gdpr_v1">
|
||||
GDPR compliant
|
||||
</TextLink>
|
||||
. Learn more in our{" "}
|
||||
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
|
||||
<Feedback
|
||||
button={
|
||||
<span className="cursor-pointer text-xs text-indigo-500 transition hover:text-indigo-400">
|
||||
get in touch
|
||||
</span>
|
||||
}
|
||||
defaultValue="feedback"
|
||||
/>
|
||||
.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SetDefaultDialog({
|
||||
regions,
|
||||
newDefaultRegion,
|
||||
}: {
|
||||
regions: Region[];
|
||||
newDefaultRegion: Region;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const currentDefaultRegion = regions.find((r) => r.isDefault);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={MapPinIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
iconSpacing="gap-2"
|
||||
className="pl-2"
|
||||
>
|
||||
<span className="text-text-bright">Set as default…</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set as default region</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription asChild>
|
||||
<div>
|
||||
<Paragraph>
|
||||
Are you sure you want to set {newDefaultRegion.name} as your new default region?
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-4 flex">
|
||||
<div className="flex flex-1 flex-col rounded-md border border-grid-dimmed">
|
||||
<div className="border-b border-grid-dimmed bg-background-bright p-3 font-medium">
|
||||
<Paragraph variant="small/bright">Current default</Paragraph>
|
||||
</div>
|
||||
<div className="border-b border-grid-dimmed p-3">
|
||||
<Paragraph variant="small">{currentDefaultRegion?.name ?? "–"}</Paragraph>
|
||||
</div>
|
||||
<div className="border-b border-grid-dimmed p-3">
|
||||
<Paragraph variant="small" className="flex items-center gap-2">
|
||||
{currentDefaultRegion?.cloudProvider ? (
|
||||
<>
|
||||
<CloudProviderIcon
|
||||
provider={currentDefaultRegion.cloudProvider}
|
||||
className="size-6"
|
||||
/>
|
||||
{cloudProviderTitle(currentDefaultRegion.cloudProvider)}
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Paragraph variant="small" className="flex items-center gap-2">
|
||||
{currentDefaultRegion?.location ? (
|
||||
<FlagIcon region={currentDefaultRegion.location} className="size-5" />
|
||||
) : null}
|
||||
{currentDefaultRegion?.description ?? "–"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle column with arrow */}
|
||||
<div className="flex items-center justify-center px-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full border border-grid-dimmed bg-background-bright p-2">
|
||||
<ArrowRightIcon className="size-4 text-text-dimmed" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="flex flex-1 flex-col rounded-md border border-grid-dimmed">
|
||||
<div className="border-b border-grid-dimmed bg-background-bright p-3 font-medium">
|
||||
<Paragraph variant="small/bright">New default</Paragraph>
|
||||
</div>
|
||||
<div className="border-b border-grid-dimmed p-3">
|
||||
<Paragraph variant="small">{newDefaultRegion.name}</Paragraph>
|
||||
</div>
|
||||
<div className="border-b border-grid-dimmed p-3">
|
||||
<Paragraph variant="small" className="flex items-center gap-2">
|
||||
{newDefaultRegion.cloudProvider ? (
|
||||
<>
|
||||
<CloudProviderIcon
|
||||
provider={newDefaultRegion.cloudProvider}
|
||||
className="size-6"
|
||||
/>
|
||||
{cloudProviderTitle(newDefaultRegion.cloudProvider)}
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Paragraph variant="small" className="flex items-center gap-2">
|
||||
{newDefaultRegion.location ? (
|
||||
<FlagIcon region={newDefaultRegion.location} className="size-5" />
|
||||
) : null}
|
||||
{newDefaultRegion.description ?? "–"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Paragraph>
|
||||
Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "}
|
||||
<TextLink to={docsPath("triggering#region")}>override when triggering</TextLink>.
|
||||
</Paragraph>
|
||||
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="size-4"
|
||||
variant="minimal"
|
||||
panelClassName="mt-4 max-w-full gap-1 border-t border-grid-dimmed pt-4 pb-0 pl-0"
|
||||
>
|
||||
<Paragraph variant="extra-small">
|
||||
Region is where your runs execute, not where your data is stored.
|
||||
{isManagedCloud ? (
|
||||
<>
|
||||
{" "}
|
||||
Trigger.dev is fully{" "}
|
||||
<TextLink to="https://security.trigger.dev/gdpr?tab=securityControls&frameworks=gdpr_v1">
|
||||
GDPR compliant
|
||||
</TextLink>
|
||||
.
|
||||
</>
|
||||
) : null}
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary/medium" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Form method="post">
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
name="regionId"
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
value={newDefaultRegion.id}
|
||||
>
|
||||
Set as default
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { runStreamLoader } from "~/presenters/v3/RunStreamPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader(args: LoaderFunctionArgs) {
|
||||
// Authenticate the user before starting the stream
|
||||
await requireUserId(args.request);
|
||||
|
||||
// Delegate to the SSE loader
|
||||
return runStreamLoader(args);
|
||||
}
|
||||
+2070
File diff suppressed because it is too large
Load Diff
+566
@@ -0,0 +1,566 @@
|
||||
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, useLocation, useNavigation, useRevalidator } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense, useState } from "react";
|
||||
import {
|
||||
TypedAwait,
|
||||
typeddefer,
|
||||
type UseDataFunctionReturn,
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PulsingDot } from "~/components/primitives/PulsingDot";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
collapsibleHandleClassName,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { SelectedItemsProvider } from "~/components/primitives/SelectedItemsProvider";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import {
|
||||
setRootOnlyFilterPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectPath,
|
||||
v3TestPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { throwNotFound } from "~/utils/httpErrors";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import {
|
||||
isRunsListLoading,
|
||||
RUNS_BULK_INSPECTOR_OPEN_VALUE,
|
||||
shouldRevalidateRunsList,
|
||||
} from "./shouldRevalidateRunsList";
|
||||
import { useRunsLiveReload } from "./useRunsLiveReload";
|
||||
|
||||
export { shouldRevalidateRunsList as shouldRevalidate };
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Runs metrics | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throwNotFound("Environment not found");
|
||||
}
|
||||
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new NextRunListPresenter($replica, clickhouse);
|
||||
const list = presenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
includeHasAnyRuns: true,
|
||||
});
|
||||
|
||||
// Only persist rootOnly when no tasks are filtered. While a task filter is active,
|
||||
// the toggle's URL value can be a temporary auto-flip (or a user override scoped to
|
||||
// the current task filter), and we don't want either bleeding into the saved
|
||||
// session preference. Clearing the task filter restores the saved preference.
|
||||
const shouldPersistRootOnly = !filters.tasks || filters.tasks.length === 0;
|
||||
const headers = shouldPersistRootOnly
|
||||
? {
|
||||
"Set-Cookie": await uiPreferencesStorage.commitSession(
|
||||
await setRootOnlyFilterPreference(filters.rootOnly, request)
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Display flags for the row-menu and bulk-action controls — the cancel/
|
||||
// replay action routes enforce write:runs independently. Permissive in OSS.
|
||||
const runAuth = await rbac.authenticateSession(request, {
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
const runPermissions = runAuth.ok
|
||||
? checkPermissions(runAuth.ability, {
|
||||
canCancelRuns: { action: "write", resource: { type: "runs" } },
|
||||
canReplayRuns: { action: "write", resource: { type: "runs" } },
|
||||
})
|
||||
: { canCancelRuns: true, canReplayRuns: true };
|
||||
|
||||
return typeddefer(
|
||||
{
|
||||
data: list,
|
||||
rootOnlyDefault: filters.rootOnly,
|
||||
filters,
|
||||
...runPermissions,
|
||||
},
|
||||
headers ? { headers } : undefined
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data, rootOnlyDefault, filters, canCancelRuns, canReplayRuns } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { isConnected } = useDevPresence();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle title="Runs" accessory={<RunsHelpTooltip />} />
|
||||
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
|
||||
<DevDisconnectedBanner isConnected={isConnected} />
|
||||
)}
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/runs-and-attempts")}
|
||||
>
|
||||
Runs docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<SelectedItemsProvider
|
||||
initialSelectedItems={[]}
|
||||
maxSelectedItemCount={BULK_ACTION_RUN_LIMIT}
|
||||
>
|
||||
{({ selectedItems }) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
|
||||
<div className="border-b border-grid-bright" />
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your task runs. Please refresh the page or try again in a
|
||||
moment.
|
||||
</Callout>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(list) => {
|
||||
return (
|
||||
<RunsList
|
||||
list={list}
|
||||
selectedItems={selectedItems}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
filters={filters}
|
||||
canCancelRuns={canCancelRuns}
|
||||
canReplayRuns={canReplayRuns}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
)}
|
||||
</SelectedItemsProvider>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunsList({
|
||||
list,
|
||||
selectedItems,
|
||||
rootOnlyDefault,
|
||||
filters,
|
||||
canCancelRuns,
|
||||
canReplayRuns,
|
||||
}: {
|
||||
list: Awaited<UseDataFunctionReturn<typeof loader>["data"]>;
|
||||
selectedItems: Set<string>;
|
||||
rootOnlyDefault: boolean;
|
||||
filters: TaskRunListSearchFilters;
|
||||
canCancelRuns: boolean;
|
||||
canReplayRuns: boolean;
|
||||
}) {
|
||||
const revalidator = useRevalidator();
|
||||
const location = useLocation();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = isRunsListLoading(navigation, location.search);
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { has, replace } = useSearchParams();
|
||||
const { visibleRuns, showNewRunsBanner, newRunsCount, dismissNewRuns, childrenStatusesBasePath } =
|
||||
useRunsLiveReload({
|
||||
runs: list.runs,
|
||||
hasAnyRuns: list.hasAnyRuns,
|
||||
isLoading,
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
environmentSlug: environment.slug,
|
||||
});
|
||||
|
||||
const onClickShowNewRuns = () => {
|
||||
const isPaginated = has("cursor") || has("direction");
|
||||
dismissNewRuns();
|
||||
if (isPaginated) {
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
revalidator.revalidate();
|
||||
};
|
||||
|
||||
// Shortcut keys for bulk actions — disabled when the role can't perform them.
|
||||
useShortcutKeys({
|
||||
shortcut: { key: "r" },
|
||||
disabled: !canReplayRuns,
|
||||
action: (e) => {
|
||||
replace({
|
||||
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
|
||||
action: "replay",
|
||||
mode: selectedItems.size > 0 ? "selected" : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
useShortcutKeys({
|
||||
shortcut: { key: "c" },
|
||||
disabled: !canCancelRuns,
|
||||
action: (e) => {
|
||||
replace({
|
||||
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
|
||||
action: "cancel",
|
||||
mode: selectedItems.size > 0 ? "selected" : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns;
|
||||
const [isBulkInspectorPanelCollapsed, setIsBulkInspectorPanelCollapsed] = useState(
|
||||
!isShowingBulkActionInspector
|
||||
);
|
||||
// Keep content mounted until onCollapseChange reports the panel is fully collapsed.
|
||||
const showBulkInspectorContent = isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed;
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="runs-main" min={"100px"}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden",
|
||||
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasAnyRuns ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions
|
||||
task={
|
||||
list.filters.tasks.length === 1
|
||||
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<RunsFilters
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{showNewRunsBanner && (
|
||||
<span className="flex duration-150 animate-in fade-in-0">
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
className="text-text-bright"
|
||||
onClick={onClickShowNewRuns}
|
||||
LeadingIcon={<PulsingDot className="h-2 w-2" />}
|
||||
tooltip="Refresh to see new runs"
|
||||
aria-label="New runs created. Refresh to see new runs."
|
||||
>
|
||||
{newRunsCount >= 100
|
||||
? "99+ new runs"
|
||||
: `${newRunsCount} new ${newRunsCount === 1 ? "run" : "runs"}`}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
{/* Stay mounted while the inspector is open to avoid toolbar layout shift. */}
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
disabled={isShowingBulkActionInspector || (!canCancelRuns && !canReplayRuns)}
|
||||
onClick={() =>
|
||||
replace({
|
||||
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
|
||||
mode: selectedItems.size > 0 ? "selected" : undefined,
|
||||
})
|
||||
}
|
||||
LeadingIcon={ListCheckedIcon}
|
||||
className={cn(
|
||||
selectedItems.size > 0 ? "pr-1" : undefined,
|
||||
isShowingBulkActionInspector && "pointer-events-none invisible"
|
||||
)}
|
||||
tooltip={
|
||||
!canCancelRuns && !canReplayRuns ? (
|
||||
"You don't have permission to cancel or replay runs"
|
||||
) : (
|
||||
<div className="-mr-1 flex items-center gap-3 text-xs text-text-dimmed">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span>Replay</span>
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant={"small"} />
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span>Cancel</span>
|
||||
<ShortcutKey shortcut={{ key: "c" }} variant={"small"} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-x-1 whitespace-nowrap text-text-bright">
|
||||
<span>Bulk action</span>
|
||||
{selectedItems.size > 0 && (
|
||||
<Badge variant="rounded">{selectedItems.size}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={visibleRuns.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={visibleRuns}
|
||||
childrenStatusesBasePath={childrenStatusesBasePath}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
canCancelRuns={canCancelRuns}
|
||||
canReplayRuns={canReplayRuns}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id="runs-handle"
|
||||
className={collapsibleHandleClassName(isShowingBulkActionInspector)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="bulk-action-inspector"
|
||||
default="400px"
|
||||
min="400px"
|
||||
max="600px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!isShowingBulkActionInspector}
|
||||
onCollapseChange={setIsBulkInspectorPanelCollapsed}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 400 }}>
|
||||
{showBulkInspectorContent && (
|
||||
<CreateBulkActionInspector
|
||||
filters={filters}
|
||||
selectedItems={selectedItems}
|
||||
hasBulkActions={list.bulkActions.length > 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFirstTaskInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
icon={TaskIcon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="max-full"
|
||||
title="Create your first task"
|
||||
accessory={
|
||||
<LinkButton to={v3ProjectPath(organization, project)} variant="primary/small">
|
||||
Create a task
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
Before running a task, you must first create one. Follow the instructions on the{" "}
|
||||
<TextLink to={v3ProjectPath(organization, project)}>Tasks</TextLink> page to create a
|
||||
task, then return here to run it.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<Header1 className="mb-6 border-b py-2">How to run your tasks</Header1>
|
||||
<StepNumber stepNumber="A" title="Trigger a test run" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Perform a test run with a payload directly from the dashboard.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={
|
||||
task
|
||||
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
|
||||
: v3TestPath(organization, project, environment)
|
||||
}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-tests"
|
||||
className="inline-flex"
|
||||
>
|
||||
Test
|
||||
</LinkButton>
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<hr className="w-full" />
|
||||
<Paragraph variant="extra-extra-small/dimmed/caps">OR</Paragraph>
|
||||
<hr className="w-full" />
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
|
||||
<StepNumber stepNumber="B" title="Trigger your task for real" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Performing a real run depends on the type of trigger your task is using.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("/triggering")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
How to trigger a task
|
||||
</LinkButton>
|
||||
</StepContentContainer>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RunsHelpTooltip() {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<QuestionMarkIcon className="size-4 text-text-dimmed transition hover:text-text-bright" />
|
||||
}
|
||||
side="bottom"
|
||||
className="max-w-sm p-3"
|
||||
disableHoverableContent
|
||||
content={
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<Paragraph variant="small/bright">What is a run?</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
A run is a single instance of a task being executed. It's created when you trigger a
|
||||
task, for example{" "}
|
||||
<InlineCode variant="extra-extra-small">
|
||||
yourTask.trigger({`{ foo: "bar" }`})
|
||||
</InlineCode>
|
||||
. Runs are durable, so they survive crashes, deploys, and restarts, and will
|
||||
automatically retry on failure.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 border-t border-grid-dimmed pt-3">
|
||||
<div>
|
||||
<Paragraph variant="small/bright">
|
||||
<InlineCode>task.trigger()</InlineCode>
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
Triggered from your backend code, an API call, or another task. Each call creates a
|
||||
single run with the payload you pass in.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Paragraph variant="small/bright">Scheduled triggers</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
Runs created automatically from a cron schedule attached to a scheduled task. Use
|
||||
them for recurring jobs like nightly syncs or hourly cleanups.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import type { Navigation, ShouldRevalidateFunction } from "@remix-run/react";
|
||||
|
||||
/** Search params that only control the bulk-action inspector UI, not list data. */
|
||||
export const RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS = ["bulkInspector", "action", "mode"] as const;
|
||||
|
||||
/** URL value set on `bulkInspector` when the inspector panel is open (presence flag). */
|
||||
export const RUNS_BULK_INSPECTOR_OPEN_VALUE = "show";
|
||||
|
||||
/** Returns a copy with bulk-inspector UI params removed. */
|
||||
export function stripBulkInspectorUiParams(params: URLSearchParams): URLSearchParams {
|
||||
const stripped = new URLSearchParams(params);
|
||||
for (const key of RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS) {
|
||||
stripped.delete(key);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/** Canonical string for list-data params (UI keys stripped, entries sorted by key). */
|
||||
export function canonicalRunsListDataSearchParams(params: URLSearchParams): string {
|
||||
const stripped = stripBulkInspectorUiParams(params);
|
||||
stripped.sort();
|
||||
return stripped.toString();
|
||||
}
|
||||
|
||||
export function searchParamsEqualIgnoringBulkInspectorUiState(
|
||||
current: URLSearchParams,
|
||||
next: URLSearchParams
|
||||
) {
|
||||
return canonicalRunsListDataSearchParams(current) === canonicalRunsListDataSearchParams(next);
|
||||
}
|
||||
|
||||
/** True when navigation should show the runs table loading state (excludes bulk-inspector UI toggles). */
|
||||
export function isRunsListLoading(navigation: Navigation, currentSearch: string): boolean {
|
||||
if (navigation.state === "idle" || !navigation.location) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(currentSearch);
|
||||
const nextParams = new URLSearchParams(navigation.location.search);
|
||||
|
||||
if (searchParamsEqualIgnoringBulkInspectorUiState(currentParams, nextParams)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip runs list loader revalidation when only bulk-inspector UI params change.
|
||||
* Explicit revalidate() (unchanged URL) and filter/pagination changes still revalidate.
|
||||
*/
|
||||
export const shouldRevalidateRunsList: ShouldRevalidateFunction = ({
|
||||
currentUrl,
|
||||
nextUrl,
|
||||
defaultShouldRevalidate,
|
||||
}) => {
|
||||
if (currentUrl.pathname !== nextUrl.pathname) {
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(currentUrl.search);
|
||||
const nextParams = new URLSearchParams(nextUrl.search);
|
||||
|
||||
if (currentParams.toString() === nextParams.toString()) {
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
if (searchParamsEqualIgnoringBulkInspectorUiState(currentParams, nextParams)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultShouldRevalidate;
|
||||
};
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import type { loader as liveRunsLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live";
|
||||
import { stripBulkInspectorUiParams } from "./shouldRevalidateRunsList";
|
||||
|
||||
const RUNS_PAGINATION_SEARCH_PARAMS = ["cursor", "direction"] as const;
|
||||
const RUNS_POLL_INTERVAL_MS = 3000;
|
||||
/** Check for new runs every N poll ticks (~6s at 3s interval). */
|
||||
const NEW_RUNS_EVERY_N_POLL_TICKS = 2;
|
||||
|
||||
type ListedRun = NextRunListItem;
|
||||
type LiveRunFields = Awaited<ReturnType<typeof liveRunsLoader>>["runs"][number];
|
||||
type LivePollFetcherData = Awaited<ReturnType<typeof liveRunsLoader>> | undefined;
|
||||
|
||||
function hasNewRunsCountFields(
|
||||
data: LivePollFetcherData
|
||||
): data is NonNullable<LivePollFetcherData> & { count: number; since: number } {
|
||||
return data !== undefined && "count" in data && "since" in data;
|
||||
}
|
||||
|
||||
function maxCreatedAtMs(runs: ListedRun[]): number | undefined {
|
||||
if (runs.length === 0) return undefined;
|
||||
|
||||
return runs.reduce<number>((maxTimestamp, run) => {
|
||||
const runTimestamp = new Date(run.createdAt).getTime();
|
||||
return Math.max(maxTimestamp, runTimestamp);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function filterParamsWithoutPagination(search: string) {
|
||||
const params = stripBulkInspectorUiParams(new URLSearchParams(search));
|
||||
for (const key of RUNS_PAGINATION_SEARCH_PARAMS) {
|
||||
params.delete(key);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function getRunsSearchKeyWithoutPagination(search: string) {
|
||||
return filterParamsWithoutPagination(search).toString();
|
||||
}
|
||||
|
||||
function isNewRunsCheckTick(tick: number) {
|
||||
return tick === 1 || tick % NEW_RUNS_EVERY_N_POLL_TICKS === 0;
|
||||
}
|
||||
|
||||
function appendNewRunsSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
{ locationSearch, since }: { locationSearch: string; since: number }
|
||||
) {
|
||||
const filterParams = filterParamsWithoutPagination(locationSearch);
|
||||
for (const [key, value] of filterParams) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
searchParams.set("includeNewRuns", "true");
|
||||
searchParams.set("since", String(since));
|
||||
}
|
||||
|
||||
function patchVisibleRunsWithLiveUpdates(currentRuns: ListedRun[], liveRuns: LiveRunFields[]) {
|
||||
const updatesById = new Map(liveRuns.map((run) => [run.friendlyId, run]));
|
||||
|
||||
return currentRuns.map((run) => {
|
||||
const update = updatesById.get(run.friendlyId);
|
||||
if (!update) return run;
|
||||
|
||||
return {
|
||||
...run,
|
||||
status: update.status,
|
||||
updatedAt: update.updatedAt,
|
||||
startedAt: update.startedAt,
|
||||
finishedAt: update.finishedAt,
|
||||
hasFinished: update.hasFinished,
|
||||
isCancellable: update.isCancellable,
|
||||
isPending: update.isPending,
|
||||
usageDurationMs: update.usageDurationMs,
|
||||
costInCents: update.costInCents,
|
||||
baseCostInCents: update.baseCostInCents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useNewRunsDetection({
|
||||
runs,
|
||||
hasAnyRuns,
|
||||
isLoading,
|
||||
}: {
|
||||
runs: ListedRun[];
|
||||
hasAnyRuns: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const pollTickRef = useRef(0);
|
||||
const [knownNewestRunMs, setKnownNewestRunMs] = useState(
|
||||
() => maxCreatedAtMs(runs) ?? Date.now()
|
||||
);
|
||||
const [newRunsCount, setNewRunsCount] = useState(0);
|
||||
|
||||
const shouldPollForNewRuns = hasAnyRuns && !isLoading && newRunsCount < 100;
|
||||
|
||||
// Re-baseline the cutoff and clear banner/throttle state. The parent calls
|
||||
// this from its single "list context changed" reset path.
|
||||
const resetNewRunsTracking = useCallback(() => {
|
||||
setKnownNewestRunMs(maxCreatedAtMs(runs) ?? Date.now());
|
||||
setNewRunsCount(0);
|
||||
pollTickRef.current = 0;
|
||||
}, [runs]);
|
||||
|
||||
const dismissNewRuns = useCallback(() => {
|
||||
setNewRunsCount(0);
|
||||
setKnownNewestRunMs(Date.now());
|
||||
pollTickRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const checkNewRunsOnTick = useCallback(() => {
|
||||
pollTickRef.current += 1;
|
||||
return shouldPollForNewRuns && isNewRunsCheckTick(pollTickRef.current);
|
||||
}, [shouldPollForNewRuns]);
|
||||
|
||||
const showNewRunsBanner = newRunsCount > 0;
|
||||
|
||||
return {
|
||||
knownNewestRunMs,
|
||||
newRunsCount,
|
||||
setNewRunsCount,
|
||||
shouldPollForNewRuns,
|
||||
showNewRunsBanner,
|
||||
dismissNewRuns,
|
||||
checkNewRunsOnTick,
|
||||
resetNewRunsTracking,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRunsLiveReload({
|
||||
runs,
|
||||
hasAnyRuns,
|
||||
isLoading,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
runs: ListedRun[];
|
||||
hasAnyRuns: boolean;
|
||||
isLoading: boolean;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const runsPollFetcher = useTypedFetcher<typeof liveRunsLoader>();
|
||||
const runsPollFetcherStateRef = useRef(runsPollFetcher.state);
|
||||
runsPollFetcherStateRef.current = runsPollFetcher.state;
|
||||
|
||||
const [visibleRuns, setVisibleRuns] = useState(runs);
|
||||
|
||||
const searchKeyWithoutPagination = useMemo(
|
||||
() => getRunsSearchKeyWithoutPagination(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
const {
|
||||
knownNewestRunMs,
|
||||
newRunsCount,
|
||||
setNewRunsCount,
|
||||
shouldPollForNewRuns,
|
||||
showNewRunsBanner,
|
||||
dismissNewRuns,
|
||||
checkNewRunsOnTick,
|
||||
resetNewRunsTracking,
|
||||
} = useNewRunsDetection({
|
||||
runs,
|
||||
hasAnyRuns,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
// Single reset path: new loader data or changed filters re-baseline both the
|
||||
// visible rows and new-run tracking.
|
||||
useEffect(() => {
|
||||
setVisibleRuns(runs);
|
||||
resetNewRunsTracking();
|
||||
}, [runs, searchKeyWithoutPagination, resetNewRunsTracking]);
|
||||
|
||||
// Patch visible rows from the live response. Keyed to the response alone so a
|
||||
// loader refresh never re-applies a stale poll payload over fresh rows.
|
||||
useEffect(() => {
|
||||
const data = runsPollFetcher.data;
|
||||
if (!data?.runs.length) return;
|
||||
|
||||
setVisibleRuns((currentRuns) => patchVisibleRunsWithLiveUpdates(currentRuns, data.runs));
|
||||
}, [runsPollFetcher.data]);
|
||||
|
||||
// Update new-runs count from the poll response. Re-evaluates when the cutoff
|
||||
// changes, even if the response object itself is unchanged.
|
||||
useEffect(() => {
|
||||
const data = runsPollFetcher.data;
|
||||
if (!hasNewRunsCountFields(data)) return;
|
||||
|
||||
if (data.since === knownNewestRunMs) {
|
||||
setNewRunsCount(data.count);
|
||||
}
|
||||
}, [runsPollFetcher.data, knownNewestRunMs, setNewRunsCount]);
|
||||
|
||||
const activeRunIdsParam = useMemo(
|
||||
() =>
|
||||
visibleRuns
|
||||
.filter((run) => !run.hasFinished)
|
||||
.map((run) => run.friendlyId)
|
||||
.join(","),
|
||||
[visibleRuns]
|
||||
);
|
||||
const hasActiveRuns = activeRunIdsParam.length > 0;
|
||||
|
||||
const runsResourcesBasePath = useMemo(
|
||||
() => `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/runs`,
|
||||
[organizationSlug, projectSlug, environmentSlug]
|
||||
);
|
||||
|
||||
const loadRunsPoll = useCallback(
|
||||
(checkForNewRuns: boolean) => {
|
||||
if (runsPollFetcherStateRef.current !== "idle") return;
|
||||
|
||||
if (!hasActiveRuns && !checkForNewRuns) return;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (hasActiveRuns) {
|
||||
searchParams.set("runIds", activeRunIdsParam);
|
||||
}
|
||||
|
||||
if (checkForNewRuns) {
|
||||
appendNewRunsSearchParams(searchParams, {
|
||||
locationSearch: location.search,
|
||||
since: knownNewestRunMs,
|
||||
});
|
||||
}
|
||||
|
||||
runsPollFetcher.load(`${runsResourcesBasePath}/live?${searchParams.toString()}`);
|
||||
},
|
||||
[
|
||||
activeRunIdsParam,
|
||||
hasActiveRuns,
|
||||
location.search,
|
||||
knownNewestRunMs,
|
||||
runsPollFetcher,
|
||||
runsResourcesBasePath,
|
||||
]
|
||||
);
|
||||
|
||||
const shouldPoll = !isLoading && (hasActiveRuns || shouldPollForNewRuns);
|
||||
|
||||
useInterval({
|
||||
interval: RUNS_POLL_INTERVAL_MS,
|
||||
onLoad: true,
|
||||
pauseWhenHidden: true,
|
||||
disabled: !shouldPoll,
|
||||
callback: () => {
|
||||
loadRunsPoll(checkNewRunsOnTick());
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
visibleRuns,
|
||||
showNewRunsBanner,
|
||||
newRunsCount,
|
||||
dismissNewRuns,
|
||||
childrenStatusesBasePath: runsResourcesBasePath,
|
||||
};
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { v3EnvironmentPath, v3ScheduleParams, v3SchedulePath } from "~/utils/pathBuilder";
|
||||
import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server";
|
||||
import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, scheduleParam } =
|
||||
v3ScheduleParams.parse(params);
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return redirectWithErrorMessage("/", request, "Environment not found");
|
||||
}
|
||||
|
||||
const presenter = new ViewSchedulePresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
friendlyId: scheduleParam,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
// Return null (not a 404 throw) so fetcher-driven hosts (e.g. the sheet
|
||||
// running this loader after a delete-in-flight) don't surface a
|
||||
// page-level error boundary. The standalone Page below renders a
|
||||
// not-found message when `schedule` is null.
|
||||
return typedjson({ schedule: result?.schedule ?? null });
|
||||
};
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("enable"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("disable"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam, scheduleParam } =
|
||||
v3ScheduleParams.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
// `_format=json` → return JSON instead of redirecting; caller stays put.
|
||||
const wantsJson = formData.get("_format") === "json";
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
const message = `No project found with slug ${projectParam}`;
|
||||
if (wantsJson) {
|
||||
return json({ ok: false as const, message }, { status: 404 });
|
||||
}
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "delete": {
|
||||
const deleteService = new DeleteTaskScheduleService();
|
||||
try {
|
||||
await deleteService.call({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
if (wantsJson) {
|
||||
return json({ ok: true as const, message: `${scheduleParam} deleted` });
|
||||
}
|
||||
return redirectWithSuccessMessage(
|
||||
v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`${scheduleParam} deleted`
|
||||
);
|
||||
} catch (e) {
|
||||
const message = `${scheduleParam} could not be deleted: ${
|
||||
e instanceof Error ? e.message : JSON.stringify(e)
|
||||
}`;
|
||||
if (wantsJson) {
|
||||
return json({ ok: false as const, message }, { status: 500 });
|
||||
}
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
case "enable":
|
||||
case "disable": {
|
||||
const service = new SetActiveOnTaskScheduleService();
|
||||
const active = submission.value.action === "enable";
|
||||
try {
|
||||
await service.call({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
friendlyId: scheduleParam,
|
||||
active,
|
||||
});
|
||||
if (wantsJson) {
|
||||
return json({ ok: true as const, active });
|
||||
}
|
||||
return redirectWithSuccessMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
`${scheduleParam} ${active ? "enabled" : "disabled"}`
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : JSON.stringify(e);
|
||||
if (wantsJson) {
|
||||
return json({ ok: false as const, message }, { status: 500 });
|
||||
}
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { schedule } = useTypedLoaderData<typeof loader>();
|
||||
const location = useLocation();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-bright p-6">
|
||||
<p className="text-sm text-text-bright">Schedule not found.</p>
|
||||
<LinkButton
|
||||
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
|
||||
variant="secondary/small"
|
||||
>
|
||||
Back to tasks
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScheduleInspector
|
||||
schedule={schedule}
|
||||
headerActions={
|
||||
<LinkButton
|
||||
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { EnvironmentParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
|
||||
|
||||
/**
|
||||
* The standalone `/schedules` listing page was removed when the unified
|
||||
* Tasks page subsumed the Agents / Standard / Schedules listings. This
|
||||
* thin redirect catches bookmarks and shared links pointing at the old
|
||||
* URL and sends users to the Tasks page pre-filtered to Scheduled tasks.
|
||||
*
|
||||
* Individual schedule routes (`/schedules/:scheduleParam`,
|
||||
* `/schedules/edit/:scheduleParam`, `/schedules/new`) live in sibling
|
||||
* route files and are unaffected.
|
||||
*/
|
||||
export async function loader({ params }: LoaderFunctionArgs) {
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const tasksPath = v3EnvironmentPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
return redirect(`${tasksPath}?types=SCHEDULED`);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { v3EnvironmentPath, v3ScheduleParams } from "~/utils/pathBuilder";
|
||||
import { humanToCronSupported } from "~/v3/humanToCron.server";
|
||||
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, scheduleParam } =
|
||||
v3ScheduleParams.parse(params);
|
||||
|
||||
const presenter = new EditSchedulePresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
|
||||
if (result.schedule?.type === "DECLARATIVE") {
|
||||
throw redirect(
|
||||
v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam })
|
||||
);
|
||||
}
|
||||
|
||||
return typedjson({ ...result, showGenerateField: humanToCronSupported });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<UpsertScheduleForm
|
||||
schedule={schedule}
|
||||
possibleTasks={possibleTasks}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
possibleTimezones={possibleTimezones}
|
||||
showGenerateField={showGenerateField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { humanToCronSupported } from "~/v3/humanToCron.server";
|
||||
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const {
|
||||
projectParam,
|
||||
envParam,
|
||||
organizationSlug: _organizationSlug,
|
||||
} = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const presenter = new EditSchedulePresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
});
|
||||
|
||||
return typedjson({ ...result, showGenerateField: humanToCronSupported });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<UpsertScheduleForm
|
||||
schedule={schedule}
|
||||
possibleTasks={possibleTasks}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
showGenerateField={showGenerateField}
|
||||
possibleTimezones={possibleTimezones}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+1060
File diff suppressed because it is too large
Load Diff
+165
@@ -0,0 +1,165 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { SessionFilters } from "~/components/sessions/v1/SessionFilters";
|
||||
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
|
||||
import { SessionsNone } from "~/components/BlankStatePanels";
|
||||
import { $replica } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getSessionFiltersFromRequest } from "~/presenters/SessionFilters.server";
|
||||
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { throwNotFound } from "~/utils/httpErrors";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Sessions | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throwNotFound("Environment not found");
|
||||
}
|
||||
|
||||
const filters = getSessionFiltersFromRequest(request);
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new SessionListPresenter($replica, clickhouse);
|
||||
const list = await presenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
statuses: filters.statuses,
|
||||
types: filters.types,
|
||||
taskIdentifiers: filters.taskIdentifiers,
|
||||
externalId: filters.externalId,
|
||||
tags: filters.tags,
|
||||
period: filters.period,
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
cursor: filters.cursor,
|
||||
direction: filters.direction,
|
||||
});
|
||||
|
||||
return typedjson(list);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const list = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle title="Sessions" accessory={<SessionsHelpTooltip />} />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("ai-chat/sessions")}
|
||||
>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{!list.hasAnySessions ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<SessionsNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<SessionFilters hasFilters={list.hasFilters} possibleTasks={list.possibleTasks} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination: list.pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<SessionsTable
|
||||
sessions={list.sessions}
|
||||
filters={list.filters}
|
||||
hasFilters={list.hasFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsHelpTooltip() {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<QuestionMarkIcon className="size-4 text-text-dimmed transition hover:text-text-bright" />
|
||||
}
|
||||
side="bottom"
|
||||
className="max-w-sm p-3"
|
||||
disableHoverableContent
|
||||
content={
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<Paragraph variant="small/bright">What is a session?</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
A session is a stateful execution of an agent, with two-way streaming and durable
|
||||
compute. A single session can have multiple runs associated with it, so one
|
||||
conversation can span many task triggers. The input stream carries incoming user
|
||||
messages, and the output stream carries everything the agent produces, including AI
|
||||
generation parts (text, reasoning, tool calls, etc.) and any custom data parts your
|
||||
task emits.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 border-t border-grid-dimmed pt-3">
|
||||
<div>
|
||||
<Paragraph variant="small/bright">
|
||||
<InlineCode>chat.agent</InlineCode>
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
The high-level chat building block. Built on sessions and handles the chat turn loop
|
||||
for you. Use it for chat apps and conversational AI experiences.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Paragraph variant="small/bright">
|
||||
<InlineCode>sessions.start()</InlineCode>
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="mt-1">
|
||||
The raw sessions API. Use it for non-chat patterns like agent inboxes, approval
|
||||
flows, or server-to-server streaming where you need a durable bi-directional
|
||||
channel.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
|
||||
import { useState } from "react";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conformZodMessage.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json(
|
||||
{ errors: { body: "organizationSlug and projectParam are required" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
const resultOrFail = await projectSettingsService.renameProject(
|
||||
projectId,
|
||||
submission.value.projectName
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to rename project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to delete project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const project = useProject();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteInputValue, setDeleteInputValue] = useState("");
|
||||
|
||||
return (
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Header2 spacing>General</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<Fieldset className="mb-5">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Project ref</Label>
|
||||
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
|
||||
<Hint>
|
||||
This goes in your{" "}
|
||||
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<Form method="post" {...getFormProps(renameForm)}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...getInputProps(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Project name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setHasRenameFormChanges(e.target.value !== project.name);
|
||||
}}
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.errors}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="rename"
|
||||
variant={"secondary/small"}
|
||||
disabled={isRenameLoading || !hasRenameFormChanges}
|
||||
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<div className="w-full rounded-sm border border-rose-500/40 p-4">
|
||||
<Form method="post" {...getFormProps(deleteForm)}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...getInputProps(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon={ExclamationTriangleIcon}
|
||||
onChange={(e) => setDeleteInputValue(e.target.value)}
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.errors}</FormError>
|
||||
<FormError>{deleteForm.errors}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading || deleteInputValue !== project.slug}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
);
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import { throwPermissionDenied } from "~/utils/permissionDenied";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import {
|
||||
VercelOnboardingModal,
|
||||
VercelSettingsPanel,
|
||||
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: EnvironmentParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
// No hard authorization: the page renders a PermissionDenied panel for
|
||||
// roles that can't manage any integration (see canManageIntegrations).
|
||||
},
|
||||
async ({ params, user, ability }) => {
|
||||
const { projectParam, organizationSlug } = params;
|
||||
|
||||
const canManageIntegrations =
|
||||
ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" });
|
||||
|
||||
if (!canManageIntegrations) {
|
||||
throwPermissionDenied("With your current role, you can't manage integrations.");
|
||||
}
|
||||
|
||||
const projectSettingsPresenter = new ProjectSettingsPresenter();
|
||||
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "project_not_found": {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed loading project settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, please try again!",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { gitHubApp, buildSettings } = resultOrFail.value;
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Config file path must not exceed 255 characters",
|
||||
}),
|
||||
installCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Install command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Install command must not exceed 500 characters",
|
||||
}),
|
||||
preBuildCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Pre-build command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Pre-build command must not exceed 500 characters",
|
||||
}),
|
||||
useNativeBuildServer: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: EnvironmentParamSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
// Build settings configure the Git-based deploy, so gate on write:github
|
||||
// (a restricted role can view neither this page nor mutate via a POST).
|
||||
authorization: { action: "write", resource: { type: "github" } },
|
||||
},
|
||||
async ({ request, params, user }) => {
|
||||
const { organizationSlug, projectParam } = params;
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema: UpdateBuildSettingsFormSchema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
|
||||
installCommand: installCommand || undefined,
|
||||
preBuildCommand: preBuildCommand || undefined,
|
||||
triggerConfigFilePath: triggerConfigFilePath || undefined,
|
||||
useNativeBuildServer: useNativeBuildServer,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update build settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update build settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
|
||||
}
|
||||
);
|
||||
|
||||
export default function IntegrationsSettingsPage() {
|
||||
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Vercel onboarding modal state
|
||||
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Ensure query param is present to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
const closeVercelOnboarding = useCallback(() => {
|
||||
// Remove query param if present
|
||||
if (hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete("vercelOnboarding");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// Close modal
|
||||
setIsModalOpen(false);
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
// When query param is present, handle modal opening
|
||||
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelIntegrationEnabled) {
|
||||
// Ensure query param is present and modal is open
|
||||
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data is loaded, ensure modal is open (query param takes precedence)
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [
|
||||
hasQueryParam,
|
||||
vercelIntegrationEnabled,
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug,
|
||||
vercelFetcher.data,
|
||||
vercelFetcher.state,
|
||||
isModalOpen,
|
||||
openVercelOnboarding,
|
||||
]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
// This is a safeguard to prevent the modal from closing during form submissions
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && !isModalOpen) {
|
||||
// Query param is present but modal is closed, open it
|
||||
// This ensures the modal stays open during the onboarding flow
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// When data finishes loading (from query param), ensure modal is open
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded and query param is present, ensure modal is open
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
|
||||
// Handle opening modal from button click (without query param)
|
||||
const handleOpenVercelModal = useCallback(() => {
|
||||
// Add query param to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
|
||||
// Data already loaded, open modal immediately
|
||||
openVercelOnboarding();
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
}, [
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug,
|
||||
vercelFetcher,
|
||||
setSearchParams,
|
||||
hasQueryParam,
|
||||
openVercelOnboarding,
|
||||
]);
|
||||
|
||||
// When data loads from button click, open modal
|
||||
useEffect(() => {
|
||||
if (
|
||||
waitingForButtonClickRef.current &&
|
||||
vercelFetcher.data?.onboardingData &&
|
||||
vercelFetcher.state === "idle"
|
||||
) {
|
||||
// Data loaded from button click, open modal and ensure query param is present
|
||||
waitingForButtonClickRef.current = false;
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{githubAppEnabled && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vercelIntegrationEnabled && (
|
||||
<div>
|
||||
<Header2 spacing>Vercel integration</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<VercelSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
onOpenVercelModal={handleOpenVercelModal}
|
||||
isLoadingVercelData={
|
||||
vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Build settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
|
||||
{/* Vercel Onboarding Modal */}
|
||||
{vercelIntegrationEnabled && (
|
||||
<VercelOnboardingModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeVercelOnboarding}
|
||||
onboardingData={vercelFetcher.data?.onboardingData ?? null}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
|
||||
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
|
||||
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true${
|
||||
vercelEnvironmentId
|
||||
? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
|
||||
const [buildSettingsValues, setBuildSettingsValues] = useState({
|
||||
preBuildCommand: buildSettings?.preBuildCommand || "",
|
||||
installCommand: buildSettings?.installCommand || "",
|
||||
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
|
||||
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
|
||||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
|
||||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
const [buildSettingsForm, fields] = useForm({
|
||||
id: "update-build-settings",
|
||||
lastResult: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, {
|
||||
schema: UpdateBuildSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBuildSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-build-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<Form method="post" {...getFormProps(buildSettingsForm)}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
|
||||
<Input
|
||||
{...getInputProps(fields.triggerConfigFilePath, { type: "text" })}
|
||||
defaultValue={buildSettings?.triggerConfigFilePath || ""}
|
||||
placeholder="trigger.config.ts"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
triggerConfigFilePath: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Path to your Trigger configuration file, relative to the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.triggerConfigFilePath.errorId}>
|
||||
{fields.triggerConfigFilePath.errors}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.installCommand.id}>Install command</Label>
|
||||
<Input
|
||||
{...getInputProps(fields.installCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.installCommand || ""}
|
||||
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
installCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Command to install your project dependencies. This will be run from the root directory
|
||||
of your repo. Auto-detected by default.
|
||||
</Hint>
|
||||
<FormError id={fields.installCommand.errorId}>
|
||||
{fields.installCommand.errors?.join(", ")}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
|
||||
<Input
|
||||
{...getInputProps(fields.preBuildCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.preBuildCommand || ""}
|
||||
placeholder="e.g., `npm run prisma:generate`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
preBuildCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Any command that needs to run before we build and deploy your project. This will be run
|
||||
from the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.preBuildCommand.errorId}>
|
||||
{fields.preBuildCommand.errors?.join(", ")}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
<div className="border-t border-grid-dimmed pt-4">
|
||||
<InputGroup>
|
||||
<CheckboxWithLabel
|
||||
{...getInputProps(fields.useNativeBuildServer, { type: "checkbox" })}
|
||||
label="Use native build server"
|
||||
variant="simple/small"
|
||||
defaultChecked={buildSettings?.useNativeBuildServer || false}
|
||||
onChange={(isChecked) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
useNativeBuildServer: isChecked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.errors}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<FormError>{buildSettingsForm.errors}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-build-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
|
||||
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Outlet, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsGeneralPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Project settings | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
// Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding)
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) {
|
||||
const org = { slug: organizationSlug };
|
||||
const project = { slug: projectParam };
|
||||
const env = { slug: envParam };
|
||||
|
||||
const basePath = url.searchParams.has("vercelOnboarding")
|
||||
? v3ProjectSettingsIntegrationsPath(org, project, env)
|
||||
: v3ProjectSettingsGeneralPath(org, project, env);
|
||||
|
||||
return redirect(`${basePath}${url.search}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Project settings" />
|
||||
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>{project.id}</Property.Value>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{project.id}</Paragraph>
|
||||
</div>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Org ID</Property.Label>
|
||||
<Property.Value>{project.organizationId}</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Card } from "~/components/primitives/charts/Card";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Header1, Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
tasksDashboardPresenter,
|
||||
type DailyRunPoint,
|
||||
} from "~/presenters/v3/TasksDashboardPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3EnvironmentPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Tasks | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
const result = await tasksDashboardPresenter.call({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return typeddefer(result);
|
||||
};
|
||||
|
||||
const isoDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "utc",
|
||||
});
|
||||
|
||||
function formatDay(value: string) {
|
||||
// value is a YYYY-MM-DD date string
|
||||
const d = new Date(value + "T00:00:00Z");
|
||||
return isoDateFormatter.format(d);
|
||||
}
|
||||
|
||||
const STANDARD_EXAMPLE = `import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "hello-world",
|
||||
run: async (payload: { name: string }) => {
|
||||
return { greeting: \`Hello, \${payload.name}!\` };
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const SCHEDULED_EXAMPLE = `import { schedules } from "@trigger.dev/sdk";
|
||||
|
||||
export const dailyReport = schedules.task({
|
||||
id: "daily-report",
|
||||
cron: "0 9 * * *",
|
||||
run: async (payload) => {
|
||||
// Runs every day at 9am UTC
|
||||
return { ranAt: payload.timestamp };
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const AGENT_EXAMPLE = `import { agent } from "@trigger.dev/sdk/ai";
|
||||
|
||||
export const supportAgent = agent({
|
||||
id: "support-agent",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
instructions: "You are a helpful customer support agent.",
|
||||
});
|
||||
`;
|
||||
|
||||
export default function TasksDashboardPage() {
|
||||
const { counts, series } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Tasks" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-6">
|
||||
<Header1 className="text-text-bright">Tasks overview</Header1>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Suspense fallback={<PanelSkeleton title="Agent tasks" />}>
|
||||
<TypedAwait resolve={series}>
|
||||
{(s) => (
|
||||
<TaskTypePanel
|
||||
title="Agent tasks"
|
||||
count={counts.agents}
|
||||
description="AI agents are tasks that can call LLMs, use tools, and run multi-step conversations. Use them to power chat experiences, intelligent automations, or autonomous workflows."
|
||||
example="A support agent that drafts replies using your docs as context."
|
||||
data={s.agents}
|
||||
seriesColor="hsl(280 80% 65%)"
|
||||
listingPath={v3EnvironmentPath(organization, project, environment)}
|
||||
docsHref={docsPath("v3/agents")}
|
||||
emptyTitle="You don't have any agents yet"
|
||||
emptyDescription="Create an AI agent to call LLMs, use tools, and run multi-step conversations from a Trigger.dev task."
|
||||
emptyCta="Create your first agent"
|
||||
exampleCode={AGENT_EXAMPLE}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
<Suspense fallback={<PanelSkeleton title="Standard tasks" />}>
|
||||
<TypedAwait resolve={series}>
|
||||
{(s) => (
|
||||
<TaskTypePanel
|
||||
title="Standard tasks"
|
||||
count={counts.standard}
|
||||
description="Standard tasks are durable background functions you trigger from your code. Use them for any async work that needs retries, observability, and reliable execution."
|
||||
example="Process an uploaded video, send a transactional email, or sync data from a third-party API."
|
||||
data={s.standard}
|
||||
seriesColor="hsl(200 80% 60%)"
|
||||
listingPath={v3RunsPath(organization, project, environment)}
|
||||
docsHref={docsPath("v3/tasks-overview")}
|
||||
emptyTitle="You don't have any standard tasks yet"
|
||||
emptyDescription="Standard tasks are the building block of Trigger.dev. Define one in your codebase and trigger it from anywhere."
|
||||
emptyCta="Create your first task"
|
||||
exampleCode={STANDARD_EXAMPLE}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
<Suspense fallback={<PanelSkeleton title="Scheduled tasks" />}>
|
||||
<TypedAwait resolve={series}>
|
||||
{(s) => (
|
||||
<TaskTypePanel
|
||||
title="Scheduled tasks"
|
||||
count={counts.scheduled}
|
||||
description="Scheduled tasks run automatically on a cron schedule. Attach as many schedules to a task as you need (e.g. one per customer)."
|
||||
example="Generate a nightly report, refresh a cache every 5 minutes, or send a weekly digest email."
|
||||
data={s.scheduled}
|
||||
seriesColor="hsl(30 90% 60%)"
|
||||
listingPath={v3EnvironmentPath(organization, project, environment)}
|
||||
docsHref={docsPath("v3/tasks-scheduled")}
|
||||
emptyTitle="You don't have any scheduled tasks yet"
|
||||
emptyDescription="Schedule a task to run on a cron expression. Once you've defined a `schedules.task` you can attach one or many schedules to it."
|
||||
emptyCta="Create your first scheduled task"
|
||||
exampleCode={SCHEDULED_EXAMPLE}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSkeleton({ title }: { title: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>{title}</Card.Header>
|
||||
<Card.Content className="flex h-72 items-center justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type TaskTypePanelProps = {
|
||||
title: string;
|
||||
count: number;
|
||||
description: string;
|
||||
example: string;
|
||||
data: DailyRunPoint[];
|
||||
seriesColor: string;
|
||||
listingPath: string;
|
||||
docsHref: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyCta: string;
|
||||
exampleCode: string;
|
||||
};
|
||||
|
||||
function TaskTypePanel(props: TaskTypePanelProps) {
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
description,
|
||||
example,
|
||||
data,
|
||||
seriesColor,
|
||||
listingPath,
|
||||
docsHref,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyCta,
|
||||
exampleCode,
|
||||
} = props;
|
||||
|
||||
const hasData = count > 0;
|
||||
|
||||
const chartConfig = useMemo<ChartConfig>(
|
||||
() => ({
|
||||
count: {
|
||||
label: "Runs",
|
||||
color: seriesColor,
|
||||
},
|
||||
}),
|
||||
[seriesColor]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<span className="truncate">{title}</span>
|
||||
<Card.Accessory>
|
||||
<span className="shrink-0 whitespace-nowrap text-base font-medium tabular-nums leading-none text-text-bright">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<Card.Content className="flex flex-col gap-3">
|
||||
<div className="h-40">
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey="day"
|
||||
labelFormatter={(value) => formatDay(value as string)}
|
||||
fillContainer
|
||||
>
|
||||
<Chart.Bar
|
||||
tooltipLabelFormatter={(label) => formatDay(label)}
|
||||
xAxisProps={{ tickFormatter: (value) => formatDay(value) }}
|
||||
/>
|
||||
</Chart.Root>
|
||||
</div>
|
||||
{hasData ? (
|
||||
<>
|
||||
<Paragraph variant="small" className="px-2 text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
<div className="flex flex-wrap items-center gap-2 px-2 pb-2 pt-1">
|
||||
<LinkButton to={listingPath} variant="secondary/small">
|
||||
View all
|
||||
</LinkButton>
|
||||
<LinkButton to={docsHref} variant="docs/small" LeadingIcon={BookOpenIcon}>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyContent
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
example={example}
|
||||
listingPath={listingPath}
|
||||
docsHref={docsHref}
|
||||
cta={emptyCta}
|
||||
exampleCode={exampleCode}
|
||||
/>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({
|
||||
title,
|
||||
description,
|
||||
example,
|
||||
listingPath,
|
||||
docsHref,
|
||||
cta,
|
||||
exampleCode,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
example: string;
|
||||
listingPath: string;
|
||||
docsHref: string;
|
||||
cta: string;
|
||||
exampleCode: string;
|
||||
}) {
|
||||
const copyExample = () => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(exampleCode).catch(() => {
|
||||
/* swallow */
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header3 className="px-2 text-text-bright">{title}</Header3>
|
||||
<Paragraph variant="small" className="px-2 text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
<div className="flex flex-wrap items-center gap-2 px-2 pb-2 pt-1">
|
||||
<LinkButton to={listingPath} variant="primary/small">
|
||||
{cta}
|
||||
</LinkButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyExample}
|
||||
className="inline-flex items-center rounded border border-grid-bright bg-background-bright px-2 py-1 text-xs text-text-dimmed transition-colors hover:text-text-bright"
|
||||
>
|
||||
Copy example code
|
||||
</button>
|
||||
<LinkButton to={docsHref} variant="docs/small" LeadingIcon={BookOpenIcon}>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1081
File diff suppressed because it is too large
Load Diff
+432
@@ -0,0 +1,432 @@
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ChartCard } from "~/components/primitives/charts/ChartCard";
|
||||
import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext";
|
||||
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
|
||||
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
|
||||
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
|
||||
import { statusColor } from "~/components/primitives/charts/statusColors";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import {
|
||||
TaskDetailPresenter,
|
||||
type TaskActivity,
|
||||
type TaskDetail,
|
||||
} from "~/presenters/v3/TaskDetailPresenter.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
v3EnvironmentPath,
|
||||
v3QueuesPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { parseFiniteInt } from "~/utils/searchParams";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
|
||||
return [{ title: slug ? `${slug} | Tasks | Trigger.dev` : "Task | Trigger.dev" }];
|
||||
};
|
||||
|
||||
const ParamsSchema = EnvironmentParamSchema.extend({
|
||||
taskParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
const { organizationSlug, projectParam, envParam, taskParam } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) throw new Response("Project not found", { status: 404 });
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) throw new Response("Environment not found", { status: 404 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const from = parseFiniteInt(url.searchParams.get("from"));
|
||||
const to = parseFiniteInt(url.searchParams.get("to"));
|
||||
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||
const directionRaw = url.searchParams.get("direction") ?? undefined;
|
||||
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
|
||||
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
|
||||
const presenter = new TaskDetailPresenter($replica, clickhouse);
|
||||
const task = await presenter.findTask({
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
taskSlug: taskParam,
|
||||
expectedTriggerSource: "STANDARD",
|
||||
});
|
||||
|
||||
if (!task) throw new Response("Task not found", { status: 404 });
|
||||
|
||||
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
|
||||
|
||||
const activity = presenter
|
||||
.getActivity({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
taskSlug: task.slug,
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
})
|
||||
.catch(() => ({ data: [], statuses: [] }) satisfies TaskActivity);
|
||||
|
||||
const runList = new NextRunListPresenter($replica, clickhouse)
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks: [task.slug],
|
||||
versions: versions.length > 0 ? versions : undefined,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
return typeddefer({
|
||||
task,
|
||||
activity,
|
||||
runList,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { task, activity, runList } = useTypedLoaderData<typeof loader>();
|
||||
const zoomToTimeFilter = useZoomToTimeFilter();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const tasksListingPath = v3EnvironmentPath(organization, project, environment);
|
||||
const testPath = v3TestTaskPath(organization, project, environment, {
|
||||
taskIdentifier: task.slug,
|
||||
});
|
||||
const queuesPath = v3QueuesPath(organization, project, environment);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{ to: tasksListingPath, text: "Tasks" }}
|
||||
title={
|
||||
<span className="flex items-center gap-1">
|
||||
<TaskIcon className="size-4.5 text-tasks" />
|
||||
<span>{task.slug}</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="task-main" min="300px">
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Top bar — title on the left; TimeFilter + pagination on the right.
|
||||
h-10 matches the right-hand sidebar header height. */}
|
||||
<div className="flex h-10 items-center border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
|
||||
<Header2>Runs</Header2>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<TimeFilter defaultPeriod="7d" labelName="Runs" />
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={runList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup orientation="vertical" className="max-h-full">
|
||||
{/* Activity chart */}
|
||||
<ResizablePanel id="task-activity" min="220px" default="320px">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background p-2">
|
||||
<ChartSyncProvider onZoom={zoomToTimeFilter}>
|
||||
<ChartCard title="Runs by status">
|
||||
<Suspense fallback={<ActivityChartSkeleton />}>
|
||||
<TypedAwait resolve={activity} errorElement={<ActivityChartSkeleton />}>
|
||||
{(result) => <ActivityChart activity={result} />}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</ChartCard>
|
||||
</ChartSyncProvider>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle id="task-activity-handle" />
|
||||
|
||||
{/* Runs table */}
|
||||
<ResizablePanel id="task-content" min="160px">
|
||||
<div className="h-full overflow-hidden">
|
||||
<Suspense fallback={<TableLoading />}>
|
||||
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
|
||||
{(list) =>
|
||||
list ? (
|
||||
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
variant="dimmed"
|
||||
showTopBorder={false}
|
||||
stickyHeader
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TableLoading />
|
||||
)
|
||||
}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle id="task-detail-handle" />
|
||||
<ResizablePanel id="task-detail" min="280px" default="380px" max="500px" isStaticAtRest>
|
||||
<TaskDetailSidebar task={task} testPath={testPath} queuesPath={queuesPath} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailSidebar({
|
||||
task,
|
||||
testPath,
|
||||
queuesPath,
|
||||
}: {
|
||||
task: TaskDetail;
|
||||
testPath: string;
|
||||
queuesPath: string;
|
||||
}) {
|
||||
const showExportName = task.exportName && task.exportName !== task.slug;
|
||||
const retrySummary = formatRetrySummary(task.retry);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2 className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TaskIcon className="size-4.5 shrink-0 text-tasks" />
|
||||
<span className="truncate">{task.slug}</span>
|
||||
</Header2>
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
to={testPath}
|
||||
LeadingIcon={BeakerIcon}
|
||||
iconSpacing="gap-x-2"
|
||||
leadingIconClassName="-mx-2"
|
||||
className="shrink-0"
|
||||
>
|
||||
Test task
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Identifier</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.slug} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>File path</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.filePath} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{showExportName ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Export name</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.exportName ?? ""} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
{task.description ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Description</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">{task.description}</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">Standard task</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{task.workerVersion ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small" className="font-mono">
|
||||
{task.workerVersion}
|
||||
</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
{task.queue ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Queue</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TextLink to={queuesPath}>{task.queue.name}</TextLink>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Concurrency: {task.queue.concurrencyLimit ?? "Unlimited"}
|
||||
{task.queue.paused ? " · Paused" : ""}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
<Property.Item>
|
||||
<Property.Label>Machine</Property.Label>
|
||||
<Property.Value className="-ml-0.5">
|
||||
<MachineLabelCombo preset={task.machinePreset} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Max duration</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">
|
||||
{task.maxDurationInSeconds
|
||||
? `${task.maxDurationInSeconds}s (${formatDurationMilliseconds(
|
||||
task.maxDurationInSeconds * 1000,
|
||||
{ style: "short" }
|
||||
)})`
|
||||
: "–"}
|
||||
</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>TTL</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">{task.ttl ?? "–"}</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Retry</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">{retrySummary}</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Payload schema</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">{task.hasPayloadSchema ? "Yes" : "–"}</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={task.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRetrySummary(retry: TaskDetail["retry"]): string {
|
||||
if (!retry || retry.maxAttempts === undefined) return "–";
|
||||
if (retry.maxAttempts <= 1) return "Disabled";
|
||||
return `${retry.maxAttempts} attempts`;
|
||||
}
|
||||
|
||||
function ActivityChart({ activity }: { activity: TaskActivity }) {
|
||||
const chartConfig: ChartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
for (const status of activity.statuses) {
|
||||
cfg[status] = {
|
||||
label: status.charAt(0) + status.slice(1).toLowerCase(),
|
||||
color: statusColor(status),
|
||||
};
|
||||
}
|
||||
return cfg;
|
||||
}, [activity.statuses]);
|
||||
|
||||
const { tickFormatter, tooltipLabelFormatter } = useMemo(
|
||||
() => buildActivityTimeAxis(activity.data),
|
||||
[activity.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={activity.data}
|
||||
dataKey="bucket"
|
||||
series={activity.statuses}
|
||||
fillContainer
|
||||
>
|
||||
<Chart.Bar
|
||||
stackId="status"
|
||||
barRadius={0}
|
||||
xAxisProps={{ tickFormatter }}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChartSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
|
||||
{Array.from({ length: 42 }).map((_, i) => (
|
||||
<div key={i} className="h-full flex-1 bg-background-dimmed" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableLoading() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { TasksStreamPresenter } from "~/presenters/v3/TasksStreamPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const presenter = new TasksStreamPresenter();
|
||||
return presenter.call({
|
||||
request,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
organizationSlug,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { ClipboardIcon } from "@heroicons/react/24/outline";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "result"; success: true; payload: string }
|
||||
| { type: "result"; success: false; error: string };
|
||||
|
||||
export function AIPayloadTabContent({
|
||||
onPayloadGenerated,
|
||||
payloadSchema,
|
||||
taskIdentifier,
|
||||
getCurrentPayload,
|
||||
generateButtonLabel = "Generate payload",
|
||||
placeholder,
|
||||
examplePromptsOverride,
|
||||
isAgent = false,
|
||||
showExamplePromptsHeader = true,
|
||||
}: {
|
||||
onPayloadGenerated: (payload: string) => void;
|
||||
payloadSchema?: unknown;
|
||||
taskIdentifier: string;
|
||||
getCurrentPayload?: () => string;
|
||||
generateButtonLabel?: string;
|
||||
placeholder?: string;
|
||||
examplePromptsOverride?: string[];
|
||||
isAgent?: boolean;
|
||||
showExamplePromptsHeader?: boolean;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isLoadingRef = useRef(false);
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
||||
const [lastPayload, setLastPayload] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/test/ai-generate-payload`;
|
||||
|
||||
const submitGeneration = useCallback(
|
||||
async (queryPrompt: string) => {
|
||||
if (!queryPrompt.trim() || isLoadingRef.current) return;
|
||||
|
||||
isLoadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setThinking("");
|
||||
setError(null);
|
||||
setShowThinking(true);
|
||||
setLastResult(null);
|
||||
setLastPayload(null);
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("prompt", queryPrompt);
|
||||
formData.append("taskIdentifier", taskIdentifier);
|
||||
formData.append("isAgent", isAgent ? "true" : "false");
|
||||
if (payloadSchema) {
|
||||
formData.append("payloadSchema", JSON.stringify(payloadSchema));
|
||||
}
|
||||
const currentPayload = getCurrentPayload?.();
|
||||
if (currentPayload) {
|
||||
formData.append("currentPayload", currentPayload);
|
||||
}
|
||||
|
||||
const response = await fetch(resourcePath, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
setError(errorData.error || "Failed to generate payload");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setError("No response stream");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setLastResult("error");
|
||||
} finally {
|
||||
isLoadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[resourcePath, taskIdentifier, payloadSchema, getCurrentPayload, isAgent]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
onPayloadGenerated(event.payload);
|
||||
setLastPayload(event.payload);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onPayloadGenerated]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
submitGeneration(prompt);
|
||||
},
|
||||
[prompt, submitGeneration]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 15000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const examplePrompts =
|
||||
examplePromptsOverride ??
|
||||
(payloadSchema
|
||||
? [
|
||||
"Generate a valid payload",
|
||||
"Generate a payload with edge cases",
|
||||
"Generate a minimal payload with only required fields",
|
||||
]
|
||||
: [
|
||||
"Generate a simple JSON payload",
|
||||
"Generate a payload with nested objects",
|
||||
"Generate a payload with an array of items",
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<div
|
||||
className="overflow-hidden rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-md bg-background-bright">
|
||||
<div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(payloadSchema
|
||||
? "e.g. generate a payload for a new user signup"
|
||||
: "e.g. generate a JSON payload with name, email, and age fields")
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isLoading}
|
||||
rows={5}
|
||||
className="focus-custom m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control placeholder:text-text-dimmed focus:border-0 focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-2"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
Generating…
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim()}
|
||||
className={cn(!prompt.trim() && "opacity-50")}
|
||||
onClick={() => handleSubmit()}
|
||||
>
|
||||
{generateButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking panel */}
|
||||
<AnimatePresence>
|
||||
{showThinking && thinking && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden rounded-b-md border-x border-b border-grid-bright bg-background-bright pb-3"
|
||||
>
|
||||
<div className="space-y-2 px-2 pt-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : lastResult === "success" ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<XMarkIcon className="size-4 text-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking…"
|
||||
: lastResult === "success"
|
||||
? "Payload generated"
|
||||
: lastResult === "error"
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{lastResult === "success" && lastPayload && (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
side="top"
|
||||
content="Copy"
|
||||
disableHoverableContent
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy"
|
||||
onClick={() => {
|
||||
if (lastPayload) {
|
||||
void navigator.clipboard.writeText(lastPayload);
|
||||
}
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed transition-colors hover:bg-background-raised hover:text-text-bright"
|
||||
>
|
||||
<ClipboardIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
side="top"
|
||||
content="Dismiss"
|
||||
disableHoverableContent
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed transition-colors hover:bg-background-raised hover:text-text-bright"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
// Strip Streamdown's code-block chrome
|
||||
"**:data-[streamdown=code-block-header]:hidden",
|
||||
"**:data-[streamdown=code-block]:border-0!",
|
||||
"**:data-[streamdown=code-block]:bg-transparent!",
|
||||
"**:data-[streamdown=code-block]:p-0!",
|
||||
"**:data-[streamdown=code-block]:my-0!",
|
||||
"**:data-[streamdown=code-block]:gap-0!",
|
||||
// Strip the inner code-block-body border (it draws a faint inset frame)
|
||||
"**:data-[streamdown=code-block-body]:border-0!",
|
||||
// Style Streamdown's inner code-block (where the horizontal scrollbar lives)
|
||||
"**:data-[streamdown=code-block-body]:scrollbar-thin",
|
||||
"**:data-[streamdown=code-block-body]:scrollbar-track-transparent",
|
||||
"**:data-[streamdown=code-block-body]:scrollbar-thumb-surface-control"
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Example prompts */}
|
||||
<div className="pt-4">
|
||||
{showExamplePromptsHeader && (
|
||||
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examplePrompts.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setPrompt(example);
|
||||
submitGeneration(example);
|
||||
}}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors focus-custom hover:border-solid hover:border-indigo-500 focus-visible:rounded-full! disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="text-left transition group-hover:text-text-bright"
|
||||
>
|
||||
{example}
|
||||
</Paragraph>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export function SchemaTabContent({
|
||||
schema,
|
||||
inferredSchema,
|
||||
title = "Payload schema",
|
||||
description,
|
||||
showDocsLink = true,
|
||||
}: {
|
||||
schema?: unknown;
|
||||
inferredSchema?: unknown;
|
||||
title?: string;
|
||||
description?: string;
|
||||
showDocsLink?: boolean;
|
||||
}) {
|
||||
if (schema) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Header3 className="text-text-bright">{title}</Header3>
|
||||
{showDocsLink ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{description ?? (
|
||||
<>
|
||||
JSON Schema defined by this task via{" "}
|
||||
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Paragraph>
|
||||
) : description ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={JSON.stringify(schema, null, 2)}
|
||||
language="json"
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inferredSchema) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header3 className="text-text-bright">Inferred schema</Header3>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Schema inferred from recent run payloads. For an exact schema, use schemaTask.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("tasks/schemaTask")}
|
||||
>
|
||||
schemaTask docs
|
||||
</LinkButton>
|
||||
<CodeBlock
|
||||
code={JSON.stringify(inferredSchema, null, 2)}
|
||||
language="json"
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header3 className="text-text-bright">No schema defined</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Use <code className="text-text-bright">schemaTask</code> to define a payload schema for this
|
||||
task. The schema will appear here and can be used by AI to generate example payloads.
|
||||
</Paragraph>
|
||||
<LinkButton variant="docs/small" LeadingIcon={BookOpenIcon} to={docsPath("tasks/schemaTask")}>
|
||||
schemaTask docs
|
||||
</LinkButton>
|
||||
<CodeBlock
|
||||
code={exampleCode}
|
||||
language="typescript"
|
||||
showLineNumbers={false}
|
||||
showCopyButton={false}
|
||||
showOpenInModal={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const exampleCode = `import { schemaTask } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export const myTask = schemaTask({
|
||||
id: "my-task",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
count: z.number().int().positive(),
|
||||
}),
|
||||
run: async (payload) => {
|
||||
// payload is fully typed
|
||||
console.log(payload.name);
|
||||
},
|
||||
});`;
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
|
||||
export function TestSidebarTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
optionsContent,
|
||||
aiContent,
|
||||
schemaContent,
|
||||
}: {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
optionsContent: React.ReactNode;
|
||||
aiContent: React.ReactNode;
|
||||
schemaContent: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ClientTabs
|
||||
value={activeTab}
|
||||
onValueChange={onTabChange}
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden pt-1"
|
||||
>
|
||||
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger
|
||||
value="options"
|
||||
variant="underline"
|
||||
layoutId="test-sidebar-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Options
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="ai"
|
||||
variant="underline"
|
||||
layoutId="test-sidebar-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</span>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="schema"
|
||||
variant="underline"
|
||||
layoutId="test-sidebar-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Schema
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent
|
||||
value="options"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
{optionsContent}
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="ai"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">{aiContent}</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="schema"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">{schemaContent}</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
+1904
File diff suppressed because it is too large
Load Diff
+224
@@ -0,0 +1,224 @@
|
||||
import { BookOpenIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction, Outlet, useParams } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TestHasNoTasks } from "~/components/BlankStatePanels";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
|
||||
import { useLinkStatus } from "~/hooks/useLinkStatus";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type TaskListItem, TestPresenter } from "~/presenters/v3/TestPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath, EnvironmentParamSchema, v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Test | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new TestPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return typedjson(result);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { tasks } = useTypedLoaderData<typeof loader>();
|
||||
const { taskParam } = useParams();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Test" />
|
||||
<PageAccessories>
|
||||
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/run-tests")}>
|
||||
Test docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{tasks.length === 0 ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<TestHasNoTasks />
|
||||
</MainCenteredContainer>
|
||||
) : taskParam ? (
|
||||
// Task selected via URL → skip the picker; the child route owns the page.
|
||||
<Outlet key={taskParam} />
|
||||
) : (
|
||||
// No task in URL: show the picker as a fallback so users landing on
|
||||
// /test (e.g. from the runs blank state with multiple task filters)
|
||||
// can still choose one.
|
||||
<div className={cn("grid h-full max-h-full grid-cols-1")}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanel id="test-selector" min="200px" default="20%">
|
||||
<TaskSelector tasks={tasks} activeTaskIdentifier={taskParam} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="test-handle" />
|
||||
<ResizablePanel id="test-main" min="225px">
|
||||
<Outlet key={taskParam} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSelector({
|
||||
tasks,
|
||||
activeTaskIdentifier,
|
||||
}: {
|
||||
tasks: TaskListItem[];
|
||||
activeTaskIdentifier?: string;
|
||||
}) {
|
||||
const { filterText, setFilterText, filteredItems } = useFuzzyFilter<TaskListItem>({
|
||||
items: tasks,
|
||||
keys: ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"],
|
||||
});
|
||||
const hasTaskInEnvironment = activeTaskIdentifier
|
||||
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden",
|
||||
hasTaskInEnvironment === false ? "grid-rows-[auto_auto_1fr]" : "grid-rows-[auto_1fr]"
|
||||
)}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="secondary-small"
|
||||
icon={MagnifyingGlassIcon}
|
||||
iconClassName="text-text-bright"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
autoFocus
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{hasTaskInEnvironment === false && (
|
||||
<div className="px-2 pb-2">
|
||||
<Callout variant="warning" className="text-sm text-yellow-300">
|
||||
There is no "{activeTaskIdentifier}" task in the selected environment.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader className="hidden">
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-[1%]" />
|
||||
<TableHeaderCell className="pl-0">Task</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((t) => <TaskRow key={t.friendlyId} task={t} />)
|
||||
) : (
|
||||
<TableBlankRow colSpan={2}>
|
||||
<Paragraph spacing variant="small">
|
||||
No tasks match "{filterText}"
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: TaskListItem }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const path = v3TestTaskPath(organization, project, environment, task);
|
||||
const { isActive, isPending } = useLinkStatus(path);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={task.taskIdentifier}
|
||||
className={cn((isActive || isPending) && "bg-indigo-500/10")}
|
||||
>
|
||||
<TableCell
|
||||
to={path}
|
||||
actionClassName="pl-2.5 pr-2 py-1"
|
||||
className={cn("w-[1%]", (isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
||||
>
|
||||
<RadioButtonCircle checked={isActive || isPending} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
to={path}
|
||||
isTabbableCell
|
||||
actionClassName="pl-0 pr-2"
|
||||
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TaskTriggerSourceIcon source={task.triggerSource} />
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="text-text-dimmed group-hover/table-row:text-text-bright"
|
||||
>
|
||||
{task.taskIdentifier}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
|
||||
import {
|
||||
runOpsNewReplicaClient,
|
||||
runOpsLegacyReplica,
|
||||
runOpsSplitReadEnabled,
|
||||
type PrismaClientOrTransaction,
|
||||
} from "~/db.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { EnvironmentParamSchema, v3WaitpointTokensPath } from "~/utils/pathBuilder";
|
||||
import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route";
|
||||
import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const Params = EnvironmentParamSchema.extend({
|
||||
waitpointParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam, waitpointParam } = Params.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
newClient: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
|
||||
legacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const result = await presenter.call({
|
||||
friendlyId: waitpointParam,
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Waitpoint not found",
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({ waitpoint: result });
|
||||
} catch (error) {
|
||||
logger.error("Error loading waitpoint for inspector", {
|
||||
error,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
waitpointParam,
|
||||
});
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { waitpoint } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cn(
|
||||
"grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright",
|
||||
waitpoint.status === "WAITING" && "grid-rows-[2.5rem_1fr_auto]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>{waitpoint.id}</Header2>
|
||||
<LinkButton
|
||||
to={`${v3WaitpointTokensPath(organization, project, environment)}${location.search}`}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="px-3">
|
||||
<WaitpointDetailTable waitpoint={waitpoint} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-6">
|
||||
<div className="mb-1 flex items-center gap-1 pl-3">
|
||||
<Header3>Related runs</Header3>
|
||||
<InfoIconTooltip content="These runs have been blocked by this waitpoint." />
|
||||
</div>
|
||||
<TaskRunsTable
|
||||
total={waitpoint.connectedRuns.length}
|
||||
hasFilters={false}
|
||||
filters={{
|
||||
tasks: [],
|
||||
versions: [],
|
||||
statuses: [],
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
}}
|
||||
runs={waitpoint.connectedRuns}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{waitpoint.status === "WAITING" && (
|
||||
<div>
|
||||
<CompleteWaitpointForm waitpoint={waitpoint} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { NoWaitpointTokens } from "~/components/BlankStatePanels";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
collapsibleHandleClassName,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import { WaitpointStatusCombo } from "~/components/runs/v3/WaitpointStatus";
|
||||
import {
|
||||
WaitpointSearchParamsSchema,
|
||||
WaitpointTokenFilters,
|
||||
} from "~/components/runs/v3/WaitpointTokenFilters";
|
||||
import { V4Title } from "~/components/V4Badge";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { WaitpointListPresenter } from "~/presenters/v3/WaitpointListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
runOpsNewReplicaClient,
|
||||
runOpsLegacyReplica,
|
||||
runOpsSplitReadEnabled,
|
||||
type PrismaClientOrTransaction,
|
||||
} from "~/db.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Waitpoint tokens | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = {
|
||||
id: url.searchParams.get("id") ?? undefined,
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
idempotencyKey: url.searchParams.get("idempotencyKey") ?? undefined,
|
||||
tags: url.searchParams.getAll("tags"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
};
|
||||
|
||||
const searchParams = WaitpointSearchParamsSchema.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new WaitpointListPresenter(undefined, undefined, {
|
||||
runOpsNew: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
|
||||
runOpsLegacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const result = await presenter.call({
|
||||
environment,
|
||||
...searchParams,
|
||||
});
|
||||
|
||||
return typedjson(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
success: _success,
|
||||
tokens,
|
||||
pagination,
|
||||
hasFilters,
|
||||
hasAnyTokens,
|
||||
filters,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const { waitpointParam } = useParams();
|
||||
const isShowingWaitpoint = !!waitpointParam;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={<V4Title>Waitpoint Tokens</V4Title>} />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/wait")}>
|
||||
Waitpoints docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{!hasAnyTokens ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<NoWaitpointTokens />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="waitpoint-tokens-main" min={"100px"}>
|
||||
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<WaitpointTokenFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-fit max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
|
||||
<Table containerClassName="border-t">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-[1%]">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">ID</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">Callback URL</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">Status</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">Completed</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">Idempotency Key</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">Tags</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.length > 0 ? (
|
||||
tokens.map((token) => {
|
||||
const ttlExpired =
|
||||
token.idempotencyKeyExpiresAt &&
|
||||
token.idempotencyKeyExpiresAt < new Date();
|
||||
|
||||
const path = v3WaitpointTokenPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
token,
|
||||
filters
|
||||
);
|
||||
const rowIsSelected = waitpointParam === token.id;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={token.id}
|
||||
className={rowIsSelected ? "bg-grid-dimmed" : undefined}
|
||||
>
|
||||
<TableCell to={path}>
|
||||
<span className="opacity-60">
|
||||
<DateTime date={token.createdAt} />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<CopyableText value={token.id} className="font-mono" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<ClipboardField value={token.url} variant={"secondary/small"} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<WaitpointStatusCombo status={token.status} className="text-xs" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{token.completedAt ? <DateTime date={token.completedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{token.idempotencyKey ? (
|
||||
token.idempotencyKeyExpiresAt ? (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<>
|
||||
<DateTime date={token.idempotencyKeyExpiresAt} />
|
||||
{ttlExpired ? (
|
||||
<span className="text-xs opacity-50"> (expired)</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
buttonClassName={ttlExpired ? "opacity-50" : undefined}
|
||||
button={token.idempotencyKey}
|
||||
/>
|
||||
) : (
|
||||
token.idempotencyKey
|
||||
)
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1">
|
||||
<div className="flex gap-1">
|
||||
{token.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="grid place-items-center py-6 text-text-dimmed">
|
||||
<Paragraph>No waitpoint tokens found</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id="waitpoint-tokens-handle"
|
||||
className={collapsibleHandleClassName(isShowingWaitpoint)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="waitpoint-tokens-inspector"
|
||||
min="450px"
|
||||
default="500px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!isShowingWaitpoint}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 450 }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { Outlet, useLoaderData } from "@remix-run/react";
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
|
||||
import { prisma } from "~/db.server";
|
||||
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { tenantContext } from "~/services/tenantContext.server";
|
||||
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
|
||||
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
externalRef: true,
|
||||
organization: { select: { id: true, featureFlags: true } },
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
logger.error("Project not found", { params, user });
|
||||
throw new Response("Project not Found", { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environments = project.environments.filter((env) => env.slug === envParam);
|
||||
if (environments.length === 0) {
|
||||
return redirect(v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }));
|
||||
}
|
||||
|
||||
let environmentId: string | undefined = undefined;
|
||||
let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined;
|
||||
|
||||
if (environments.length > 1) {
|
||||
const bestEnvironment = environments.find((env) => env.orgMember?.userId === user.id);
|
||||
if (!bestEnvironment) {
|
||||
throw new Response("Environment not Found", {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
environmentId = bestEnvironment.id;
|
||||
environmentType = bestEnvironment.type;
|
||||
} else {
|
||||
environmentId = environments[0].id;
|
||||
environmentType = environments[0].type;
|
||||
}
|
||||
|
||||
// userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here.
|
||||
tenantContext.enrich({
|
||||
orgId: project.organization.id,
|
||||
projectId: project.id,
|
||||
projectRef: project.externalRef,
|
||||
envId: environmentId,
|
||||
envType: environmentType,
|
||||
});
|
||||
|
||||
await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });
|
||||
|
||||
// Resolve dashboard-agent access here (single source of truth: global env,
|
||||
// admins/impersonators, then the global/per-org feature flag, default off) so
|
||||
// the launcher button is hidden when it's not enabled. The org's featureFlags
|
||||
// came from the membership-checked project query above, so we pass them in to
|
||||
// avoid a second org lookup.
|
||||
const hasDashboardAgentAccess = await canAccessDashboardAgent({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
|
||||
});
|
||||
|
||||
return {
|
||||
...project,
|
||||
hasDashboardAgentAccess,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { hasDashboardAgentAccess } = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<DashboardAgent hasAccess={hasDashboardAgentAccess}>
|
||||
<Outlet />
|
||||
</DashboardAgent>
|
||||
);
|
||||
}
|
||||
|
||||
// Caught here (inside the project SideMenu's Outlet) rather than at the project
|
||||
// layout, so a permission denial or error on any env-scoped page renders in the
|
||||
// content pane with the SideMenu intact. RouteErrorDisplay renders the
|
||||
// permission panel for a 403 and the generic error otherwise.
|
||||
export function ErrorBoundary() {
|
||||
return <RouteErrorDisplay />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { DevPresenceProvider } from "~/components/DevPresence";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { MainBody } from "~/components/layout/AppLayout";
|
||||
import { SideMenu } from "~/components/navigation/SideMenu";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useIsImpersonating, useOrganization, useOrganizations } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { v3ProjectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export default function Project() {
|
||||
const organizations = useOrganizations();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const user = useUser();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
|
||||
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
|
||||
<SideMenu
|
||||
user={{ ...user, isImpersonating }}
|
||||
project={project}
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
<MainBody>
|
||||
<Outlet />
|
||||
</MainBody>
|
||||
</DevPresenceProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const org = useOrganization();
|
||||
const project = useProject();
|
||||
return <RouteErrorDisplay button={{ title: project.name, to: v3ProjectPath(org, project) }} />;
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import colorWheelIcon from "../../assets/images/color-wheel.png";
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
GlobeAltIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react";
|
||||
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarData,
|
||||
avatarIcons,
|
||||
AvatarType,
|
||||
defaultAvatar,
|
||||
parseAvatar,
|
||||
defaultAvatarHex,
|
||||
defaultAvatarColors,
|
||||
type Avatar as AvatarT,
|
||||
} from "~/components/primitives/Avatar";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { clearCurrentProject } from "~/services/dashboardPreferences.server";
|
||||
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon";
|
||||
import { OrganizationParamsSchema, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Organization settings | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
avatar: true,
|
||||
onboardingData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const onboardingData = toRecord(organization.onboardingData);
|
||||
|
||||
const parsedAvatar = parseAvatar(organization.avatar, defaultAvatar);
|
||||
const lastIconHex =
|
||||
parsedAvatar.type === "image" && parsedAvatar.lastIconHex
|
||||
? parsedAvatar.lastIconHex
|
||||
: defaultAvatarHex;
|
||||
|
||||
return typedjson({
|
||||
organization: {
|
||||
...organization,
|
||||
avatar: parsedAvatar,
|
||||
companyUrl: typeof onboardingData.companyUrl === "string" ? onboardingData.companyUrl : "",
|
||||
lastIconHex,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("avatar"),
|
||||
type: AvatarType,
|
||||
name: z.string().optional(),
|
||||
hex: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
organizationName: z
|
||||
.string()
|
||||
.min(3, "Organization name must have at least 3 characters")
|
||||
.max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
organizationSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conformZodMessage.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, organizationSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${organizationSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug } = params;
|
||||
if (!organizationSlug) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === organizationSlug, organizationSlug };
|
||||
},
|
||||
});
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
try {
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: submission.value.organizationName,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Organization renamed to ${submission.value.organizationName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const deleteOrganizationService = new DeleteOrganizationService();
|
||||
try {
|
||||
await deleteOrganizationService.call({ organizationSlug, userId: user.id, request });
|
||||
|
||||
//we need to clear the project from the session
|
||||
await clearCurrentProject({
|
||||
user,
|
||||
});
|
||||
return redirect(rootPath());
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
logger.error("Organization could not be deleted", {
|
||||
error: errorMessage,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
errorMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
case "avatar": {
|
||||
const orgWhere = {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId: user.id } },
|
||||
};
|
||||
|
||||
if (submission.value.type === "image") {
|
||||
const url = submission.value.url ?? "";
|
||||
const domain = url ? extractDomain(url) : null;
|
||||
|
||||
const existing = await prisma.organization.findFirst({
|
||||
where: orgWhere,
|
||||
select: { avatar: true, onboardingData: true },
|
||||
});
|
||||
|
||||
const existingData = toRecord(existing?.onboardingData);
|
||||
const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar);
|
||||
const lastIconHex = extractLastIconHex(existingAvatar);
|
||||
|
||||
await prisma.organization.update({
|
||||
where: orgWhere,
|
||||
data: {
|
||||
avatar: {
|
||||
type: "image",
|
||||
url: domain ? buildFaviconUrl(domain) : "",
|
||||
...(lastIconHex ? { lastIconHex } : {}),
|
||||
},
|
||||
onboardingData: { ...existingData, companyUrl: url },
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Updated logo`
|
||||
);
|
||||
}
|
||||
|
||||
const avatar = AvatarData.safeParse(submission.value);
|
||||
|
||||
if (!avatar.success) {
|
||||
return redirectWithErrorMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
avatar.error.message
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.organization.update({
|
||||
where: orgWhere,
|
||||
data: {
|
||||
avatar: avatar.data,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Updated logo`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
||||
return json({ errors: { body: message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { organization } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [renameForm, { organizationName }] = useForm({
|
||||
id: "rename-organization",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteForm, { organizationSlug }] = useForm({
|
||||
id: "delete-organization",
|
||||
// TODO: type this
|
||||
lastResult: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({
|
||||
isMatch: slug === organization.slug,
|
||||
organizationSlug: organization.slug,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={`${organization.title} organization settings`} />
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
<div className="mb-3 border-b border-grid-dimmed pb-3">
|
||||
<Header2>Settings</Header2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<LogoForm organization={organization} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form method="post" {...getFormProps(renameForm)}>
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<Fieldset className="gap-y-0">
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={organizationName.id}>Organization name</Label>
|
||||
<Input
|
||||
{...getInputProps(organizationName, { type: "text" })}
|
||||
defaultValue={organization.title}
|
||||
placeholder="Your organization name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={organizationName.errorId}>{organizationName.errors}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"secondary/small"}
|
||||
disabled={isRenameLoading}
|
||||
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Rename organization
|
||||
</Button>
|
||||
}
|
||||
className="border-t-0"
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<Form
|
||||
method="post"
|
||||
{...getFormProps(deleteForm)}
|
||||
className="w-full rounded-sm border border-rose-500/40"
|
||||
>
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<Fieldset className="p-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor={organizationSlug.id}>Delete organization</Label>
|
||||
<Input
|
||||
{...getInputProps(organizationSlug, { type: "text" })}
|
||||
placeholder="Your organization slug"
|
||||
icon={ExclamationTriangleIcon}
|
||||
fullWidth
|
||||
/>
|
||||
<FormError id={organizationSlug.errorId}>{organizationSlug.errors}</FormError>
|
||||
<FormError>{deleteForm.errors}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Organization
|
||||
slug <InlineCode variant="extra-small">{organization.slug}</InlineCode> and
|
||||
then press Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
Delete organization
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoForm({
|
||||
organization,
|
||||
}: {
|
||||
organization: { avatar: AvatarT; title: string; companyUrl: string; lastIconHex: string };
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const avatar = navigation.formData
|
||||
? (avatarFromFormData(navigation.formData) ?? organization.avatar)
|
||||
: organization.avatar;
|
||||
|
||||
const hex =
|
||||
"hex" in avatar
|
||||
? avatar.hex
|
||||
: avatar.type === "image" && avatar.lastIconHex
|
||||
? avatar.lastIconHex
|
||||
: organization.lastIconHex;
|
||||
const mode: "logo" | "icon" = avatar.type === "image" ? "logo" : "icon";
|
||||
|
||||
const [companyUrl, setCompanyUrl] = useState(organization.companyUrl);
|
||||
const faviconPreview = useFaviconUrl(companyUrl);
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
const logoFormRef = useRef<HTMLFormElement>(null);
|
||||
const submit = useSubmit();
|
||||
const prevFaviconRef = useRef(faviconPreview);
|
||||
|
||||
useEffect(() => {
|
||||
if (faviconPreview === prevFaviconRef.current) return;
|
||||
prevFaviconRef.current = faviconPreview;
|
||||
if (mode === "logo" && logoFormRef.current) {
|
||||
submit(logoFormRef.current);
|
||||
}
|
||||
}, [faviconPreview, mode, submit]);
|
||||
|
||||
const showFavicon = faviconPreview && !faviconError;
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label>Logo</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Row 1: Logo from URL */}
|
||||
<Form ref={logoFormRef} method="post" className="flex items-center gap-3">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="image" />
|
||||
<input type="hidden" name="url" value={companyUrl} />
|
||||
<button type="submit" className="flex shrink-0 items-center gap-3">
|
||||
<RadioDot active={mode === "logo"} />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
iconTileClass,
|
||||
mode === "logo"
|
||||
? "border-indigo-500"
|
||||
: "border-grid-dimmed hover:border-border-bright"
|
||||
)}
|
||||
>
|
||||
{showFavicon ? (
|
||||
<img
|
||||
src={faviconPreview}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className="rounded-sm"
|
||||
onError={() => setFaviconError(true)}
|
||||
onLoad={() => setFaviconError(false)}
|
||||
/>
|
||||
) : (
|
||||
<GlobeAltIcon className="size-6 text-text-dimmed" />
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyUrl}
|
||||
onChange={(e) => {
|
||||
setCompanyUrl(e.target.value);
|
||||
setFaviconError(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (mode !== "logo" && logoFormRef.current) {
|
||||
submit(logoFormRef.current);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your company URL to generate a logo"
|
||||
variant="medium"
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{/* Row 2: Icon picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Form method="post" className="shrink-0">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="letters" />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button type="submit">
|
||||
<RadioDot active={mode === "icon"} />
|
||||
</button>
|
||||
</Form>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Letters */}
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="letters" />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
iconTileClass,
|
||||
avatar.type !== "letters" && "border-grid-dimmed hover:border-border-bright"
|
||||
)}
|
||||
style={{
|
||||
borderColor: avatar.type === "letters" ? hex : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={{ type: "letters", hex }}
|
||||
size={2.5}
|
||||
includePadding
|
||||
orgName={organization.title}
|
||||
/>
|
||||
</button>
|
||||
</Form>
|
||||
{/* Icons */}
|
||||
{Object.entries(avatarIcons).map(([name]) => (
|
||||
<Form key={name} method="post">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="icon" />
|
||||
<input type="hidden" name="name" value={name} />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
iconTileClass,
|
||||
!(avatar.type === "icon" && avatar.name === name) &&
|
||||
"border-grid-dimmed hover:border-border-bright"
|
||||
)}
|
||||
style={{
|
||||
borderColor: avatar.type === "icon" && avatar.name === name ? hex : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={{ type: "icon", name, hex }}
|
||||
size={2.5}
|
||||
includePadding
|
||||
orgName={organization.title}
|
||||
/>
|
||||
</button>
|
||||
</Form>
|
||||
))}
|
||||
{/* Color picker */}
|
||||
<HexPopover avatar={avatar} hex={hex} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={cn(iconTileClass, "border-grid-dimmed hover:border-border-bright")}
|
||||
>
|
||||
<img src={colorWheelIcon} className="m-0 block size-[30px] p-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
<Form method="post" className="flex w-fit min-w-40 flex-col gap-1">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="type"
|
||||
value={avatar.type === "image" ? "letters" : avatar.type}
|
||||
/>
|
||||
{avatar.type === "icon" && <input type="hidden" name="name" value={avatar.name} />}
|
||||
{defaultAvatarColors.map((color) => (
|
||||
<Button
|
||||
key={color.hex}
|
||||
name="hex"
|
||||
value={color.hex}
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={
|
||||
<div
|
||||
className="size-4 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color.hex,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
TrailingIcon={hex === color.hex && <CheckIcon className="size-4 text-text-dimmed" />}
|
||||
trailingIconClassName="ml-4"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className={cn(
|
||||
"group-hover:bg-background-raised",
|
||||
hex === color.hex
|
||||
? "bg-background-hover group-hover:bg-surface-control/50"
|
||||
: undefined
|
||||
)}
|
||||
>
|
||||
{color.name}
|
||||
</Button>
|
||||
))}
|
||||
</Form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioDot({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full border-2 p-0.5 transition",
|
||||
active ? "border-indigo-500" : "border-grid-bright hover:border-border-bright"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: active ? "#6366f1" : "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconTileClass =
|
||||
"box-content grid size-10 shrink-0 place-items-center rounded-sm border-2 bg-charcoal-775";
|
||||
|
||||
function toRecord(json: unknown): Record<string, unknown> {
|
||||
return json && typeof json === "object" ? (json as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function extractLastIconHex(avatar: AvatarT): string | undefined {
|
||||
if ("hex" in avatar) return avatar.hex;
|
||||
if (avatar.type === "image") return avatar.lastIconHex;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function avatarFromFormData(formData: FormData): AvatarT | undefined {
|
||||
const action = formData.get("action");
|
||||
if (action !== "avatar") return undefined;
|
||||
|
||||
const type = formData.get("type");
|
||||
const hex = formData.get("hex") as string;
|
||||
|
||||
switch (type) {
|
||||
case "letters":
|
||||
return { type: "letters", hex };
|
||||
case "icon":
|
||||
return { type: "icon", name: formData.get("name") as string, hex };
|
||||
case "image": {
|
||||
const url = formData.get("url") as string;
|
||||
const domain = url ? extractDomain(url) : null;
|
||||
return { type: "image", url: domain ? buildFaviconUrl(domain) : "" };
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { OrganizationParamsSchema, v3BillingLimitsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ params }: LoaderFunctionArgs) {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
return redirect(v3BillingLimitsPath({ slug: organizationSlug }), 302);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/** Revalidate org layout (billingLimit banner) after billing limits settings forms submit. */
|
||||
export function isBillingLimitSettingsFormSubmission(
|
||||
formMethod: string | undefined,
|
||||
formData: FormData | undefined
|
||||
): boolean {
|
||||
if (!formMethod || !formData || formMethod.toLowerCase() !== "post") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intent = formData.get("intent");
|
||||
return (
|
||||
intent === "billing-limit" || intent === "billing-alerts" || intent === "billing-limit-resolve"
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
BillingLimitResult,
|
||||
ResolveBillingLimitRequest,
|
||||
} from "~/services/billingLimit.schemas";
|
||||
|
||||
export function isEnforcementActive(billingLimit: BillingLimitResult): boolean {
|
||||
return (
|
||||
billingLimit.isConfigured &&
|
||||
(billingLimit.limitState.status === "grace" || billingLimit.limitState.status === "rejected")
|
||||
);
|
||||
}
|
||||
|
||||
export function getAlertsResetRequested(request: Request): boolean {
|
||||
return new URL(request.url).searchParams.get("alertsReset") === "1";
|
||||
}
|
||||
|
||||
export function getEffectiveLimitCentsAfterLimitSave(
|
||||
mode: "plan" | "custom" | "none",
|
||||
planLimitCents: number,
|
||||
customAmountDollars?: number
|
||||
): number {
|
||||
if (mode === "custom") {
|
||||
return Math.round((customAmountDollars ?? 0) * 100);
|
||||
}
|
||||
|
||||
return planLimitCents;
|
||||
}
|
||||
|
||||
export function getResolveSubmitted(request: Request): boolean {
|
||||
return new URL(request.url).searchParams.get("resolved") === "1";
|
||||
}
|
||||
|
||||
export function getSubmittedResumeMode(
|
||||
request: Request
|
||||
): ResolveBillingLimitRequest["resumeMode"] | null {
|
||||
const value = new URL(request.url).searchParams.get("resumeMode");
|
||||
if (value === "queue" || value === "new_only") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import type { MetaFunction } from "@remix-run/react";
|
||||
import { json, redirect } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import {
|
||||
BillingAlertsSection,
|
||||
billingAlertsSchema,
|
||||
type BillingAlertsFormData,
|
||||
} from "~/components/billing/BillingAlertsSection";
|
||||
import {
|
||||
getBillingLimitMode,
|
||||
getEffectiveLimitCents,
|
||||
isPercentageAlertMode,
|
||||
MAX_ABSOLUTE_ALERTS,
|
||||
MAX_PERCENTAGE_ALERTS,
|
||||
MAX_PERCENTAGE_THRESHOLD,
|
||||
normalizeBillingAlertsFromApi,
|
||||
resetAlertsPayloadForLimitMode,
|
||||
shouldResetAlertsOnLimitChange,
|
||||
thresholdsToAlertPayload,
|
||||
hadSavedAlertsToClearOnLimitChange,
|
||||
thresholdValuesAreUnique,
|
||||
} from "~/components/billing/billingAlertsFormat";
|
||||
import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat";
|
||||
import {
|
||||
BillingLimitConfigSection,
|
||||
billingLimitFormSchema,
|
||||
} from "~/components/billing/BillingLimitConfigSection";
|
||||
import {
|
||||
BillingLimitRecoveryPanel,
|
||||
billingLimitRecoveryFormSchema,
|
||||
} from "~/components/billing/BillingLimitRecoveryPanel";
|
||||
import { BillingLimitResolveProgress } from "~/components/billing/BillingLimitResolveProgress";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useScrollContainerToTop } from "~/hooks/useScrollContainerToTop";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import {
|
||||
commitSession,
|
||||
getSession,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
setSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import {
|
||||
getBillingAlerts,
|
||||
getBillingLimit,
|
||||
getCachedUsage,
|
||||
getCurrentPlan,
|
||||
resolveBillingLimit,
|
||||
setBillingAlert,
|
||||
setBillingLimit,
|
||||
} from "~/services/platform.v3.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
import {
|
||||
getAlertsResetRequested,
|
||||
getEffectiveLimitCentsAfterLimitSave,
|
||||
getResolveSubmitted,
|
||||
getSubmittedResumeMode,
|
||||
isEnforcementActive,
|
||||
} from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server";
|
||||
import {
|
||||
countBillingLimitPausedEnvironments,
|
||||
getBillingLimitQueuedRunCount,
|
||||
} from "~/v3/services/billingLimit/getBillingLimitQueuedRunCount.server";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
v3BillingLimitsPath,
|
||||
v3BillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
const billingLimitsAuthorization = {
|
||||
action: "manage" as const,
|
||||
resource: { type: "billing-limits" as const },
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: `Billing limits | Trigger.dev` }];
|
||||
};
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: OrganizationParamsSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: {
|
||||
...billingLimitsAuthorization,
|
||||
message: "With your current role, you can't manage billing limits.",
|
||||
},
|
||||
},
|
||||
async ({ params, request, user }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug } = params;
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
if (!isManagedCloud) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
const currentPlan = await getCurrentPlan(organization.id);
|
||||
if (currentPlan?.v3Subscription?.showSelfServe === false) {
|
||||
return redirect(v3BillingPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
|
||||
if (billingLimitError || !billingLimit) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
statusText: `Billing limit error: ${billingLimitError ?? "not found"}`,
|
||||
});
|
||||
}
|
||||
|
||||
const [alertsError, alerts] = await tryCatch(getBillingAlerts(organization.id));
|
||||
if (alertsError || !alerts) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
statusText: `Billing alerts error: ${alertsError ?? "not found"}`,
|
||||
});
|
||||
}
|
||||
|
||||
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
|
||||
const alertsResetRequested = getAlertsResetRequested(request);
|
||||
const resolveSubmitted = getResolveSubmitted(request);
|
||||
const submittedResumeMode = getSubmittedResumeMode(request);
|
||||
|
||||
const firstDayOfMonth = new Date();
|
||||
firstDayOfMonth.setUTCDate(1);
|
||||
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const firstDayOfNextMonth = new Date();
|
||||
firstDayOfNextMonth.setUTCDate(1);
|
||||
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
|
||||
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
|
||||
|
||||
const [usage, queuedRunCount, billingLimitPauseEnvCount] = await Promise.all([
|
||||
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
|
||||
isEnforcementActive(billingLimit)
|
||||
? getBillingLimitQueuedRunCount(organization.id)
|
||||
: Promise.resolve(0),
|
||||
countBillingLimitPausedEnvironments(organization.id),
|
||||
]);
|
||||
|
||||
const currentSpendCents = usage?.cents ?? 0;
|
||||
const suggestedNewLimitDollars = isEnforcementActive(billingLimit)
|
||||
? getSuggestedRecoveryLimitDollars(
|
||||
billingLimit.isConfigured ? billingLimit.effectiveAmountCents : null,
|
||||
currentSpendCents
|
||||
)
|
||||
: 0;
|
||||
|
||||
const alertsFormData = normalizeBillingAlertsFromApi(alerts, {
|
||||
planLimitCents,
|
||||
effectiveLimitCents: getEffectiveLimitCents(billingLimit, planLimitCents),
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
billingLimit,
|
||||
alerts: alertsFormData,
|
||||
planLimitCents,
|
||||
isRecoveryMode: isEnforcementActive(billingLimit),
|
||||
alertsResetRequested,
|
||||
currentSpendCents,
|
||||
queuedRunCount,
|
||||
billingLimitPauseEnvCount,
|
||||
resolveSubmitted,
|
||||
submittedResumeMode,
|
||||
suggestedNewLimitDollars,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
type LoaderData = {
|
||||
billingLimit: BillingLimitResult;
|
||||
alerts: BillingAlertsFormData;
|
||||
planLimitCents: number;
|
||||
isRecoveryMode: boolean;
|
||||
alertsResetRequested: boolean;
|
||||
currentSpendCents: number;
|
||||
queuedRunCount: number;
|
||||
billingLimitPauseEnvCount: number;
|
||||
resolveSubmitted: boolean;
|
||||
submittedResumeMode: "queue" | "new_only" | null;
|
||||
suggestedNewLimitDollars: number;
|
||||
};
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: OrganizationParamsSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: billingLimitsAuthorization,
|
||||
},
|
||||
async ({ request, params, user }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug } = params;
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"You are not authorized to update billing settings"
|
||||
);
|
||||
}
|
||||
|
||||
const currentPlan = await getCurrentPlan(organization.id);
|
||||
if (currentPlan?.v3Subscription?.showSelfServe === false) {
|
||||
return redirect(v3BillingPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "billing-alerts") {
|
||||
const submission = parseWithZod(formData, { schema: billingAlertsSchema });
|
||||
if (submission.status !== "success") {
|
||||
return json({ formIntent: "billing-alerts", submission: submission.reply() });
|
||||
}
|
||||
|
||||
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
|
||||
if (billingLimitError || !billingLimit) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to load billing limit for alerts"
|
||||
);
|
||||
}
|
||||
|
||||
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
|
||||
const billingLimitMode = getBillingLimitMode(billingLimit);
|
||||
const maxAlerts = isPercentageAlertMode(billingLimitMode)
|
||||
? MAX_PERCENTAGE_ALERTS
|
||||
: MAX_ABSOLUTE_ALERTS;
|
||||
|
||||
if (submission.value.alertLevels.length > maxAlerts) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`You can add at most ${maxAlerts} alerts`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
submission.value.alertLevels.some(
|
||||
(threshold) => !Number.isFinite(threshold) || threshold <= 0
|
||||
)
|
||||
) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Each alert must be greater than 0"
|
||||
);
|
||||
}
|
||||
|
||||
if (!thresholdValuesAreUnique(submission.value.alertLevels)) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Each alert must be unique"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isPercentageAlertMode(billingLimitMode) &&
|
||||
submission.value.alertLevels.some((threshold) => threshold > MAX_PERCENTAGE_THRESHOLD)
|
||||
) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Alerts cannot exceed 100% of your billing limit"
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
|
||||
const alertPayload = thresholdsToAlertPayload(
|
||||
submission.value.alertLevels,
|
||||
billingLimitMode,
|
||||
effectiveLimitCents
|
||||
);
|
||||
|
||||
const [error] = await tryCatch(
|
||||
setBillingAlert(organization.id, {
|
||||
emails: submission.value.emails,
|
||||
...alertPayload,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to update billing alerts"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Billing alerts updated"
|
||||
);
|
||||
}
|
||||
|
||||
if (intent === "billing-limit") {
|
||||
const submission = parseWithZod(formData, { schema: billingLimitFormSchema });
|
||||
if (submission.status !== "success") {
|
||||
return json({ formIntent: "billing-limit", submission: submission.reply() });
|
||||
}
|
||||
|
||||
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
|
||||
if (billingLimitError || !billingLimit) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to load billing limit"
|
||||
);
|
||||
}
|
||||
|
||||
if (isEnforcementActive(billingLimit)) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Resolve the active billing limit before changing settings"
|
||||
);
|
||||
}
|
||||
|
||||
const cancelInProgressRuns =
|
||||
submission.value.mode === "none" ? false : (submission.value.cancelInProgressRuns ?? false);
|
||||
const previousMode = getBillingLimitMode(billingLimit);
|
||||
const resettingAlerts = shouldResetAlertsOnLimitChange(previousMode, submission.value.mode);
|
||||
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
|
||||
|
||||
try {
|
||||
if (submission.value.mode === "custom") {
|
||||
await setBillingLimit(organization.id, {
|
||||
mode: "custom",
|
||||
amountCents: Math.round(submission.value.amount * 100),
|
||||
cancelInProgressRuns,
|
||||
});
|
||||
} else if (submission.value.mode === "plan") {
|
||||
await setBillingLimit(organization.id, {
|
||||
mode: "plan",
|
||||
cancelInProgressRuns,
|
||||
});
|
||||
} else {
|
||||
await setBillingLimit(organization.id, {
|
||||
mode: "none",
|
||||
cancelInProgressRuns: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update billing limit";
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
if (resettingAlerts) {
|
||||
const [alertsError, existingAlerts] = await tryCatch(getBillingAlerts(organization.id));
|
||||
if (alertsError || !existingAlerts) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Billing limit updated, but failed to clear billing alerts"
|
||||
);
|
||||
}
|
||||
|
||||
const existingAlertsFormData = normalizeBillingAlertsFromApi(existingAlerts, {
|
||||
planLimitCents,
|
||||
effectiveLimitCents: getEffectiveLimitCents(billingLimit, planLimitCents),
|
||||
});
|
||||
|
||||
const shouldClearSavedAlerts = hadSavedAlertsToClearOnLimitChange(
|
||||
existingAlertsFormData,
|
||||
billingLimit,
|
||||
planLimitCents
|
||||
);
|
||||
|
||||
if (shouldClearSavedAlerts) {
|
||||
const effectiveLimitCents = getEffectiveLimitCentsAfterLimitSave(
|
||||
submission.value.mode,
|
||||
planLimitCents,
|
||||
submission.value.mode === "custom" ? submission.value.amount : undefined
|
||||
);
|
||||
|
||||
const [clearAlertsError] = await tryCatch(
|
||||
setBillingAlert(
|
||||
organization.id,
|
||||
resetAlertsPayloadForLimitMode(
|
||||
submission.value.mode,
|
||||
effectiveLimitCents,
|
||||
existingAlerts.emails ?? []
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (clearAlertsError) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Billing limit updated, but failed to clear billing alerts"
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
setSuccessMessage(session, "Billing limit updated");
|
||||
|
||||
return redirect(`${v3BillingLimitsPath({ slug: organizationSlug })}?alertsReset=1`, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
setSuccessMessage(session, "Billing limit updated");
|
||||
|
||||
return redirect(v3BillingLimitsPath({ slug: organizationSlug }), {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (intent === "billing-limit-resolve") {
|
||||
const submission = parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
|
||||
if (submission.status !== "success") {
|
||||
return json({ formIntent: "billing-limit-resolve", submission: submission.reply() });
|
||||
}
|
||||
|
||||
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
|
||||
if (billingLimitError || !billingLimit) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to load billing limit"
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEnforcementActive(billingLimit)) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Billing limit is not in an enforced state"
|
||||
);
|
||||
}
|
||||
|
||||
const resolvePayload =
|
||||
submission.value.action === "increase"
|
||||
? {
|
||||
action: "increase" as const,
|
||||
newAmountCents: Math.round((submission.value.newAmount ?? 0) * 100),
|
||||
resumeMode: submission.value.resumeMode,
|
||||
}
|
||||
: {
|
||||
action: "remove" as const,
|
||||
resumeMode: submission.value.resumeMode,
|
||||
};
|
||||
|
||||
const [error] = await tryCatch(resolveBillingLimit(organization.id, resolvePayload));
|
||||
|
||||
if (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to resolve billing limit";
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingLimitsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
setSuccessMessage(session, "Billing limit resolved");
|
||||
|
||||
const resumeModeParam = submission.value.resumeMode;
|
||||
return redirect(
|
||||
`${v3BillingLimitsPath({ slug: organizationSlug })}?resolved=1&resumeMode=${resumeModeParam}`,
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Unknown form intent" }, { status: 400 });
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
billingLimit,
|
||||
alerts,
|
||||
planLimitCents,
|
||||
isRecoveryMode,
|
||||
alertsResetRequested,
|
||||
currentSpendCents,
|
||||
queuedRunCount,
|
||||
billingLimitPauseEnvCount,
|
||||
resolveSubmitted,
|
||||
submittedResumeMode,
|
||||
suggestedNewLimitDollars,
|
||||
} = useTypedLoaderData<LoaderData>();
|
||||
|
||||
const showResolveProgress = resolveSubmitted && billingLimitPauseEnvCount > 0;
|
||||
const pageBodyRef = useScrollContainerToTop<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Billing limits" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable ref={pageBodyRef}>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-4 pb-4 pt-10">
|
||||
<BillingLimitResolveProgress
|
||||
show={showResolveProgress}
|
||||
cancellingQueuedRuns={submittedResumeMode === "new_only"}
|
||||
/>
|
||||
{isRecoveryMode && billingLimit.isConfigured ? (
|
||||
<BillingLimitRecoveryPanel
|
||||
billingLimit={billingLimit}
|
||||
currentSpendCents={currentSpendCents}
|
||||
queuedRunCount={queuedRunCount}
|
||||
suggestedNewLimitDollars={suggestedNewLimitDollars}
|
||||
/>
|
||||
) : (
|
||||
<BillingLimitConfigSection
|
||||
billingLimit={billingLimit}
|
||||
planLimitCents={planLimitCents}
|
||||
/>
|
||||
)}
|
||||
<BillingAlertsSection
|
||||
alerts={alerts}
|
||||
billingLimit={billingLimit}
|
||||
planLimitCents={planLimitCents}
|
||||
alertsResetRequested={alertsResetRequested}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { CalendarDaysIcon, CreditCardIcon, StarIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type PlanDefinition } from "@trigger.dev/platform";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
|
||||
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
v3StripePortalPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Billing | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: OrganizationParamsSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: {
|
||||
action: "manage",
|
||||
resource: { type: "billing" },
|
||||
message: "With your current role, you can't manage billing.",
|
||||
},
|
||||
},
|
||||
async ({ params, request, user }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug } = params;
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
if (!isManagedCloud) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
const currentPlan = await getCurrentPlan(organization.id);
|
||||
const showSelfServe = currentPlan?.v3Subscription?.showSelfServe !== false;
|
||||
|
||||
//periods
|
||||
const periodStart = new Date();
|
||||
periodStart.setUTCHours(0, 0, 0, 0);
|
||||
periodStart.setUTCDate(1);
|
||||
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setUTCMonth(periodEnd.getMonth() + 1);
|
||||
periodEnd.setUTCDate(0);
|
||||
periodEnd.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const daysRemaining = Math.ceil(
|
||||
(periodEnd.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
// Extract 'message' from search params
|
||||
const url = new URL(request.url);
|
||||
const message = url.searchParams.get("message");
|
||||
|
||||
if (!showSelfServe) {
|
||||
return typedjson({
|
||||
showSelfServe: false as const,
|
||||
...currentPlan,
|
||||
organizationSlug,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
daysRemaining,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
const plans = await getPlans();
|
||||
if (!plans) {
|
||||
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
showSelfServe: true as const,
|
||||
...plans,
|
||||
...currentPlan,
|
||||
organizationSlug,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
daysRemaining,
|
||||
message,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const loaderData = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const {
|
||||
showSelfServe,
|
||||
v3Subscription,
|
||||
organizationSlug,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
daysRemaining,
|
||||
message,
|
||||
} = loaderData;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Billing" />
|
||||
<PageAccessories>
|
||||
{v3Subscription?.isPaying && showSelfServe && (
|
||||
<>
|
||||
<LinkButton
|
||||
to={v3StripePortalPath({ slug: organizationSlug })}
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Invoices
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={v3StripePortalPath({ slug: organizationSlug })}
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Manage card details
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={showSelfServe}>
|
||||
{showSelfServe ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{message && (
|
||||
<Callout variant="idea" className="mb-2">
|
||||
{message}
|
||||
</Callout>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-3 divide-grid-bright rounded-sm border border-grid-bright bg-background-bright py-2 pr-1 text-text-bright lg:w-fit lg:flex-row lg:items-center lg:divide-x">
|
||||
<div className="flex gap-2 px-3 lg:items-center">
|
||||
<StarIcon className="size-5 min-w-5 lg:-mt-0.5" />
|
||||
{planLabel(
|
||||
v3Subscription?.plan,
|
||||
v3Subscription?.canceledAt !== undefined,
|
||||
periodEnd
|
||||
)}
|
||||
</div>
|
||||
{v3Subscription?.isPaying ? (
|
||||
<div className="flex gap-2 px-3 lg:items-center">
|
||||
<CalendarDaysIcon className="size-5 min-w-5 lg:-mt-0.5" />
|
||||
Billing period: <DateTime
|
||||
date={periodStart}
|
||||
includeTime={false}
|
||||
timeZone="UTC"
|
||||
/>{" "}
|
||||
to <DateTime date={periodEnd} includeTime={false} timeZone="UTC" /> (
|
||||
{daysRemaining} days remaining)
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<PricingPlans
|
||||
plans={loaderData.plans}
|
||||
concurrencyAddOnPricing={loaderData.addOnPricing.concurrency}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan={false}
|
||||
periodEnd={periodEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
title="Billing"
|
||||
icon={CreditCardIcon}
|
||||
iconClassName="text-emerald-500"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Your billing is managed by our team.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
Get in touch for invoices, plan changes, or other billing questions.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function planLabel(plan: PlanDefinition | undefined, canceled: boolean, periodEnd: Date) {
|
||||
if (!plan || plan.type === "free") {
|
||||
return "You're on the Free plan";
|
||||
}
|
||||
|
||||
if (plan.type === "enterprise") {
|
||||
return "You're on the Enterprise plan";
|
||||
}
|
||||
|
||||
const text = `You're on the $${plan.tierPrice}/mo ${plan.title} plan`;
|
||||
|
||||
if (canceled) {
|
||||
return (
|
||||
<>
|
||||
{text}. From <DateTime includeTime={false} date={periodEnd} /> you're on the Free plan.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { BugIcon } from "~/assets/icons/BugIcon";
|
||||
import { SlackMonoIcon } from "~/assets/icons/SlackMonoIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { requireOrganization } from "~/services/org.server";
|
||||
import { OrganizationParamsSchema, organizationSlackIntegrationPath } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const slackIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "SLACK",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!slackIntegration) {
|
||||
return typedjson({
|
||||
organization,
|
||||
slackIntegration: null,
|
||||
alertChannels: [],
|
||||
teamName: null,
|
||||
});
|
||||
}
|
||||
|
||||
const integrationData = slackIntegration.integrationData as any;
|
||||
const teamName = integrationData?.team?.name ?? null;
|
||||
|
||||
const alertChannels = await prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
type: "SLACK",
|
||||
project: { organizationId: organization.id },
|
||||
OR: [
|
||||
{ integrationId: slackIntegration.id },
|
||||
{
|
||||
properties: {
|
||||
path: ["integrationId"],
|
||||
equals: slackIntegration.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
organization,
|
||||
slackIntegration,
|
||||
alertChannels,
|
||||
teamName,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
intent: z.literal("uninstall"),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization, userId } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const formData = await request.formData();
|
||||
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
|
||||
if (!result.success) {
|
||||
return json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
const slackIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "SLACK",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!slackIntegration) {
|
||||
return json({ error: "Slack integration not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const txResult = await fromPromise(
|
||||
$transaction(prisma, async (tx) => {
|
||||
await tx.projectAlertChannel.updateMany({
|
||||
where: {
|
||||
type: "SLACK",
|
||||
OR: [
|
||||
{ integrationId: slackIntegration.id },
|
||||
{
|
||||
properties: {
|
||||
path: ["integrationId"],
|
||||
equals: slackIntegration.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
integrationId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organizationIntegration.update({
|
||||
where: { id: slackIntegration.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (txResult.isErr()) {
|
||||
logger.error("Failed to remove Slack integration", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: slackIntegration.id,
|
||||
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to remove Slack integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Slack integration removed successfully", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: slackIntegration.id,
|
||||
});
|
||||
|
||||
return redirect(organizationSlackIntegrationPath({ slug: organizationSlug }));
|
||||
};
|
||||
|
||||
export default function SlackIntegrationPage() {
|
||||
const { slackIntegration, alertChannels, teamName } = useTypedLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isUninstalling =
|
||||
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
|
||||
|
||||
if (!slackIntegration) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Slack integration" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<SlackMonoIcon className="mb-2 size-16 text-secondary" />
|
||||
<Header2>No Slack integration found</Header2>
|
||||
<Paragraph className="max-w-md text-center text-text-dimmed">
|
||||
Your organization doesn't have a Slack integration configured. You can connect Slack
|
||||
when setting up alerts from the{" "}
|
||||
<BugIcon className="-ml-0.5 mb-0.5 inline size-5 text-errors" />
|
||||
<span className="text-text-bright">Errors</span> page.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Slack integration" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<div className="mb-3 border-b border-grid-dimmed pb-3">
|
||||
<Header2>Integration details</Header2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{teamName && (
|
||||
<Paragraph variant="small">
|
||||
<span className="text-text-dimmed">Workspace:</span>{" "}
|
||||
<span className="text-text-bright">{teamName}</span>
|
||||
</Paragraph>
|
||||
)}
|
||||
<Paragraph variant="small">
|
||||
<span className="text-text-dimmed">Installed:</span>{" "}
|
||||
<span className="text-text-bright">
|
||||
<DateTime date={slackIntegration.createdAt} />
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header3 spacing>
|
||||
Connected alert channels
|
||||
<span className="ml-1 text-text-dimmed">({alertChannels.length})</span>
|
||||
</Header3>
|
||||
{alertChannels.length === 0 ? (
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
No alert channels are currently connected to this Slack integration.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Channel</TableHeaderCell>
|
||||
<TableHeaderCell>Project</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertChannels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.name}</TableCell>
|
||||
<TableCell>{channel.project.name}</TableCell>
|
||||
<TableCell>
|
||||
<EnabledStatus enabled={channel.enabled} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateTime date={channel.createdAt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<div className="w-full rounded-sm border border-rose-500/40 p-4">
|
||||
<Header3 spacing>Remove integration</Header3>
|
||||
<Hint>
|
||||
This will remove the Slack integration and disable all connected alert channels.
|
||||
This action cannot be undone.
|
||||
</Hint>
|
||||
{actionData?.error && (
|
||||
<Paragraph variant="small" className="mt-2 text-error">
|
||||
{actionData.error}
|
||||
</Paragraph>
|
||||
)}
|
||||
<FormButtons
|
||||
className="mt-2"
|
||||
confirmButton={
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="danger/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
Remove integration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Slack integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="mb-2">
|
||||
This will remove the Slack integration and disable all connected alert
|
||||
channels. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="uninstall" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
type="submit"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
{isUninstalling ? "Removing…" : "Remove integration"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireOrganization } from "~/services/org.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { OrganizationParamsSchema, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const url = new URL(request.url);
|
||||
const configurationId = url.searchParams.get("configurationId") ?? undefined;
|
||||
const { organization, userId } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
// Display flag for the Remove Integration control — the action enforces
|
||||
// write:vercel independently. Permissive in OSS.
|
||||
const sessionAuth = await rbac.authenticateSession(request, {
|
||||
userId,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
const canManageVercel = sessionAuth.ok
|
||||
? sessionAuth.ability.can("write", { type: "vercel" })
|
||||
: true;
|
||||
|
||||
// Find Vercel integration for this organization
|
||||
let vercelIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
// If configurationId is provided, filter by it in integrationData
|
||||
...(configurationId && {
|
||||
integrationData: {
|
||||
path: ["installationId"],
|
||||
equals: configurationId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vercelIntegration) {
|
||||
return typedjson({
|
||||
organization,
|
||||
vercelIntegration: null,
|
||||
connectedProjects: [],
|
||||
teamId: null,
|
||||
installationId: null,
|
||||
canManageVercel,
|
||||
});
|
||||
}
|
||||
|
||||
// Get team ID from integrationData
|
||||
const integrationData = vercelIntegration.integrationData as any;
|
||||
const teamId = integrationData?.teamId ?? null;
|
||||
const installationId = integrationData?.installationId ?? null;
|
||||
|
||||
// Get all connected projects for this integration
|
||||
const connectedProjects = await prisma.organizationProjectIntegration.findMany({
|
||||
where: {
|
||||
organizationIntegrationId: vercelIntegration.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
organization,
|
||||
vercelIntegration,
|
||||
connectedProjects,
|
||||
teamId,
|
||||
installationId,
|
||||
canManageVercel,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
intent: z.literal("uninstall"),
|
||||
});
|
||||
|
||||
export const action = dashboardAction(
|
||||
{
|
||||
params: OrganizationParamsSchema,
|
||||
context: async (params) => {
|
||||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: { action: "write", resource: { type: "vercel" } },
|
||||
},
|
||||
async ({ request, params }) => {
|
||||
const { organizationSlug } = params;
|
||||
const { organization, userId } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const formData = await request.formData();
|
||||
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
|
||||
if (!result.success) {
|
||||
return json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find Vercel integration
|
||||
const vercelIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vercelIntegration) {
|
||||
return json({ error: "Vercel integration not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Uninstall from Vercel side
|
||||
const uninstallResult =
|
||||
await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration);
|
||||
|
||||
if (uninstallResult.isErr()) {
|
||||
logger.error("Failed to uninstall Vercel integration", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
error: uninstallResult.error.message,
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to uninstall Vercel integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Soft-delete the integration and all connected projects in a transaction
|
||||
const txResult = await fromPromise(
|
||||
$transaction(prisma, async (tx) => {
|
||||
await tx.organizationProjectIntegration.updateMany({
|
||||
where: {
|
||||
organizationIntegrationId: vercelIntegration.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.organizationIntegration.update({
|
||||
where: { id: vercelIntegration.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (txResult.isErr()) {
|
||||
logger.error("Failed to soft-delete Vercel integration records", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to uninstall Vercel integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (uninstallResult.value.authInvalid) {
|
||||
logger.warn("Vercel integration uninstalled with auth error - token invalid", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
});
|
||||
} else {
|
||||
logger.info("Vercel integration uninstalled successfully", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect back to organization settings
|
||||
return redirect(`/orgs/${organizationSlug}/settings`);
|
||||
}
|
||||
);
|
||||
|
||||
export default function VercelIntegrationPage() {
|
||||
const {
|
||||
organization,
|
||||
vercelIntegration,
|
||||
connectedProjects,
|
||||
teamId,
|
||||
installationId,
|
||||
canManageVercel,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isUninstalling =
|
||||
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
|
||||
|
||||
if (!vercelIntegration) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Header1>No Vercel Integration Found</Header1>
|
||||
<Paragraph className="mt-2 text-center text-text-dimmed">
|
||||
This organization doesn't have a Vercel integration configured.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="mb-8">
|
||||
<Header1>Vercel Integration</Header1>
|
||||
<Paragraph className="mt-2 text-text-dimmed">
|
||||
Manage your organization's Vercel integration and connected projects.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Integration Info Section */}
|
||||
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
|
||||
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
|
||||
{teamId && (
|
||||
<div>
|
||||
<span className="font-medium">Vercel Team ID:</span> {teamId}
|
||||
</div>
|
||||
)}
|
||||
{installationId && (
|
||||
<div>
|
||||
<span className="font-medium">Installation ID:</span> {installationId}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Installed:</span>{" "}
|
||||
{formatDate(new Date(vercelIntegration.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
disabled={isUninstalling || !canManageVercel}
|
||||
tooltip={
|
||||
canManageVercel
|
||||
? undefined
|
||||
: "You don't have permission to manage the Vercel integration"
|
||||
}
|
||||
>
|
||||
Remove Integration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Vercel Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
This will permanently remove the Vercel integration and disconnect all projects.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="uninstall" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
type="submit"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
{isUninstalling ? "Removing..." : "Remove Integration"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{actionData?.error && (
|
||||
<Paragraph variant="small" className="text-error">
|
||||
{actionData.error}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Projects Section */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-bright">
|
||||
Connected Projects ({connectedProjects.length})
|
||||
</h2>
|
||||
|
||||
{connectedProjects.length === 0 ? (
|
||||
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
|
||||
<Paragraph className="text-text-dimmed">
|
||||
No projects are currently connected to this Vercel integration.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Project Name</TableHeaderCell>
|
||||
<TableHeaderCell>Vercel Project ID</TableHeaderCell>
|
||||
<TableHeaderCell>Connected</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectedProjects.map((projectIntegration) => (
|
||||
<TableRow key={projectIntegration.id}>
|
||||
<TableCell>{projectIntegration.project.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{projectIntegration.externalEntityId}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(new Date(projectIntegration.createdAt))}</TableCell>
|
||||
<TableCell>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3ProjectSettingsIntegrationsPath(
|
||||
organization,
|
||||
projectIntegration.project,
|
||||
{ slug: "prod" } // Default to production environment
|
||||
)}
|
||||
>
|
||||
Configure
|
||||
</LinkButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ClipboardDocumentIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, useRevalidator, type MetaFunction } from "@remix-run/react";
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import type { PrivateLinkConnectionStatus } from "@trigger.dev/platform";
|
||||
import { useMemo, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { deletePrivateLink, getPrivateLinks } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
docsPath,
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
v3PrivateConnectionsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: `Private Connections | Trigger.dev` }];
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
|
||||
if (!canAccess) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
const [error, connections] = await tryCatch(getPrivateLinks(organization.id));
|
||||
if (error) {
|
||||
logger.error("Error loading private link connections", {
|
||||
error,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
connections: connections?.connections ?? [],
|
||||
organizationId: organization.id,
|
||||
});
|
||||
}
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
if (request.method !== "DELETE" && request.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const connectionId = formData.get("connectionId");
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent !== "delete" || typeof connectionId !== "string") {
|
||||
return json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return redirectWithErrorMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Organization not found"
|
||||
);
|
||||
}
|
||||
|
||||
const [error] = await tryCatch(deletePrivateLink(organization.id, connectionId));
|
||||
if (error) {
|
||||
return redirectWithErrorMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Failed to delete connection: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Connection deletion initiated"
|
||||
);
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<PrivateLinkConnectionStatus, string> = {
|
||||
PENDING: "bg-amber-500/20 text-amber-400",
|
||||
PROVISIONING: "bg-blue-500/20 text-blue-400",
|
||||
ACTIVE: "bg-emerald-500/20 text-emerald-400",
|
||||
ERROR: "bg-rose-500/20 text-rose-400",
|
||||
DELETING: "bg-surface-control-active/20 text-text-dimmed",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: PrivateLinkConnectionStatus }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
className="ml-1 inline-flex items-center text-text-dimmed transition hover:text-text-bright"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
|
||||
{copied && <span className="ml-1 text-xs text-emerald-400">Copied</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const TERMINAL_STATUSES: PrivateLinkConnectionStatus[] = ["ACTIVE", "ERROR"];
|
||||
|
||||
export default function Page() {
|
||||
const { connections } = useTypedLoaderData<typeof loader>();
|
||||
const plan = useCurrentPlan();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const hasInProgressConnections = useMemo(
|
||||
() => connections.some((c) => !TERMINAL_STATUSES.includes(c.status)),
|
||||
[connections]
|
||||
);
|
||||
|
||||
useInterval({
|
||||
interval: 3_000,
|
||||
onLoad: false,
|
||||
callback: () => {
|
||||
if (revalidator.state === "idle") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
},
|
||||
disabled: !hasInProgressConnections,
|
||||
});
|
||||
|
||||
const hasPrivateNetworking = plan?.v3Subscription?.plan?.limits?.hasPrivateNetworking ?? false;
|
||||
const limit = plan?.v3Subscription?.plan?.limits?.privateLinkConnectionLimit ?? 2;
|
||||
const canAdd = connections.filter((c) => c.status !== "DELETING").length < limit;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Private Connections" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("private-networking/overview")}
|
||||
>
|
||||
Private connection docs
|
||||
</LinkButton>
|
||||
{hasPrivateNetworking && canAdd && (
|
||||
<LinkButton variant="primary/small" LeadingIcon={PlusIcon} to="new">
|
||||
Add Connection
|
||||
</LinkButton>
|
||||
)}
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<MainHorizontallyCenteredContainer className="max-w-3xl">
|
||||
<div>
|
||||
<div className="mb-4 border-b border-grid-dimmed pb-3">
|
||||
<Header2 spacing>Private Connections</Header2>
|
||||
<Paragraph variant="small">
|
||||
Connect your AWS resources (databases, caches, APIs) to your Trigger.dev tasks via
|
||||
AWS PrivateLink. Connections are organization-wide and work across all projects and
|
||||
environments.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{!hasPrivateNetworking ? (
|
||||
<div className="rounded-lg border border-grid-dimmed p-6 text-center">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Private Connections require upgrading to Pro or an Enterprise plan.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<div className="rounded-lg border border-grid-dimmed p-6 text-center">
|
||||
<Paragraph variant="small" className="mb-4 text-text-dimmed">
|
||||
No private connections yet. Add your first connection to securely reach your AWS
|
||||
resources from task pods.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/small" LeadingIcon={PlusIcon} to="new">
|
||||
Add Connection
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{connections.map((connection) => (
|
||||
<div key={connection.id} className="rounded-lg border border-grid-dimmed p-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-medium text-text-bright">{connection.name}</span>
|
||||
<StatusBadge status={connection.status} />
|
||||
{connection.status !== "DELETING" && (
|
||||
<Form method="POST" className="ml-auto">
|
||||
<input type="hidden" name="connectionId" value={connection.id} />
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<button
|
||||
type="submit"
|
||||
className="text-text-dimmed transition hover:text-rose-400"
|
||||
title="Delete connection"
|
||||
onClick={(e) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete connection "${connection.name}"? This will remove the VPC Endpoint and network access.`
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center text-xs text-text-dimmed">
|
||||
<span className="w-24 shrink-0">Service:</span>
|
||||
<code className="truncate font-mono text-text-dimmed">
|
||||
{connection.endpointServiceName}
|
||||
</code>
|
||||
<CopyButton value={connection.endpointServiceName} />
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-text-dimmed">
|
||||
<span className="w-24 shrink-0">Region:</span>
|
||||
<span>{connection.targetRegion}</span>
|
||||
</div>
|
||||
{connection.endpointIps && connection.endpointIps.length > 0 && (
|
||||
<div className="flex items-center text-xs text-text-dimmed">
|
||||
<span className="w-24 shrink-0">IPs:</span>
|
||||
<code className="truncate font-mono text-emerald-400">
|
||||
{connection.endpointIps.join(", ")}
|
||||
</code>
|
||||
<CopyButton value={connection.endpointIps.join(", ")} />
|
||||
</div>
|
||||
)}
|
||||
{connection.statusMessage && (
|
||||
<div className="flex items-center text-xs text-rose-400">
|
||||
<span className="w-24 shrink-0">Error:</span>
|
||||
<span>{connection.statusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-xs text-text-dimmed">
|
||||
<span className="w-24 shrink-0">Created:</span>
|
||||
<span>{new Date(connection.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!canAdd && (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Connection limit reached ({limit}). Delete an existing connection to add a new
|
||||
one.
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+795
@@ -0,0 +1,795 @@
|
||||
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react";
|
||||
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import type { CreatePrivateLinkConnectionBody } from "@trigger.dev/platform";
|
||||
import { createPrivateLink, getPrivateLinkRegions } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
docsPath,
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
v3PrivateConnectionsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BookOpenIcon,
|
||||
CommandLineIcon,
|
||||
DocumentTextIcon,
|
||||
PencilSquareIcon,
|
||||
SparklesIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: `Add Private Connection | Trigger.dev` }];
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
|
||||
if (!canAccess) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
const [_error, regions] = await tryCatch(getPrivateLinkRegions(organization.id));
|
||||
|
||||
const awsAccountIds = env.PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS?.split(",").filter(Boolean) ?? [];
|
||||
|
||||
return typedjson({
|
||||
availableRegions: regions?.availableRegions ?? ["us-east-1", "eu-central-1"],
|
||||
activeRegions: regions?.activeRegions ?? [],
|
||||
awsAccountIds,
|
||||
});
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"),
|
||||
endpointServiceName: z
|
||||
.string()
|
||||
.min(1, "VPC Endpoint Service name is required")
|
||||
.regex(
|
||||
/^com\.amazonaws\.vpce\..+\.vpce-svc-.+$/,
|
||||
"Must be a valid VPC Endpoint Service name (com.amazonaws.vpce.<region>.vpce-svc-*)"
|
||||
),
|
||||
targetRegion: z.string().min(1, "Region is required"),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return redirectWithErrorMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Organization not found"
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch available regions dynamically (same call the loader makes)
|
||||
const [, fetchedRegions] = await tryCatch(getPrivateLinkRegions(organization.id));
|
||||
const availableRegions = fetchedRegions?.availableRegions ?? ["us-east-1", "eu-central-1"];
|
||||
|
||||
const { targetRegion: selectedRegion, ...rest } = submission.value;
|
||||
|
||||
if (!availableRegions.includes(selectedRegion)) {
|
||||
return redirectWithErrorMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Invalid region: ${selectedRegion}`
|
||||
);
|
||||
}
|
||||
|
||||
const [error] = await tryCatch(
|
||||
createPrivateLink(organization.id, {
|
||||
...rest,
|
||||
targetRegion: selectedRegion as CreatePrivateLinkConnectionBody["targetRegion"],
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return redirectWithErrorMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
const message = "Connection created! Provisioning will begin shortly.";
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3PrivateConnectionsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
message
|
||||
);
|
||||
};
|
||||
|
||||
type SetupMethod = "manual" | "ai" | "terraform" | "docs";
|
||||
|
||||
type PortEntry = { port: string; protocol: "TCP" | "UDP" };
|
||||
|
||||
const AWS_REGIONS = [
|
||||
{ value: "us-east-1", label: "US East (N. Virginia)" },
|
||||
{ value: "us-east-2", label: "US East (Ohio)" },
|
||||
{ value: "us-west-1", label: "US West (N. California)" },
|
||||
{ value: "us-west-2", label: "US West (Oregon)" },
|
||||
{ value: "af-south-1", label: "Africa (Cape Town)" },
|
||||
{ value: "ap-east-1", label: "Asia Pacific (Hong Kong)" },
|
||||
{ value: "ap-south-1", label: "Asia Pacific (Mumbai)" },
|
||||
{ value: "ap-south-2", label: "Asia Pacific (Hyderabad)" },
|
||||
{ value: "ap-southeast-1", label: "Asia Pacific (Singapore)" },
|
||||
{ value: "ap-southeast-2", label: "Asia Pacific (Sydney)" },
|
||||
{ value: "ap-southeast-3", label: "Asia Pacific (Jakarta)" },
|
||||
{ value: "ap-southeast-4", label: "Asia Pacific (Melbourne)" },
|
||||
{ value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" },
|
||||
{ value: "ap-northeast-2", label: "Asia Pacific (Seoul)" },
|
||||
{ value: "ap-northeast-3", label: "Asia Pacific (Osaka)" },
|
||||
{ value: "ca-central-1", label: "Canada (Central)" },
|
||||
{ value: "ca-west-1", label: "Canada West (Calgary)" },
|
||||
{ value: "eu-central-1", label: "Europe (Frankfurt)" },
|
||||
{ value: "eu-central-2", label: "Europe (Zurich)" },
|
||||
{ value: "eu-west-1", label: "Europe (Ireland)" },
|
||||
{ value: "eu-west-2", label: "Europe (London)" },
|
||||
{ value: "eu-west-3", label: "Europe (Paris)" },
|
||||
{ value: "eu-south-1", label: "Europe (Milan)" },
|
||||
{ value: "eu-south-2", label: "Europe (Spain)" },
|
||||
{ value: "eu-north-1", label: "Europe (Stockholm)" },
|
||||
{ value: "il-central-1", label: "Israel (Tel Aviv)" },
|
||||
{ value: "me-south-1", label: "Middle East (Bahrain)" },
|
||||
{ value: "me-central-1", label: "Middle East (UAE)" },
|
||||
{ value: "sa-east-1", label: "South America (São Paulo)" },
|
||||
];
|
||||
|
||||
function TerraformWizard({ awsAccountIds }: { awsAccountIds: string[] }) {
|
||||
const [hostname, setHostname] = useState("");
|
||||
const [ports, setPorts] = useState<PortEntry[]>([{ port: "5432", protocol: "TCP" }]);
|
||||
const [region, setRegion] = useState("us-east-1");
|
||||
|
||||
const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]);
|
||||
const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index));
|
||||
const updatePort = (index: number, field: keyof PortEntry, value: string) =>
|
||||
setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
|
||||
|
||||
const validPorts = ports.filter((p) => p.port !== "");
|
||||
|
||||
const terraformScript = `# Trigger.dev Private Networking - Terraform Configuration
|
||||
# Creates an NLB and VPC Endpoint Service for your resource
|
||||
|
||||
variable "vpc_id" {
|
||||
description = "Your VPC ID"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "Private subnet IDs in your VPC"
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "target_ip" {
|
||||
description = "IP address of the target resource"
|
||||
type = string${hostname ? `\n default = "${hostname}"` : ""}
|
||||
}
|
||||
|
||||
# Network Load Balancer
|
||||
resource "aws_lb" "trigger_privatelink" {
|
||||
name = "trigger-privatelink"
|
||||
internal = true
|
||||
load_balancer_type = "network"
|
||||
subnets = var.subnet_ids
|
||||
}
|
||||
${validPorts
|
||||
.map(
|
||||
(p, i) => `
|
||||
resource "aws_lb_target_group" "port_${p.port}" {
|
||||
name = "trigger-pl-${p.port}"
|
||||
port = ${p.port}
|
||||
protocol = "${p.protocol}"
|
||||
vpc_id = var.vpc_id
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
protocol = "TCP"
|
||||
port = ${p.port}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group_attachment" "port_${p.port}" {
|
||||
target_group_arn = aws_lb_target_group.port_${p.port}.arn
|
||||
target_id = var.target_ip
|
||||
port = ${p.port}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "port_${p.port}" {
|
||||
load_balancer_arn = aws_lb.trigger_privatelink.arn
|
||||
port = ${p.port}
|
||||
protocol = "${p.protocol}"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.port_${p.port}.arn
|
||||
}
|
||||
}`
|
||||
)
|
||||
.join("\n")}
|
||||
|
||||
# VPC Endpoint Service
|
||||
resource "aws_vpc_endpoint_service" "trigger_privatelink" {
|
||||
acceptance_required = false
|
||||
network_load_balancer_arns = [aws_lb.trigger_privatelink.arn]
|
||||
|
||||
# Trigger.dev runs in us-east-1 and eu-central-1. Listing both makes this
|
||||
# service consumable from either region so any of your tasks can connect.
|
||||
supported_regions = ["us-east-1", "eu-central-1"]
|
||||
|
||||
allowed_principals = [
|
||||
${awsAccountIds.map((id) => ` "arn:aws:iam::${id}:root",`).join("\n")}
|
||||
]
|
||||
}
|
||||
|
||||
output "endpoint_service_name" {
|
||||
description = "Paste this into the Trigger.dev dashboard"
|
||||
value = aws_vpc_endpoint_service.trigger_privatelink.service_name
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<InputGroup>
|
||||
<Label>Resource hostname or IP</Label>
|
||||
<Input
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
placeholder="my-database.abc123.us-east-1.rds.amazonaws.com"
|
||||
fullWidth
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<div>
|
||||
<Label>Ports</Label>
|
||||
<div className="mt-1 space-y-2">
|
||||
{ports.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={entry.port}
|
||||
onChange={(e) => updatePort(index, "port", e.target.value)}
|
||||
placeholder="Port"
|
||||
className="w-24"
|
||||
/>
|
||||
<select
|
||||
value={entry.protocol}
|
||||
onChange={(e) => updatePort(index, "protocol", e.target.value)}
|
||||
className="rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
|
||||
>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
</select>
|
||||
{ports.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePort(index)}
|
||||
className="text-text-dimmed transition hover:text-rose-400"
|
||||
title="Remove port"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPort}
|
||||
className="text-xs text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
+ Add port
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputGroup>
|
||||
<Label>Resource AWS Region</Label>
|
||||
<Select
|
||||
variant="tertiary/medium"
|
||||
value={region}
|
||||
setValue={(v) => setRegion(v)}
|
||||
items={AWS_REGIONS}
|
||||
filter={(item, search) => {
|
||||
const s = search.toLowerCase();
|
||||
return item.value.toLowerCase().includes(s) || item.label.toLowerCase().includes(s);
|
||||
}}
|
||||
text={(value) => {
|
||||
const r = AWS_REGIONS.find((r) => r.value === value);
|
||||
return r ? `${r.value} — ${r.label}` : value;
|
||||
}}
|
||||
placeholder="Select a region"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="text-text-bright">{item.value}</span>
|
||||
<span className="ml-2 text-text-dimmed">{item.label}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</InputGroup>
|
||||
|
||||
<div className="rounded-md border border-grid-bright bg-background-deep">
|
||||
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-dimmed">main.tf</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(terraformScript)}
|
||||
className="text-xs text-text-dimmed transition hover:text-text-bright"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto p-3 text-xs text-text-dimmed">
|
||||
<code>{terraformScript}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AIPromptWizard({ awsAccountIds }: { awsAccountIds: string[] }) {
|
||||
const [hostname, setHostname] = useState("");
|
||||
const [ports, setPorts] = useState<PortEntry[]>([{ port: "5432", protocol: "TCP" }]);
|
||||
const [region, setRegion] = useState("us-east-1");
|
||||
|
||||
const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]);
|
||||
const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index));
|
||||
const updatePort = (index: number, field: keyof PortEntry, value: string) =>
|
||||
setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
|
||||
|
||||
const validPorts = ports.filter((p) => p.port !== "");
|
||||
const regionLabel = AWS_REGIONS.find((r) => r.value === region)?.label ?? region;
|
||||
|
||||
const _portsDescription =
|
||||
validPorts.length > 0
|
||||
? validPorts.map((p) => `${p.port} (${p.protocol})`).join(", ")
|
||||
: "5432 (TCP)";
|
||||
|
||||
const prompt = `I need to set up AWS PrivateLink so that Trigger.dev can connect to my resource. Please create the following in my AWS account in the ${region} (${regionLabel}) region:
|
||||
|
||||
1. A Network Load Balancer (NLB):
|
||||
- Name: trigger-privatelink
|
||||
- Internal: yes
|
||||
- Type: network
|
||||
- Place it in my private subnets
|
||||
|
||||
2. For each of the following ports, create a target group, target group attachment, and listener:
|
||||
${validPorts.length > 0 ? validPorts.map((p) => ` - Port ${p.port} (${p.protocol})`).join("\n") : " - Port 5432 (TCP)"}
|
||||
|
||||
Each target group should:
|
||||
- Target type: ip
|
||||
- Target IP: ${hostname || "<my-resource-ip>"}
|
||||
- Have a TCP health check on the same port
|
||||
|
||||
3. A VPC Endpoint Service:
|
||||
- Acceptance required: no
|
||||
- Attach the NLB created above
|
||||
- Supported regions: us-east-1, eu-central-1 (these are the AWS regions Trigger.dev runs in, so the service must be consumable from both)
|
||||
- Allowed principals:
|
||||
${awsAccountIds.map((id) => ` - arn:aws:iam::${id}:root`).join("\n") || " - <Trigger.dev AWS account ARN>"}
|
||||
|
||||
After creating everything, give me the VPC Endpoint Service name (it looks like com.amazonaws.vpce.<region>.vpce-svc-*) so I can paste it into the Trigger.dev dashboard.`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<InputGroup>
|
||||
<Label>Resource hostname or IP</Label>
|
||||
<Input
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
placeholder="my-database.abc123.us-east-1.rds.amazonaws.com"
|
||||
fullWidth
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<div>
|
||||
<Label>Ports</Label>
|
||||
<div className="mt-1 space-y-2">
|
||||
{ports.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={entry.port}
|
||||
onChange={(e) => updatePort(index, "port", e.target.value)}
|
||||
placeholder="Port"
|
||||
className="w-24"
|
||||
/>
|
||||
<select
|
||||
value={entry.protocol}
|
||||
onChange={(e) => updatePort(index, "protocol", e.target.value)}
|
||||
className="rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
|
||||
>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
</select>
|
||||
{ports.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePort(index)}
|
||||
className="text-text-dimmed transition hover:text-rose-400"
|
||||
title="Remove port"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPort}
|
||||
className="text-xs text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
+ Add port
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputGroup>
|
||||
<Label>Resource AWS Region</Label>
|
||||
<Select
|
||||
variant="tertiary/medium"
|
||||
value={region}
|
||||
setValue={(v) => setRegion(v)}
|
||||
items={AWS_REGIONS}
|
||||
filter={(item, search) => {
|
||||
const s = search.toLowerCase();
|
||||
return item.value.toLowerCase().includes(s) || item.label.toLowerCase().includes(s);
|
||||
}}
|
||||
text={(value) => {
|
||||
const r = AWS_REGIONS.find((r) => r.value === value);
|
||||
return r ? `${r.value} — ${r.label}` : value;
|
||||
}}
|
||||
placeholder="Select a region"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="text-text-bright">{item.value}</span>
|
||||
<span className="ml-2 text-text-dimmed">{item.label}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</InputGroup>
|
||||
|
||||
<div className="rounded-md border border-grid-bright bg-background-deep">
|
||||
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-dimmed">AI Prompt</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(prompt)}
|
||||
className="text-xs text-text-dimmed transition hover:text-text-bright"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap p-3 text-xs text-text-dimmed">
|
||||
{prompt}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { availableRegions, activeRegions, awsAccountIds } = useTypedLoaderData<typeof loader>();
|
||||
const { organizationSlug } = useParams();
|
||||
const lastSubmission = useActionData();
|
||||
const [setupMethod, setSetupMethod] = useState<SetupMethod | null>("manual");
|
||||
|
||||
const defaultRegion = "us-east-1";
|
||||
|
||||
const [form, { name, endpointServiceName, targetRegion }] = useForm({
|
||||
id: "create-private-connection",
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
title="Add Private Connection"
|
||||
backButton={{
|
||||
to: v3PrivateConnectionsPath({ slug: organizationSlug! }),
|
||||
text: "Private Connections",
|
||||
}}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("private-networking/overview")}
|
||||
>
|
||||
Private connection docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<MainHorizontallyCenteredContainer className="max-w-3xl">
|
||||
<div>
|
||||
<div className="mb-4 border-b border-grid-dimmed pb-3">
|
||||
<Header2 spacing>Add Private Connection</Header2>
|
||||
<Paragraph variant="small">
|
||||
Connect your AWS resources to Trigger.dev task pods via AWS PrivateLink. You'll need
|
||||
to create a VPC Endpoint Service on your AWS account first.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Setup method cards */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupMethod("manual")}
|
||||
className={`rounded-lg border p-4 text-left transition ${
|
||||
setupMethod === "manual"
|
||||
? "border-indigo-500 bg-indigo-500/10"
|
||||
: "border-grid-dimmed hover:border-border-bright"
|
||||
}`}
|
||||
>
|
||||
<PencilSquareIcon className="mb-2 h-5 w-5 text-indigo-400" />
|
||||
<div className="text-sm font-medium text-text-bright">I have my details</div>
|
||||
<div className="mt-1 text-xs text-text-dimmed">
|
||||
Already have your VPC Endpoint Service name
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupMethod("ai")}
|
||||
className={`rounded-lg border p-4 text-left transition ${
|
||||
setupMethod === "ai"
|
||||
? "border-indigo-500 bg-indigo-500/10"
|
||||
: "border-grid-dimmed hover:border-border-bright"
|
||||
}`}
|
||||
>
|
||||
<SparklesIcon className="mb-2 h-5 w-5 text-purple-400" />
|
||||
<div className="text-sm font-medium text-text-bright">Set up with AI</div>
|
||||
<div className="mt-1 text-xs text-text-dimmed">
|
||||
Generate a prompt for your AI assistant
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupMethod("terraform")}
|
||||
className={`rounded-lg border p-4 text-left transition ${
|
||||
setupMethod === "terraform"
|
||||
? "border-indigo-500 bg-indigo-500/10"
|
||||
: "border-grid-dimmed hover:border-border-bright"
|
||||
}`}
|
||||
>
|
||||
<CommandLineIcon className="mb-2 h-5 w-5 text-emerald-400" />
|
||||
<div className="text-sm font-medium text-text-bright">Set up with Terraform</div>
|
||||
<div className="mt-1 text-xs text-text-dimmed">
|
||||
Generate a customized Terraform script
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupMethod("docs")}
|
||||
className={`rounded-lg border p-4 text-left transition ${
|
||||
setupMethod === "docs"
|
||||
? "border-indigo-500 bg-indigo-500/10"
|
||||
: "border-grid-dimmed hover:border-border-bright"
|
||||
}`}
|
||||
>
|
||||
<DocumentTextIcon className="mb-2 h-5 w-5 text-amber-400" />
|
||||
<div className="text-sm font-medium text-text-bright">Step-by-step guide</div>
|
||||
<div className="mt-1 text-xs text-text-dimmed">
|
||||
Visual instructions for the AWS Console
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI prompt wizard */}
|
||||
{setupMethod === "ai" && (
|
||||
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
|
||||
<Header3 spacing>AI-Assisted Setup</Header3>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Fill in your resource details below and we'll generate a prompt you can paste into
|
||||
Claude, ChatGPT, or any AI assistant with AWS access. After it creates the
|
||||
resources, paste the VPC Endpoint Service name below.
|
||||
</Paragraph>
|
||||
<AIPromptWizard awsAccountIds={awsAccountIds} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terraform wizard (expandable) */}
|
||||
{setupMethod === "terraform" && (
|
||||
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
|
||||
<Header3 spacing>Terraform Configuration</Header3>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Fill in your resource details below and we'll generate a Terraform script. Run{" "}
|
||||
<code className="rounded bg-background-bright px-1 py-0.5 text-xs">
|
||||
terraform apply
|
||||
</code>{" "}
|
||||
to create the VPC Endpoint Service, then paste the output service name below.
|
||||
</Paragraph>
|
||||
<TerraformWizard awsAccountIds={awsAccountIds} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Docs link */}
|
||||
{setupMethod === "docs" && (
|
||||
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
|
||||
<Header3 spacing>Setup Guide</Header3>
|
||||
{awsAccountIds.length > 0 && (
|
||||
<>
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
When adding allowed principals to your VPC Endpoint Service, use the following
|
||||
AWS account ARN(s):
|
||||
</Paragraph>
|
||||
<div className="mb-4 space-y-2">
|
||||
{awsAccountIds.map((id) => (
|
||||
<ClipboardField
|
||||
key={id}
|
||||
value={`arn:aws:iam::${id}:root`}
|
||||
variant="primary/medium"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
Follow the step-by-step guide in our docs to create a VPC Endpoint Service in the
|
||||
AWS Console, then come back here to add the connection.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("private-networking/aws-console-setup")}
|
||||
variant="primary/medium"
|
||||
TrailingIcon={ArrowTopRightOnSquareIcon}
|
||||
>
|
||||
Open setup guide
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AWS account ARNs reference */}
|
||||
{setupMethod === "manual" && awsAccountIds.length > 0 && (
|
||||
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
Add the following AWS account ARN(s) to your VPC Endpoint Service's allowed
|
||||
principals:
|
||||
</Paragraph>
|
||||
<div className="space-y-2">
|
||||
{awsAccountIds.map((id) => (
|
||||
<ClipboardField
|
||||
key={id}
|
||||
value={`arn:aws:iam::${id}:root`}
|
||||
variant="primary/medium"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection form (always visible) */}
|
||||
<div className="rounded-lg border border-grid-dimmed p-4">
|
||||
<Header3 spacing>Connection Details</Header3>
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={name.id} required>
|
||||
Friendly name
|
||||
</Label>
|
||||
<Input
|
||||
{...getInputProps(name, { type: "text" })}
|
||||
placeholder="e.g., Production Database, Redis Cache"
|
||||
fullWidth
|
||||
/>
|
||||
<FormError id={name.errorId}>{name.errors}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={endpointServiceName.id} required>
|
||||
VPC Endpoint Service name
|
||||
</Label>
|
||||
<Input
|
||||
{...getInputProps(endpointServiceName, { type: "text" })}
|
||||
placeholder="com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0"
|
||||
fullWidth
|
||||
/>
|
||||
<FormError id={endpointServiceName.errorId}>
|
||||
{endpointServiceName.errors}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={targetRegion.id} required>
|
||||
Target region
|
||||
</Label>
|
||||
<select
|
||||
{...getSelectProps(targetRegion)}
|
||||
defaultValue={defaultRegion}
|
||||
className="w-full rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
|
||||
>
|
||||
{availableRegions.map((region: string) => (
|
||||
<option key={region} value={region}>
|
||||
{region}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FormError id={targetRegion.errorId}>{targetRegion.errors}</FormError>
|
||||
{activeRegions.length > 0 && (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Your tasks have recently run in: {activeRegions.join(", ")}
|
||||
</Paragraph>
|
||||
)}
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
cancelButton={
|
||||
<LinkButton variant="tertiary/small" to="..">
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/small">
|
||||
Create Connection
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Roles | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const Params = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: Params,
|
||||
context: async (params) => {
|
||||
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return orgId ? { organizationId: orgId } : {};
|
||||
},
|
||||
authorization: { action: "read", resource: { type: "members" } },
|
||||
},
|
||||
async ({ context, user }) => {
|
||||
const orgId = context.organizationId;
|
||||
if (!orgId) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRole] =
|
||||
await Promise.all([
|
||||
rbac.allRoles(orgId),
|
||||
rbac.getAssignableRoleIds(orgId),
|
||||
rbac.allPermissions(orgId),
|
||||
rbac.systemRoles(orgId),
|
||||
// OSS self-host has no RBAC plugin.
|
||||
rbac.isUsingPlugin(),
|
||||
rbac.getUserRole({ userId: user.id, organizationId: orgId }),
|
||||
]);
|
||||
|
||||
return typedjson({
|
||||
roles,
|
||||
assignableRoleIds,
|
||||
allPermissions,
|
||||
systemRoles,
|
||||
isUsingPlugin,
|
||||
currentRoleName: currentRole?.name ?? null,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
type LoaderData = UseDataFunctionReturn<typeof loader>;
|
||||
type LoaderRole = LoaderData["roles"][number];
|
||||
type LoaderPermission = LoaderData["allPermissions"][number];
|
||||
type RolePermission = LoaderRole["permissions"][number];
|
||||
|
||||
// Ungrouped permissions fall into "Other".
|
||||
const FALLBACK_GROUP = "Other";
|
||||
|
||||
export default function Page() {
|
||||
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
const rolesById = new Map<string, LoaderRole>(roles.map((r) => [r.id, r]));
|
||||
const assignable = new Set(assignableRoleIds);
|
||||
|
||||
// System roles first (plugin order), then custom roles.
|
||||
const systemRoleOrder = systemRoles ?? [];
|
||||
const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
|
||||
const systemColumns = systemRoleOrder.flatMap((meta) => {
|
||||
const role = rolesById.get(meta.id);
|
||||
return role ? [{ role, fallbackName: meta.name }] : [];
|
||||
});
|
||||
const customColumns = roles
|
||||
.filter((r) => !systemRoleIdSet.has(r.id))
|
||||
.map((role) => ({ role, fallbackName: role.name }));
|
||||
const columns = [...systemColumns, ...customColumns];
|
||||
|
||||
const grouped = groupPermissions(allPermissions);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Roles" />
|
||||
{/* Hide on OSS self-host and managed customers (!showSelfServe). */}
|
||||
{isUsingPlugin && showSelfServe ? <RequestCustomRoles /> : null}
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="border-b border-grid-bright px-4 py-6">
|
||||
<Paragraph variant="small">
|
||||
Roles control what each team member can do in <strong>{organization.title}</strong>.
|
||||
Compare what each role grants below; assign a role to a team member from the{" "}
|
||||
<TextLink to={`/orgs/${organization.slug}/settings/team`}>Team page</TextLink>.
|
||||
</Paragraph>
|
||||
{currentRoleName ? (
|
||||
<Paragraph variant="small" className="mt-2">
|
||||
Your role is <strong className="text-text-bright">{currentRoleName}</strong>.
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{columns.length === 0 ? (
|
||||
<EmptyState isUsingPlugin={isUsingPlugin} showSelfServe={showSelfServe} />
|
||||
) : (
|
||||
<Table stickyHeader containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Permission</TableHeaderCell>
|
||||
{columns.map(({ role }) => (
|
||||
<TableHeaderCell key={role.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{role.name}</span>
|
||||
<PlanBadge
|
||||
roleId={role.id}
|
||||
assignable={assignable}
|
||||
systemRoleIdSet={systemRoleIdSet}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grouped.length === 0 ? (
|
||||
<TableBlankRow colSpan={columns.length + 2}>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
No permissions to display.
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
grouped.flatMap(({ group, permissions }) => [
|
||||
<TableRow key={`${group}-header`}>
|
||||
<TableCell colSpan={columns.length + 2} className="bg-background-bright">
|
||||
<Header3 className="text-xs uppercase tracking-wide text-text-dimmed">
|
||||
{group}
|
||||
</Header3>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
...permissions.map((permission) => (
|
||||
<TableRow key={permission.name}>
|
||||
<TableCell>
|
||||
<code className="text-xs">{permission.name}</code>
|
||||
</TableCell>
|
||||
{columns.map(({ role }) => (
|
||||
<TableCell key={role.id}>
|
||||
<RoleCell
|
||||
permissionName={permission.name}
|
||||
rolePermissions={role.permissions}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<Paragraph variant="small">
|
||||
{permission.description || (
|
||||
<span className="text-text-dimmed">—</span>
|
||||
)}
|
||||
</Paragraph>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
])
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
isUsingPlugin,
|
||||
showSelfServe,
|
||||
}: {
|
||||
isUsingPlugin: boolean;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
// OSS self-host vs plan-gated empty state.
|
||||
if (!isUsingPlugin) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-8 text-center">
|
||||
<Header3>Roles aren't available in this self-hosted deployment.</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
All members have full access. Role-Based Access Controls are available in Trigger.dev
|
||||
Cloud or with an enterprise self-hosted license.
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-8 text-center">
|
||||
<Header3>No roles available on this plan.</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{showSelfServe
|
||||
? "Upgrade to Pro to unlock RBAC."
|
||||
: "Contact us to discuss RBAC for your organization."}
|
||||
</Paragraph>
|
||||
{!showSelfServe ? (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanBadge({
|
||||
roleId,
|
||||
assignable,
|
||||
systemRoleIdSet,
|
||||
}: {
|
||||
roleId: string;
|
||||
assignable: ReadonlySet<string>;
|
||||
systemRoleIdSet: ReadonlySet<string>;
|
||||
}) {
|
||||
if (assignable.has(roleId)) return null;
|
||||
// Unassignable system roles → Pro; custom roles → Enterprise.
|
||||
if (systemRoleIdSet.has(roleId)) {
|
||||
return <Badge variant="extra-small">Pro</Badge>;
|
||||
}
|
||||
return <Badge variant="extra-small">Enterprise</Badge>;
|
||||
}
|
||||
|
||||
function RoleCell({
|
||||
permissionName,
|
||||
rolePermissions,
|
||||
}: {
|
||||
permissionName: string;
|
||||
rolePermissions: RolePermission[];
|
||||
}) {
|
||||
const matching = rolePermissions.filter((p) => p.name === permissionName);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return (
|
||||
<span className="text-text-dimmed" aria-label="Not granted">
|
||||
<XMarkIcon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const allowed = matching.filter((p) => !p.inverted);
|
||||
const denied = matching.filter((p) => p.inverted);
|
||||
|
||||
if (allowed.length === 0) {
|
||||
return (
|
||||
<span className="text-error" aria-label="Denied">
|
||||
<XMarkIcon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const conditionalDeny = denied.find((p) => p.conditions);
|
||||
if (conditionalDeny?.conditions) {
|
||||
const allowedEnvTypes = allowedEnvTypesFromDeny(conditionalDeny.conditions);
|
||||
if (allowedEnvTypes) {
|
||||
// Conditional grant: show the environments the permission is allowed in.
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{allowedEnvTypes.map((type) => (
|
||||
<EnvironmentCombo key={type} environment={{ type }} className="text-xs" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Conditions we can't map to environments fall back to a text label.
|
||||
return (
|
||||
<span className="text-xs text-text-dimmed">{conditionLabel(conditionalDeny.conditions)}</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-success" aria-label="Allowed">
|
||||
<CheckIcon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ENV_TYPES = ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const;
|
||||
type EnvType = (typeof ENV_TYPES)[number];
|
||||
|
||||
// A conditional `cannot` rule denies the permission where the resource matches
|
||||
// its condition, so the permission stays allowed everywhere else. Translate the
|
||||
// envType condition into the set of environments where it's still allowed, or
|
||||
// null when we can't interpret it (caller falls back to a text label).
|
||||
function allowedEnvTypesFromDeny(conditions: Record<string, unknown>): EnvType[] | null {
|
||||
const envType = conditions.envType;
|
||||
// Equality, e.g. { envType: "PRODUCTION" } → denied in prod, allowed elsewhere.
|
||||
if (typeof envType === "string") {
|
||||
return ENV_TYPES.includes(envType as EnvType) ? ENV_TYPES.filter((t) => t !== envType) : null;
|
||||
}
|
||||
// Negation, e.g. { envType: { $ne: "DEVELOPMENT" } } → denied everywhere except
|
||||
// DEVELOPMENT, so allowed only in DEVELOPMENT.
|
||||
if (envType && typeof envType === "object" && "$ne" in envType) {
|
||||
const ne = (envType as { $ne: unknown }).$ne;
|
||||
return typeof ne === "string" && ENV_TYPES.includes(ne as EnvType) ? [ne as EnvType] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only `envType` is supported today.
|
||||
function conditionLabel(conditions: Record<string, unknown>): string {
|
||||
if (typeof conditions.envType === "string") {
|
||||
if (conditions.envType === "PRODUCTION") return "Non-prod only";
|
||||
return `Non-${conditions.envType.toLowerCase()} only`;
|
||||
}
|
||||
return JSON.stringify(conditions);
|
||||
}
|
||||
|
||||
function groupPermissions(
|
||||
permissions: LoaderPermission[]
|
||||
): { group: string; permissions: LoaderPermission[] }[] {
|
||||
const buckets = new Map<string, LoaderPermission[]>();
|
||||
for (const permission of permissions) {
|
||||
const group = permission.group ?? FALLBACK_GROUP;
|
||||
const list = buckets.get(group) ?? [];
|
||||
list.push(permission);
|
||||
buckets.set(group, list);
|
||||
}
|
||||
return Array.from(buckets, ([group, permissions]) => ({ group, permissions }));
|
||||
}
|
||||
|
||||
function RequestCustomRoles() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small">Create role</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Custom roles are an Enterprise feature</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Paragraph>
|
||||
Define your own roles with bespoke permission sets — perfect for "Member, but no
|
||||
production deploys" or a vendor/contractor role. Available on the Enterprise plan.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Get in touch and we'll walk you through the Enterprise plan and how custom roles fit
|
||||
your team.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="secondary/medium" onClick={() => setOpen(false)}>
|
||||
Maybe later
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={() => {
|
||||
window.open("https://trigger.dev/contact", "_blank");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Contact us
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user