Compare commits

...

32 Commits

Author SHA1 Message Date
Matt Aitken 70f9bd0d70 Latest lockfile 2024-02-12 13:11:14 +00:00
github-actions[bot] a1860dbaae chore: Update version for release (#899)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-02-12 13:00:14 +00:00
Matt Aitken d69e4e712d OpenAI integration: retry 400 status code because OpenAI returns this sometimes for no reason (#898) 2024-02-12 12:48:48 +00:00
Matt Aitken 7fae67c47d Fix/autoyield canceled run (#890)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 16s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Todo for the fix

* If the run has been canceled during execution, don’t do any further processing of the run
2024-02-09 11:06:19 +00:00
Matt Aitken 9ae0ca64af Fix edge cases with deleting last org/project (#892)
* If you have an org with no projects, it displays in the project dropdown with a “New project” button

* When creating a new org disable the button whilst it’s doing the request

* If an org already had any deleted projects it couldn’t be deleted…

* When selecting the best project, factor in deleted ones

* Don’t show the cancel button when creating a new org if there are no non-deleted projects

* If a project has already been deleted just return
2024-02-09 11:05:28 +00:00
Matt Aitken da69fa0613 Fixes #886. Removed “public” schema from the JobListPresenter query
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-02-07 16:48:18 +00:00
Matt Aitken 336029b842 Organization renaming, deleting and one-click email unsubscribe (#885)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Added the org settings page to the sidebar

* Added loading states when renaming/deleting projects

* Don’t show deleted orgs in the app

* The actual db migration file

* The Org settings page with the actions working

* Don’t remove org members, just leave them

* Don’t show invites from orgs that are deleted

* Allow disabling IntegrationConnections

* Don’t refresh IntegrationConnections that are disabled

* Set all the integrations as disabled

* Updated the unsubscribe checkbox text

* Unsubscribe route

* Use the magic link secret, not the encryption key. Also make the error message more vague

* Only members of the org or project can rename them

* Don’t throw an error if the connection can’t be refreshed, return undefined instead
2024-02-07 10:52:50 +00:00
Matt Aitken 29edcd3df9 Rename and delete projects (#880)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Side menu: selector title changed to project. Changed section headers and made it Slack + Discord

* Added project settings page. Renaming is working

* Added project.deletedAt column

* Deleting projects WIP

* Allow clearing the project from the session

* Don’t load projects that are deleted

* Improved the project selection logic

* Clear the session project id when deleting a project

* Deleting the last project in an org now works. The new project page doesn’t have the sidebar anymore.

* Removed false code comment

* Added the JobRun index back in, using Prisma. Otherwise it tries to remove it on each migration

* Only return environments where the project isn’t deleted. This will block API calls

* Do the project deletedAt check in code, not SQL

* Made it clearer what the code does with a comment and applied the same logic to the public api key
2024-02-05 13:14:33 +00:00
Matt Aitken a9ff32418e Fix for when there are no jobs…
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-30 17:35:04 +00:00
Matt Aitken 7358fcb891 Latest lockfile 2024-01-30 17:07:24 +00:00
Matt Aitken 28052daad9 Faster jobs page (#879)
* Optional next/previous with the list pagination

* Defer the loading of the runs table

* Only return the latest run in a separate query, then do a join in code

* Added a composite index to speed up getting the latest run from a job id
2024-01-30 17:02:04 +00:00
github-actions[bot] 364c8c5f7f chore: Update version for release (#876)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-30 13:36:36 +00:00
Matt Aitken 4b3b418abb Set endpoint URLs to null, instead of deleting them (#878)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Added Endpoint deletedAt column

* Only show endpoints where they’re not deleted

* Don’t delete Endpoints, set the deletedAt and change their slug name

* Only perform indexing if the endpoint isn’t deleted

* Have a nullable URL for endpoints

* Deal with null URLs throughout the app

* Re-running and retrying behaves properly when there’s no endpoint URL

* Remove console.log

* Better error message when doing a run
2024-01-30 10:45:12 +00:00
Matt Aitken 2e354d342c Deleting endpoint working (#875) 2024-01-29 11:57:25 +00:00
Matt Aitken dd879c8e4a Fix api run statuses (#874)
* Added a subtask for testing

* Make it easier to run the CLI from the nextjs reference project

* Copies of the run and statuses endpoints, but without simplifying the run statuses

* Use the new v2 endpoints that give the full run statuses

* Removed unused import

* v2 events endpoint with the full run status info

* Changeset
2024-01-29 11:42:19 +00:00
Matt Aitken af485b9180 Fix custom oauth client (#872)
* A checkbox can now be readonly and not be checkable, with correct styling

* Use readOnly for the hasCustomClient checkbox, not disabled

* Better styling of the disabled state

* The update oauth form too
2024-01-26 17:25:15 +00:00
Matt Aitken a739ebaa88 Tweaked the layout of the usage runs charts so the daily runs are in the box with other run charts 2024-01-26 15:05:52 +00:00
Kritik Jiyaviya 3ebc2578e0 feat: Add daily runs graph (#865)
* feat: Add daily runs graph

* Merge branch 'main' into feat/daily-runs-graph

* update DayRunsChart

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-26 15:00:30 +00:00
Kritik Jiyaviya 0b657b33f9 Allow retrying a job run with an invalid payload (#869) 2024-01-26 12:39:23 +00:00
Erin Allison 7ac942dc0e Address unintended type coercion in PageInfoPropertyComponent (#861)
Fixes triggerdotdev/trigger.dev#858

Signed-off-by: Erin Allison <erin@eallison.us>
2024-01-26 09:44:55 +00:00
Matt Aitken 9b12016428 Improved SQL reads for some dashboard pages (#868)
* Pass subscription status into the usage bar

* Page navigation spinner is now blue (was a bit subtle before)

* Better logging of db queries, this will be commented out before the PR is merged

* Select only the required fields

* We don’t need the member count for each org

* WIP redirecting with projectId in session

* Switching projects is now working, without duplicating the project query

* Removed logs in revalidate function

* Removes some unused imports

* ProjectPresenter: removed lots of unused db selects

* Root use defaultShouldRevalidate, not just true

* Simplified the job list query and separated the deleting job modal query

* Use requireUserId instead of requireUser wherever possible

* EventListPresenter query simplified

* Simplified the RunListPresenter query

* Disable query logging

* Use the latest updated version for the job list table

* We need to use the org presenter on the select plan page
2024-01-26 09:41:11 +00:00
Matt Aitken d6b44de4ba Defer the loading of the usage data (#866)
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Updated to the latest remix-typedjson package

* Throw an error if the org isn’t found

* Use defer. Doesn’t work because Date isn’t deserialized correctly

* Use regular defer, typeddefer isn’t working properly…

* Don’t update remix-typedjson, wasn’t needed

* Organize imports
2024-01-22 10:26:33 +00:00
Matt Aitken 17df4839d7 Latest lockfile
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-21 17:30:21 +00:00
github-actions[bot] e3f78178f7 chore: Update version for release (#864)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-21 17:29:23 +00:00
Kritik Jiyaviya da90ee13c3 fix: Resend integration example comment (#860) 2024-01-21 17:16:26 +00:00
Matt Aitken 583da458ec Changed the minimum interval time period from 60s to 20s (with changeset) 2024-01-21 17:06:09 +00:00
Matt Aitken dcf95c4eb2 Revert "Changeset: Changed the minimum interval time from 60s to 20s"
This reverts commit c272e38de2.
2024-01-21 17:04:11 +00:00
Matt Aitken 32cf5790cb Revert "Changed the minimum interval time from 60s to 20s"
This reverts commit ca78ddc2c2.
2024-01-21 17:04:11 +00:00
Matt Aitken c272e38de2 Changeset: Changed the minimum interval time from 60s to 20s 2024-01-21 16:29:54 +00:00
Matt Aitken ca78ddc2c2 Changed the minimum interval time from 60s to 20s 2024-01-21 16:28:28 +00:00
Matt Aitken 07ed8c346a Fix for enabled env var in docs 2024-01-20 14:01:56 +00:00
Matt Aitken d4391f2e2d Fixed link in io.logger to the run log 2024-01-20 12:54:11 +00:00
161 changed files with 3163 additions and 842 deletions
@@ -22,7 +22,7 @@ const tooltipStyle = {
color: "#E2E8F0",
};
type DataItem = { date: Date; maxConcurrentRuns: number };
type DataItem = { date: string; maxConcurrentRuns: number };
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
@@ -62,25 +62,34 @@ export function ConcurrentRunsChart({
tickLine={false}
axisLine={false}
dataKey={(item: DataItem) => {
if (item.date.getDate() === 1) {
return dateFormatter.format(item.date);
if (!item.date) return "";
const date = new Date(item.date);
if (date.getDate() === 1) {
return dateFormatter.format(date);
}
return `${item.date.getDate()}`;
return `${date.getDate()}`;
}}
className="text-xs"
>
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
</XAxis>
<YAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} />
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
contentStyle={tooltipStyle}
labelFormatter={(value, data) => {
const date = data.at(0)?.payload.date;
if (!date) {
const dateString = data.at(0)?.payload.date;
if (!dateString) {
return "";
}
return dateFormatter.format(date);
return dateFormatter.format(new Date(dateString));
}}
/>
{concurrentRunsLimit && (
@@ -0,0 +1,89 @@
import { Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Paragraph } from "../primitives/Paragraph";
const tooltipStyle = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
border: "1px solid #1A2434",
backgroundColor: "#0B1018",
padding: "0.3rem 0.5rem",
fontSize: "0.75rem",
color: "#E2E8F0",
};
type DataItem = { date: string; runs: number };
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
export function DailyRunsChart({
data,
hasDailyRunsData,
}: {
data: DataItem[];
hasDailyRunsData: boolean;
}) {
return (
<div className="relative">
{!hasDailyRunsData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No daily Runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
<LineChart
data={data}
margin={{
top: 20,
right: 0,
left: 0,
bottom: 10,
}}
className="-ml-8"
>
<XAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
dataKey={(item: DataItem) => {
if (!item.date) return "";
const date = new Date(item.date);
if (date.getDate() === 1) {
return dateFormatter.format(date);
}
return `${date.getDate()}`;
}}
className="text-xs"
>
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
</XAxis>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
contentStyle={tooltipStyle}
labelFormatter={(value, data) => {
const dateString = data.at(0)?.payload.date;
if (!dateString) {
return "";
}
return dateFormatter.format(new Date(dateString));
}}
/>
<Line dataKey="runs" name="Runs" stroke="#16A34A" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -127,7 +127,7 @@ export function ConnectToOAuthForm({
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
disabled={requiresCustomOAuthApp}
readOnly={requiresCustomOAuthApp}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
defaultChecked={requiresCustomOAuthApp}
@@ -135,8 +135,9 @@ export function ConnectToOAuthForm({
{useMyOAuthApp && (
<div className="ml-6 mt-2">
<Paragraph variant="small" className="mb-2">
Set the callback url to <CodeBlock code={callbackUrl} showLineNumbers={false} />
Set the callback url to
</Paragraph>
<CodeBlock code={callbackUrl} showLineNumbers={false} />
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<InputGroup fullWidth>
@@ -117,7 +117,7 @@ export function UpdateOAuthForm({
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
disabled={requiresCustomOAuthApp}
readOnly={requiresCustomOAuthApp}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
defaultChecked={requiresCustomOAuthApp}
@@ -1,20 +1,45 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useFetcher } from "@remix-run/react";
import { useEffect } from "react";
import { loader } from "~/routes/resources.jobs.$jobId";
import { cn } from "~/utils/cn";
import { JobStatusTable } from "../JobsStatusTable";
import { JobEnvironment, JobStatusTable } from "../JobsStatusTable";
import { Button } from "../primitives/Buttons";
import { Header1, Header2 } from "../primitives/Headers";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { useFetcher } from "@remix-run/react";
import { Spinner } from "../primitives/Spinner";
import { TextLink } from "../primitives/TextLink";
import { useTypedFetcher } from "remix-typedjson";
type JobEnvironment = {
type: RuntimeEnvironmentType;
lastRun?: Date;
version: string;
enabled: boolean;
};
export function DeleteJobDialog({ id, title, slug }: { id: string; title: string; slug: string }) {
const fetcher = useTypedFetcher<typeof loader>();
useEffect(() => {
fetcher.load(`/resources/jobs/${id}`);
}, [id]);
const isLoading = fetcher.state === "loading" || fetcher.state === "submitting";
if (isLoading || !fetcher.data) {
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
<Header1>{title}</Header1>
<Paragraph variant="small">ID: {slug}</Paragraph>
</div>
<Spinner />
</div>
);
} else {
return (
<DeleteJobDialogContent
id={id}
title={title}
slug={slug}
environments={fetcher.data.environments}
/>
);
}
}
type DeleteJobDialogContentProps = {
id: string;
+6 -11
View File
@@ -22,7 +22,7 @@ import {
} from "../primitives/Table";
import { SimpleTooltip } from "../primitives/Tooltip";
import { runStatusTitle } from "../runs/RunStatuses";
import { DeleteJobDialogContent } from "./DeleteJobModalContent";
import { DeleteJobDialog, DeleteJobDialogContent } from "./DeleteJobModalContent";
import { JobStatusBadge } from "./JobStatusBadge";
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
@@ -49,13 +49,13 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<TableRow key={job.id} className="group">
<TableCell to={path}>
<span className="flex items-center gap-2">
<NamedIcon name={job.event.icon} className="w-8 h-8" />
<NamedIcon name={job.event.icon} className="h-8 w-8" />
<LabelValueStack
label={job.title}
value={
job.dynamic ? (
<span className="flex items-center gap-0.5">
<NamedIcon name="dynamic" className="w-4 h-4" />{" "}
<NamedIcon name="dynamic" className="h-4 w-4" />{" "}
<span className="uppercase">Dynamic:</span> {job.event.title}
</span>
) : (
@@ -75,9 +75,9 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
key={integration.key}
button={
<div className="relative">
<NamedIcon name={integration.icon} className="w-6 h-6" />
<NamedIcon name={integration.icon} className="h-6 w-6" />
{integration.setupStatus === "MISSING_FIELDS" && (
<NamedIcon name="error" className="absolute w-4 h-4 -left-1 -top-1" />
<NamedIcon name="error" className="absolute -left-1 -top-1 h-4 w-4" />
)}
</div>
}
@@ -165,12 +165,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
</DialogTrigger>
<DialogContent>
<DialogHeader>Delete Job</DialogHeader>
<DeleteJobDialogContent
id={job.id}
title={job.title}
slug={job.slug}
environments={job.environments}
/>
<DeleteJobDialog id={job.id} title={job.title} slug={job.slug} />
</DialogContent>
</Dialog>
</TableCellMenu>
@@ -5,6 +5,6 @@ import { cn } from "~/utils/cn";
export function PageNavigationIndicator({ className }: { className?: string }) {
const navigation = useNavigation();
if (navigation.state === "loading") {
return <Spinner color="muted" className={cn("h-4 w-4", className)} />;
return <Spinner color="blue" className={cn("h-4 w-4", className)} />;
}
}
@@ -4,7 +4,6 @@ import {
ArrowRightOnRectangleIcon,
ChartBarIcon,
CursorArrowRaysIcon,
EllipsisHorizontalIcon,
ShieldCheckIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
@@ -15,6 +14,7 @@ import { useFeatures } from "~/hooks/useFeatures";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { useV3Enabled } from "~/root";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { cn } from "~/utils/cn";
import {
@@ -26,6 +26,7 @@ import {
organizationBillingPath,
organizationIntegrationsPath,
organizationPath,
organizationSettingsPath,
organizationTeamPath,
personalAccessTokensPath,
projectEnvironmentsPath,
@@ -33,6 +34,7 @@ import {
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
projectSettingsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
@@ -56,9 +58,8 @@ import {
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
import { SideMenuHeader } from "./SideMenuHeader";
import { useV3Enabled } from "~/root";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
@@ -106,11 +107,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
showHeaderDivider ? " border-border" : "border-transparent"
)}
>
<ProjectSelector
organization={organization}
organizations={organizations}
project={project}
/>
<ProjectSelector organizations={organizations} project={project} />
<UserMenu user={user} />
</div>
<div
@@ -118,7 +115,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={project.name || "No project found"}>
<SideMenuHeader title={"Project"}>
<PopoverMenuItem
to={projectSetupPath(organization, project)}
title="Framework setup"
@@ -168,9 +165,16 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
to={projectEnvironmentsPath(organization, project)}
data-action="environments & api keys"
/>
<SideMenuItem
name="Project settings"
icon="settings"
iconColor="text-teal-500"
to={projectSettingsPath(organization, project)}
data-action="project-settings"
/>
</div>
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title={organization.title}>
<SideMenuHeader title={"Organization"}>
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
<PopoverMenuItem
to={inviteTeamMemberPath(organization)}
@@ -206,10 +210,17 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
iconColor="text-green-600"
data-action="usage & billing"
/>
<SideMenuItem
name="Organization settings"
icon="settings"
iconColor="text-teal-500"
to={organizationSettingsPath(organization)}
data-action="organization-settings"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
{currentPlan?.subscription?.isPaying === true ? (
{currentPlan?.subscription?.isPaying === true && (
<Dialog>
<DialogTrigger asChild>
<Button
@@ -265,16 +276,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</div>
</DialogContent>
</Dialog>
) : (
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
)}
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Documentation"
icon="docs"
@@ -317,11 +326,9 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
function ProjectSelector({
project,
organization,
organizations,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
}) {
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
@@ -339,7 +346,7 @@ function ProjectSelector({
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
>
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
<span className="truncate">{organization.title ?? "Select an organization"}</span>
<span className="truncate">{project.name ?? "Select a project"}</span>
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
@@ -350,23 +357,31 @@ function ProjectSelector({
<Fragment key={organization.id}>
<PopoverSectionHeader title={organization.title} />
<div className="flex flex-col gap-1 p-1">
{organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={projectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-bright">
<span className="grow truncate text-left">{p.name}</span>
<MenuCount count={p.jobCount} />
</div>
}
isSelected={isSelected}
icon="folder"
/>
);
})}
{organization.projects.length > 0 ? (
organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={projectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-bright">
<span className="grow truncate text-left">{p.name}</span>
<MenuCount count={p.jobCount} />
</div>
}
isSelected={isSelected}
icon="folder"
/>
);
})
) : (
<PopoverMenuItem
to={newProjectPath(organization)}
title="New project"
icon="plus"
/>
)}
</div>
</Fragment>
))}
@@ -109,14 +109,16 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
return (
<div
className={cn(
"group flex cursor-pointer items-start gap-x-2 transition",
"group flex items-start gap-x-2 transition ",
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
buttonClassName,
isChecked && isCheckedClassName,
isDisabled && isDisabledClassName,
(isDisabled || props.readOnly) && isDisabledClassName,
className
)}
onClick={(e) => {
if (isDisabled) return;
//returning false is not setting the state to false, it stops the event from bubbling up
if (isDisabled || props.readOnly === true) return false;
setIsChecked((c) => !c);
}}
>
@@ -127,12 +129,15 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
value={value}
checked={isChecked}
onChange={(e) => {
//returning false is not setting the state to false, it stops the event from bubbling up
if (isDisabled || props.readOnly === true) return false;
setIsChecked(!isChecked);
}}
disabled={isDisabled}
className={cn(
inputPositionClasses,
"cursor-pointer rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
"rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 read-only:border-slate-650 read-only:!bg-slate-700 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
)}
id={id}
ref={ref}
@@ -141,7 +146,10 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
<div className="flex items-center gap-x-2">
<label
htmlFor={id}
className={cn("cursor-pointer", labelClassName)}
className={cn(
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
labelClassName
)}
onClick={(e) => e.preventDefault()}
>
{label}
@@ -129,10 +129,10 @@ function PageInfoPropertyContent({
{label && (
<Paragraph variant="extra-small/caps" className="mt-0.5 whitespace-nowrap">
{label}
{value && ":"}
{value !== undefined && ":"}
</Paragraph>
)}
{value && <Paragraph variant="small">{value}</Paragraph>}
{value !== undefined && <Paragraph variant="small">{value}</Paragraph>}
</div>
);
}
@@ -117,6 +117,7 @@ export function RunOverview({ run, trigger, showRerun, paths, currentUser }: Run
{showRerun && run.isFinished && (
<RerunPopover
runId={run.id}
runPath={paths.run}
runsPath={paths.runsPath}
environmentType={run.environment.type}
status={run.basicStatus}
@@ -317,18 +318,20 @@ function BlankTasks({ status }: { status: RunBasicStatus }) {
function RerunPopover({
runId,
runPath,
runsPath,
environmentType,
status,
}: {
runId: string;
runPath: string;
runsPath: string;
environmentType: RuntimeEnvironmentType;
status: RunBasicStatus;
}) {
const lastSubmission = useActionData();
const [form, { successRedirect }] = useForm({
const [form, { successRedirect, failureRedirect }] = useForm({
id: "rerun",
// TODO: type this
lastSubmission: lastSubmission as any,
@@ -347,6 +350,7 @@ function RerunPopover({
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
<input {...conform.input(failureRedirect, { type: "hidden" })} defaultValue={runPath} />
{environmentType === "PRODUCTION" && (
<div className="px-4 pt-4">
<Callout variant="warning">
@@ -77,11 +77,11 @@ export function RunsTable({
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No runs found" />
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No runs match your filters" />
{!isLoading && <NoRuns title="No runs match your filters" />}
</TableBlankRow>
) : (
runs.map((run) => {
@@ -21,7 +21,6 @@ const mockOrganization: MatchedOrganization = {
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
],
hasUnconfiguredIntegrations: false,
memberCount: 1,
runsEnabled: true,
};
+9 -3
View File
@@ -110,13 +110,19 @@ function getClient() {
// emit: "stdout",
// level: "query",
// },
// {
// emit: "event",
// level: "query",
// },
],
});
// client.$on("query", (e) => {
// console.log("Query: " + e.query);
// console.log("Params: " + e.params);
// console.log("Duration: " + e.duration + "ms");
// console.log(`Query tooks ${e.duration}ms`, {
// query: e.query,
// params: e.params,
// duration: e.duration,
// });
// });
// connect eagerly
+5 -6
View File
@@ -1,17 +1,16 @@
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
import { organizationMatchId } from "./useOrganizations";
export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam";
export type MatchedProject = UseDataFunctionReturn<typeof orgLoader>["project"];
export function useOptionalProject(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: projectMatchId,
const routeMatch = useTypedMatchesData<typeof orgLoader>({
id: organizationMatchId,
matches,
});
+3 -4
View File
@@ -1,9 +1,8 @@
import type { User } from "~/models/user.server";
import { useMatchesData } from "~/utils";
import { useChanged } from "./useChanged";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
import type { User } from "~/models/user.server";
import { loader } from "~/root";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
const routeMatch = useTypedMatchesData<typeof loader>({
+3
View File
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
return await prisma.orgMemberInvite.findMany({
where: {
email,
organization: {
deletedAt: null,
},
},
include: {
organization: true,
@@ -14,6 +14,11 @@ export async function findEnvironmentByApiKey(apiKey: string) {
},
});
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
return null;
}
return environment;
}
@@ -28,24 +33,10 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
},
});
return environment;
}
export async function getEnvironmentForOrganization(organizationSlug: string, slug: string) {
const organization = await prisma.organization.findUnique({
where: {
slug: organizationSlug,
},
include: {
environments: true,
},
});
if (!organization) {
return;
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
return null;
}
const environment = organization.environments.find((environment) => environment.slug === slug);
return environment;
}
@@ -38,7 +38,7 @@ export type ClientEndpoint =
state: "configured";
id: string;
slug: string;
url: string;
url: string | null;
indexWebhookPath: string;
latestIndex?: {
status: EndpointIndexStatus;
@@ -102,6 +102,11 @@ export class EnvironmentsPresenter {
},
},
},
where: {
url: {
not: null,
},
},
},
},
where: {
@@ -40,27 +40,25 @@ export class EventListPresenter {
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
// Find all runtimeEnvironments that the user has access to
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
where: {
projectId: project.id,
},
});
const events = await this.#prismaClient.eventRecord.findMany({
select: {
id: true,
@@ -100,9 +98,6 @@ export class EventListPresenter {
},
projectId: project.id,
organizationId: organization.id,
environmentId: {
in: environments.map((environment) => environment.id),
},
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
createdAt: {
gte: from ? new Date(from).toISOString() : undefined,
@@ -9,6 +9,7 @@ import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { z } from "zod";
import { projectPath } from "~/utils/pathBuilder";
import { JobRunStatus } from "@trigger.dev/database";
export type ProjectJob = Awaited<ReturnType<JobListPresenter["call"]>>[0];
@@ -43,52 +44,34 @@ export class JobListPresenter {
id: true,
slug: true,
title: true,
aliases: {
integrations: {
select: {
version: {
key: true,
integration: {
select: {
version: true,
eventSpecification: true,
properties: true,
status: true,
runs: {
select: {
createdAt: true,
status: true,
},
take: 1,
orderBy: [{ createdAt: "desc" }],
},
integrations: {
select: {
key: true,
integration: {
select: {
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
triggerLink: true,
triggerHelp: true,
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
versions: {
select: {
version: true,
eventSpecification: true,
properties: true,
status: true,
triggerLink: true,
triggerHelp: true,
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
orderBy: [{ updatedAt: "desc" }],
take: 1,
},
dynamicTriggers: {
select: {
@@ -115,50 +98,47 @@ export class JobListPresenter {
orderBy: [{ title: "asc" }],
});
let latestRuns = [] as {
createdAt: Date;
status: JobRunStatus;
jobId: string;
rn: BigInt;
}[];
if (jobs.length > 0) {
latestRuns = await this.#prismaClient.$queryRaw<
{
createdAt: Date;
status: JobRunStatus;
jobId: string;
rn: BigInt;
}[]
>`
SELECT * FROM (
SELECT
"id",
"createdAt",
"status",
"jobId",
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
FROM
"JobRun"
WHERE
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
) t
WHERE rn = 1;`;
}
return jobs
.map((job) => {
//the best alias to select:
// 1. Logged-in user dev
// 2. Prod
// 3. Any other user's dev
const sortedAliases = job.aliases.sort((a, b) => {
if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) {
return -1;
}
if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) {
return 1;
}
if (a.environment.type === "PRODUCTION") {
return -1;
}
if (b.environment.type === "PRODUCTION") {
return 1;
}
return 0;
});
const alias = sortedAliases.at(0);
if (!alias) {
throw new Error(`No aliases found for job ${job.id}, this should never happen.`);
.flatMap((job) => {
const version = job.versions.at(0);
if (!version) {
return [];
}
const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification);
const eventSpecification = EventSpecificationSchema.parse(version.eventSpecification);
const lastRuns = job.aliases
.map((alias) => alias.version.runs.at(0))
.filter(Boolean)
.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime();
});
const lastRun = lastRuns.at(0);
const integrations = alias.version.integrations.map((integration) => ({
const integrations = job.integrations.map((integration) => ({
key: integration.key,
title: integration.integration.slug,
icon: integration.integration.definition.icon ?? integration.integration.definition.id,
@@ -171,44 +151,41 @@ export class JobListPresenter {
properties = [...properties, ...eventSpecification.properties];
}
if (alias.version.properties) {
const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties);
if (version.properties) {
const versionProperties = z.array(DisplayPropertySchema).parse(version.properties);
properties = [...properties, ...versionProperties];
}
const environments = job.aliases.map((alias) => ({
type: alias.environment.type,
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
}));
const latestRun = latestRuns.find((r) => r.jobId === job.id);
return {
id: job.id,
slug: job.slug,
title: job.title,
version: alias.version.version,
status: alias.version.status,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
icon: eventSpecification.icon,
source: eventSpecification.source,
link: projectSlug
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
alias.version.triggerLink
}`
: undefined,
return [
{
id: job.id,
slug: job.slug,
title: job.title,
version: version.version,
status: version.status,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
icon: eventSpecification.icon,
source: eventSpecification.source,
link: projectSlug
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
version.triggerLink
}`
: undefined,
},
integrations,
hasIntegrationsRequiringAction: integrations.some(
(i) => i.setupStatus === "MISSING_FIELDS"
),
environment: version.environment,
lastRun: latestRun,
properties,
projectSlug: job.project.slug,
},
integrations,
hasIntegrationsRequiringAction: integrations.some(
(i) => i.setupStatus === "MISSING_FIELDS"
),
lastRun,
properties,
environments,
projectSlug: job.project.slug,
};
];
})
.filter(Boolean);
}
@@ -10,11 +10,16 @@ export class NewOrganizationPresenter {
public async call({ userId }: { userId: User["id"] }) {
const organizations = await this.#prismaClient.organization.findMany({
select: {
projects: {
where: { deletedAt: null },
},
},
where: { members: { some: { userId } } },
});
return {
hasOrganizations: organizations.length > 0,
hasOrganizations: organizations.filter((o) => o.projects.length > 0).length > 0,
};
}
}
@@ -1,9 +1,7 @@
import { estimate } from "@trigger.dev/billing";
import { formatDateTime } from "~/components/primitives/DateTime";
import { PrismaClient, prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
export class OrgUsagePresenter {
#prismaClient: PrismaClient;
@@ -25,7 +23,7 @@ export class OrgUsagePresenter {
});
if (!organization) {
return;
throw new Error("Organization not found");
}
// Get count of runs since the start of the current month
@@ -108,7 +106,7 @@ export class OrgUsagePresenter {
const ThirtyDaysAgo = new Date();
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
ThirtyDaysAgo.setHours(0, 0, 0, 0);
ThirtyDaysAgo.setUTCHours(0, 0, 0, 0);
const hasConcurrencyData = concurrencyChartRawData.length > 0;
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
@@ -117,6 +115,13 @@ export class OrgUsagePresenter {
concurrencyChartRawData
);
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
{ day: Date; runs: BigInt }[]
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
const hasDailyRunsData = dailyRunsRawData.length > 0;
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
endOfMonth.setDate(endOfMonth.getDate() - 1);
const projectedRunsCount = Math.round(
@@ -146,12 +151,12 @@ export class OrgUsagePresenter {
const periodStart = new Date();
periodStart.setDate(1);
periodStart.setHours(0, 0, 0, 0);
periodStart.setUTCHours(0, 0, 0, 0);
const periodEnd = new Date();
periodEnd.setDate(1);
periodEnd.setMonth(periodEnd.getMonth() + 1);
periodEnd.setHours(0, 0, 0, 0);
periodEnd.setUTCHours(0, 0, 0, 0);
return {
id: organization.id,
@@ -161,6 +166,8 @@ export class OrgUsagePresenter {
hasMonthlyRunData,
concurrencyData: concurrencyChartRawDataFilledIn,
hasConcurrencyData,
dailyRunsData: dailyRunsDataFilledIn,
hasDailyRunsData,
runCostEstimation,
projectedRunCostEstimation,
periodStart,
@@ -224,6 +231,33 @@ function fillInMissingConcurrencyDays(
return outputData;
}
function fillInMissingDailyRuns(
startDate: Date,
days: number,
data: Array<{ day: Date; runs: BigInt }>
) {
const outputData: Array<{ date: Date; runs: number }> = [];
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
if (!foundData) {
outputData.push({
date,
runs: 0,
});
} else {
outputData.push({
date,
runs: Number(foundData.runs),
});
}
}
return outputData;
}
// Start month will be like 2023-03 and endMonth will be like 2023-10
// The result should be an array of months between these two months, including the start and end month
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
@@ -1,10 +1,17 @@
import { PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { ProjectPresenter } from "./ProjectPresenter.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
getCurrentProjectId,
setCurrentProjectId,
} from "~/services/currentProject.server";
import { logger } from "~/services/logger.server";
type Org = Awaited<ReturnType<OrganizationsPresenter["getOrganizations"]>>[number];
import { newProjectPath } from "~/utils/pathBuilder";
import { ProjectPresenter } from "./ProjectPresenter.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { match } from "assert";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -16,40 +23,167 @@ export class OrganizationsPresenter {
public async call({
userId,
organizationSlug,
request,
projectSlug,
request,
}: {
userId: string;
organizationSlug: string;
projectSlug: string | undefined;
request: Request;
projectSlug?: string;
}) {
const organizations = await this.getOrganizations(userId);
//first get the project id, this redirects if there's no session
const projectId = await this.#getProjectId({
request,
projectSlug,
organizationSlug,
userId,
});
const organizations = await this.#getOrganizations(userId);
const organization = organizations.find((o) => o.slug === organizationSlug);
if (!organization) {
logger.info("Not Found: organization", {
organizationSlug,
projectSlug,
request,
organization,
});
throw new Response("Not Found", { status: 404 });
}
const project = await this.getProject(organization, projectSlug, request, userId);
const projectPresenter = new ProjectPresenter(this.#prismaClient);
const project = await projectPresenter.call({
id: projectId,
userId,
});
if (!project) {
throw redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
request,
"No projects found in organization"
);
}
return { organizations, organization, project };
}
async getOrganizations(userId: string) {
async #getProjectId({
request,
projectSlug,
organizationSlug,
userId,
}: {
request: Request;
projectSlug: string | undefined;
organizationSlug: string;
userId: string;
}): Promise<string> {
const sessionProjectId = await getCurrentProjectId(request);
//no project in session, let's set one
if (!sessionProjectId) {
//no session id and no project slug so we need to select the best project
if (!projectSlug) {
const bestProject = await this.#selectBestProjectForOrganization(
organizationSlug,
userId,
request
);
const session = await setCurrentProjectId(bestProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
//get all the projects
const projects = await prisma.project.findMany({
select: {
id: true,
slug: true,
},
where: {
organization: {
slug: organizationSlug,
},
deletedAt: null,
slug: projectSlug,
},
orderBy: {
updatedAt: "desc",
},
});
if (projects.length === 0) {
throw redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
request,
"No projects in this organization"
);
}
//try get the project which matches the URL
let matchingProject = projects.find((p) => p.slug === projectSlug);
//if there's no matching project, just use the most recently updated one
if (!matchingProject) {
matchingProject = projects[0];
}
//set the session
const session = await setCurrentProjectId(matchingProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
if (!projectSlug) {
return sessionProjectId;
}
//check session id matches the project slug
const project = await prisma.project.findFirst({
select: {
id: true,
slug: true,
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
},
deletedAt: null,
},
});
if (!project) {
throw new Response("Project not found in organization", { status: 404 });
}
if (project.id !== sessionProjectId) {
const session = await setCurrentProjectId(project.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
return project.id;
}
async #getOrganizations(userId: string) {
const orgs = await this.#prismaClient.organization.findMany({
where: { members: { some: { userId } } },
where: { members: { some: { userId } }, deletedAt: null },
orderBy: { createdAt: "desc" },
include: {
select: {
id: true,
slug: true,
title: true,
runsEnabled: true,
projects: {
orderBy: { name: "asc" },
include: {
where: { deletedAt: null },
select: {
id: true,
slug: true,
name: true,
_count: {
select: {
jobs: {
@@ -67,10 +201,10 @@ export class OrganizationsPresenter {
},
},
},
orderBy: { name: "asc" },
},
_count: {
select: {
members: true,
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
@@ -93,39 +227,41 @@ export class OrganizationsPresenter {
jobCount: project._count.jobs,
})),
hasUnconfiguredIntegrations: org._count.integrations > 0,
memberCount: org._count.members,
runsEnabled: org.runsEnabled,
};
});
}
async getProject(
organization: Org,
projectSlug: string | undefined,
request: Request,
userId: string
async #selectBestProjectForOrganization(
organizationSlug: string,
userId: string,
request: Request
) {
const projectPresenter = new ProjectPresenter();
const projects = await this.#prismaClient.project.findMany({
select: {
id: true,
slug: true,
},
where: {
deletedAt: null,
organization: {
deletedAt: null,
slug: organizationSlug,
members: { some: { userId } },
},
},
orderBy: {
jobs: {
_count: "desc",
},
},
take: 1,
});
if (!projectSlug) {
const projectId = await getCurrentProjectId(request);
const orgProject = organization.projects.find((p) => p.id === projectId);
if (!orgProject) {
logger.info("Not Found: proj 1", {
projectId,
organization,
projectSlug: projectSlug ?? null,
});
throw new Response("Not Found", { status: 404 });
}
projectSlug = orgProject.slug;
if (projects.length === 0) {
throw redirect(newProjectPath({ slug: organizationSlug }), request);
}
const project = await projectPresenter.call({ userId, slug: projectSlug });
if (!project) {
logger.info("Not Found: proj 2", { projectSlug, organization, project });
throw new Response("Not Found", { status: 404 });
}
return project;
return projects[0];
}
}
@@ -11,8 +11,8 @@ export class ProjectPresenter {
public async call({
userId,
slug,
}: Pick<Project, "slug"> & {
id,
}: Pick<Project, "id"> & {
userId: User["id"];
}) {
const project = await this.#prismaClient.project.findFirst({
@@ -23,67 +23,7 @@ export class ProjectPresenter {
organizationId: true,
createdAt: true,
updatedAt: true,
jobs: {
select: {
id: true,
slug: true,
title: true,
aliases: {
select: {
version: {
select: {
version: true,
eventSpecification: true,
properties: true,
runs: {
select: {
createdAt: true,
status: true,
},
take: 1,
orderBy: [{ createdAt: "desc" }],
},
integrations: {
select: {
key: true,
integration: {
select: {
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
},
},
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
},
dynamicTriggers: {
select: {
type: true,
},
},
},
where: {
internal: false,
deletedAt: null,
},
orderBy: [{ title: "asc" }],
},
deletedAt: true,
_count: {
select: {
sources: {
@@ -100,19 +40,6 @@ export class ProjectPresenter {
httpEndpoints: true,
},
},
organization: {
select: {
_count: {
select: {
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
},
},
},
},
},
},
environments: {
select: {
id: true,
@@ -127,7 +54,7 @@ export class ProjectPresenter {
},
},
},
where: { slug, organization: { members: { some: { userId } } } },
where: { id, deletedAt: null, organization: { members: { some: { userId } } } },
});
if (!project) {
@@ -141,6 +68,7 @@ export class ProjectPresenter {
organizationId: project.organizationId,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
deletedAt: project.deletedAt,
hasInactiveExternalTriggers: project._count.sources > 0,
jobCount: project._count.jobs,
httpEndpointCount: project._count.httpEndpoints,
@@ -54,6 +54,9 @@ export class RunListPresenter {
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
@@ -62,19 +65,15 @@ export class RunListPresenter {
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
// Find all runtimeEnvironments that the user has access to
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
where: {
projectId: project.id,
},
});
const job = jobSlug
? await this.#prismaClient.job.findFirstOrThrow({
where: {
@@ -132,9 +131,6 @@ export class RunListPresenter {
jobId: job?.id,
projectId: project.id,
organizationId: organization.id,
environmentId: {
in: environments.map((environment) => environment.id),
},
status: filterStatuses ? { in: filterStatuses } : undefined,
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
startedAt: {
@@ -14,7 +14,7 @@ export class SelectBestProjectPresenter {
const projectId = await getCurrentProjectId(request);
if (projectId) {
const project = await this.#prismaClient.project.findUnique({
where: { id: projectId, organization: { members: { some: { userId } } } },
where: { id: projectId, deletedAt: null, organization: { members: { some: { userId } } } },
include: { organization: true },
});
if (project) {
@@ -28,6 +28,7 @@ export class SelectBestProjectPresenter {
organization: true,
},
where: {
deletedAt: null,
organization: {
members: { some: { userId } },
},
+1 -1
View File
@@ -79,7 +79,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
return false;
}
return true;
return options.defaultShouldRevalidate;
};
export function ErrorBoundary() {
@@ -1,35 +1,32 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Await, useLoaderData } from "@remix-run/react";
import { DataFunctionArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ConcurrentRunsChart } from "~/components/billing/ConcurrentRunsChart";
import { UsageBar } from "~/components/billing/UsageBar";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DailyRunsChart } from "~/components/billing/DailyRunsChat";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { useOrganization } from "~/hooks/useOrganizations";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
import { OrganizationParamsSchema, plansPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
export async function loader({ params, request }: LoaderFunctionArgs) {
export async function loader({ request, params }: DataFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new OrgUsagePresenter();
const data = await presenter.call({ userId, slug: organizationSlug, request });
if (!data) {
throw new Response(null, { status: 404 });
}
return typedjson(data);
const usageData = presenter.call({ userId, slug: organizationSlug, request });
return defer({ usageData });
}
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
@@ -47,146 +44,194 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
const { usageData } = useLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
? loaderData.concurrencyData.some(
(c) => c.maxConcurrentRuns >= (currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
)
: false;
const hitsRunLimit = currentPlan?.usage?.runCountCap
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
: false;
return (
<div className="flex flex-col gap-4">
<div>
<Header2 spacing>Concurrent runs</Header2>
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
{hitConcurrencyLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Increase concurrent runs
</LinkButton>
}
>
{`Some of your runs are being queued because the number of concurrent runs is limited to
${currentPlan?.subscription?.limits.concurrentRuns}.`}
</Callout>
)}
<ConcurrentRunsChart
data={loaderData.concurrencyData}
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
hasConcurrencyData={loaderData.hasConcurrencyData}
/>
</div>
</div>
<Suspense
fallback={
<>
<LoadingElement title="Concurrent runs" />
<LoadingElement title="Runs" />
</>
}
>
<Await
resolve={usageData}
errorElement={<Paragraph>There was a problem loading your usage data.</Paragraph>}
>
{(data) => {
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
? data.concurrencyData.some(
(c) =>
c.maxConcurrentRuns >=
(currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
)
: false;
<div className="@container">
<Header2 spacing>Runs</Header2>
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"error"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small" className="text-white">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
Upgrade to a paid plan before{" "}
<DateTime date={loaderData.periodEnd} includeSeconds={false} includeTime={false} />.
</Paragraph>
</Callout>
)}
<div className="flex flex-col gap-x-8 @4xl:flex-row">
<div className="flex w-full flex-col gap-4">
{loaderData.runCostEstimation !== undefined &&
loaderData.projectedRunCostEstimation !== undefined && (
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">Month-to-date</Header3>
<p className="text-3xl font-medium text-bright">
{formatCurrency(loaderData.runCostEstimation, false)}
</p>
return (
<>
<div>
<Header2 spacing>Concurrent runs</Header2>
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
{hitConcurrencyLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Increase concurrent runs
</LinkButton>
}
>
{`Some of your runs are being queued because the number of concurrent runs is limited to
${currentPlan?.subscription?.limits.concurrentRuns}.`}
</Callout>
)}
<ConcurrentRunsChart
data={data.concurrencyData}
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
hasConcurrencyData={data.hasConcurrencyData}
/>
</div>
</div>
<div className="@container">
<Header2 spacing>Runs</Header2>
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"error"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small" className="text-white">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs
limit. Upgrade to a paid plan before{" "}
<DateTime
date={data.periodEnd}
includeSeconds={false}
includeTime={false}
/>
.
</Paragraph>
</Callout>
)}
<div className="flex flex-col gap-x-8 @4xl:flex-row">
<div className="flex w-full flex-col gap-4">
{data.runCostEstimation !== undefined &&
data.projectedRunCostEstimation !== undefined && (
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">Month-to-date</Header3>
<p className="text-3xl font-medium text-bright">
{formatCurrency(data.runCostEstimation, false)}
</p>
</div>
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
<div className="flex flex-col gap-2 text-dimmed">
<Header3 className="text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(data.projectedRunCostEstimation, false)}
</p>
</div>
</div>
)}
<UsageBar
numberOfCurrentRuns={data.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
projectedRuns={data.projectedRunsCount}
subscribedToPaidTier={
(currentPlan && currentPlan.subscription?.isPaying) ?? false
}
/>
</div>
<div className="relative w-full">
<Header3 className="mb-4">Monthly runs</Header3>
{!data.hasMonthlyRunData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={data.monthlyRunsData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="-ml-7"
>
<XAxis
dataKey="name"
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
content={<CustomTooltip />}
/>
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
<div className="flex flex-col gap-2 text-dimmed">
<Header3 className="text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(loaderData.projectedRunCostEstimation, false)}
</p>
<div>
<Header3 className="mb-4">Daily runs</Header3>
<DailyRunsChart
data={data.dailyRunsData}
hasDailyRunsData={data.hasDailyRunsData}
/>
</div>
</div>
)}
<UsageBar
numberOfCurrentRuns={loaderData.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
projectedRuns={loaderData.projectedRunsCount}
/>
</div>
<div className="relative w-full">
<Header3 className="mb-4">Monthly runs</Header3>
{!loaderData.hasMonthlyRunData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={loaderData.monthlyRunsData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="-ml-7"
>
<XAxis
dataKey="name"
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
content={<CustomTooltip />}
/>
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</>
);
}}
</Await>
</Suspense>
</div>
);
}
function LoadingElement({ title }: { title: string }) {
return (
<div>
<Header2 spacing>{title}</Header2>
<div className="flex h-96 w-full items-center justify-center gap-5 rounded border border-border p-6">
<Spinner />
</div>
</div>
);
@@ -38,31 +38,23 @@ import {
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Project } from "~/models/project.server";
import {
Client,
IntegrationOrApi,
IntegrationsPresenter,
} from "~/presenters/IntegrationsPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
OrganizationParamsSchema,
ProjectParamSchema,
docsCreateIntegration,
docsPath,
integrationClientPath,
} from "~/utils/pathBuilder";
import { OrganizationParamsSchema, docsPath, integrationClientPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new IntegrationsPresenter();
const data = await presenter.call({
userId: user.id,
userId,
organizationSlug,
});
@@ -18,7 +18,7 @@ import {
import { useOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { IntegrationClientPresenter } from "~/presenters/IntegrationClientPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
IntegrationClientParamSchema,
@@ -29,12 +29,12 @@ import {
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
const presenter = new IntegrationClientPresenter();
const client = await presenter.call({
userId: user.id,
userId,
organizationSlug,
clientSlug: clientParam,
});
@@ -47,6 +47,9 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
const refreshEndpointFetcher = useFetcher();
const refreshingEndpoint = refreshEndpointFetcher.state !== "idle";
const deleteEndpointFetcher = useFetcher();
const deletingEndpoint = deleteEndpointFetcher.state !== "idle";
const revalidator = useRevalidator();
const events = useEventSource(endpointStreamingPath({ id: endpoint.environment.id }), {
event: "message",
@@ -70,12 +73,30 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
>
<SheetContent size="lg">
<SheetHeader>
<Header1>
<div className="flex items-center gap-2">
<EnvironmentLabel environment={{ type: endpoint.environment.type }} />
<Header1>Configure endpoint</Header1>
</div>
</Header1>
<div className="flex w-full items-center justify-between">
<Header1>
<div className="flex items-center gap-2">
<EnvironmentLabel environment={{ type: endpoint.environment.type }} />
<Header1>Configure endpoint</Header1>
</div>
</Header1>
{endpoint.state === "configured" && (
<deleteEndpointFetcher.Form
method="post"
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
>
<input type="hidden" name="action" value="delete" />
<Button
variant="danger/small"
type="submit"
disabled={deletingEndpoint}
LeadingIcon={deletingEndpoint ? "spinner-white" : undefined}
>
{deletingEndpoint ? "Deleting" : "Delete"}
</Button>
</deleteEndpointFetcher.Form>
)}
</div>
</SheetHeader>
<SheetBody>
<setEndpointUrlFetcher.Form
@@ -90,7 +111,7 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
<Input
className="rounded-r-none"
{...conform.input(url, { type: "url" })}
defaultValue={"url" in endpoint ? endpoint.url : ""}
defaultValue={"url" in endpoint ? endpoint.url ?? "" : ""}
placeholder="URL for your Trigger API route"
/>
<Button
@@ -123,6 +144,7 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
method="post"
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
>
<input type="hidden" name="action" value="refresh" />
<Callout
variant="info"
icon={
@@ -5,8 +5,8 @@ import { cn } from "~/utils/cn";
type List = {
pagination: {
next: string | undefined;
previous: string | undefined;
next?: string | undefined;
previous?: string | undefined;
};
};
@@ -1,12 +1,9 @@
import { Outlet, useLocation } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson } from "remix-typedjson";
import { JobStatusBadge } from "~/components/jobs/JobStatusBadge";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
@@ -22,11 +19,9 @@ import {
import { Paragraph } from "~/components/primitives/Paragraph";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { projectMatchId, useProject } from "~/hooks/useProject";
import { useProject } from "~/hooks/useProject";
import { useOptionalRun } from "~/hooks/useRun";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { findJobByParams } from "~/models/job.server";
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
import { JobPresenter } from "~/presenters/JobPresenter.server";
import { requireUserId } from "~/services/session.server";
import { titleCase } from "~/utils";
@@ -36,7 +31,6 @@ import {
jobPath,
jobSettingsPath,
jobTestPath,
jobTriggerPath,
trimTrailingSlash,
} from "~/utils/pathBuilder";
@@ -1,5 +1,5 @@
import { useLocation, useNavigate, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Await, useLoaderData, useLocation, useNavigate, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -20,6 +20,8 @@ import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { RunsFilters } from "~/components/runs/RunFilters";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -31,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new RunListPresenter();
const list = await presenter.call({
const list = presenter.call({
userId,
filterEnvironment: searchParams.environment,
filterStatus: searchParams.status,
@@ -44,13 +46,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to: searchParams.to,
});
return typedjson({
return defer({
list,
});
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const { list } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
@@ -79,18 +81,49 @@ export default function Page() {
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-center justify-between gap-x-2">
<RunsFilters />
<ListPagination list={list} />
<Suspense fallback={<></>}>
<Await resolve={list}>{(data) => <ListPagination list={data} />}</Await>
</Suspense>
</div>
<RunsTable
total={list.runs.length}
hasFilters={false}
showJob={true}
runs={list.runs}
isLoading={isLoading}
runsParentPath={projectPath(organization, project)}
currentUser={user}
/>
<ListPagination list={list} className="mt-2 justify-end" />
<Suspense
fallback={
<RunsTable
total={0}
hasFilters={false}
showJob={true}
runs={[]}
isLoading={true}
runsParentPath={projectPath(organization, project)}
currentUser={user}
/>
}
>
<Await resolve={list}>
{(data) => {
const runs = data.runs.map((run) => ({
...run,
startedAt: run.startedAt ? new Date(run.startedAt) : null,
completedAt: run.completedAt ? new Date(run.completedAt) : null,
createdAt: new Date(run.createdAt),
}));
return (
<>
<RunsTable
total={data.runs.length}
hasFilters={false}
showJob={true}
runs={runs}
isLoading={isLoading}
runsParentPath={projectPath(organization, project)}
currentUser={user}
/>
<ListPagination list={data} className="mt-2 justify-end" />
</>
);
}}
</Await>
</Suspense>
</div>
</PageBody>
</PageContainer>
@@ -0,0 +1,264 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
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 { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
} from "~/services/currentProject.server";
import { DeleteProjectService } from "~/services/deleteProject.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
export 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: conform.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 is required" } }, { status: 400 });
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
},
});
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
switch (submission.value.action) {
case "rename": {
await prisma.project.update({
where: {
slug: projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
data: {
name: submission.value.projectName,
},
});
return redirectWithSuccessMessage(
projectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
const deleteProjectService = new DeleteProjectService();
try {
await deleteProjectService.call({ projectSlug: projectParam, userId });
//we need to clear the project from the session
const removeProjectIdSession = await clearCurrentProjectId(request);
return redirect(organizationPath({ slug: organizationSlug }), {
headers: { "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession) },
});
} catch (error: unknown) {
logger.error("Project could not be deleted", {
error: error instanceof Error ? error.message : JSON.stringify(error),
});
return redirectWithErrorMessage(
organizationPath({ slug: organizationSlug }),
request,
`Project ${projectParam} could not be deleted`
);
}
}
}
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function Page() {
const project = useProject();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [renameForm, { projectName }] = useForm({
id: "rename-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema(),
});
},
});
const [deleteForm, { projectSlug }] = useForm({
id: "delete-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.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>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${project.name} project settings`} />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-4">
<div>
<Form method="post" {...renameForm.props} className="max-w-md">
<input type="hidden" name="action" value="rename" />
<Fieldset>
<InputGroup>
<Label htmlFor={projectName.id}>Rename your project</Label>
<Input
{...conform.input(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Your project name"
icon="folder"
autoFocus
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"primary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
>
Rename project
</Button>
}
/>
</Fieldset>
</Form>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...deleteForm.props}
className="max-w-md rounded-sm border border-rose-500/40"
>
<input type="hidden" name="action" value="delete" />
<Fieldset className="p-4">
<InputGroup>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon="warning"
autoFocus
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</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"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete project
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -19,17 +19,17 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { TriggersPresenter } from "~/presenters/TriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, externalTriggerPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const presenter = new TriggersPresenter();
const data = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
});
@@ -21,17 +21,17 @@ import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { ScheduledTriggersPresenter } from "~/presenters/ScheduledTriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { ProjectParamSchema, docsPath, trimTrailingSlash } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const presenter = new ScheduledTriggersPresenter();
const data = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
});
@@ -31,7 +31,7 @@ import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TriggerSourcePresenter } from "~/presenters/TriggerSourcePresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { ActivateSourceService } from "~/services/sources/activateSource.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
@@ -45,7 +45,7 @@ import {
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -54,7 +54,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new TriggerSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
triggerSourceId: triggerParam,
@@ -16,7 +16,7 @@ import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
@@ -29,7 +29,7 @@ import {
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -38,7 +38,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new WebhookSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
@@ -11,7 +11,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
@@ -24,7 +24,7 @@ import {
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -33,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new WebhookDeliveryPresenter();
const { webhook } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
@@ -15,7 +15,7 @@ import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import {
TriggerSourceParamSchema,
projectWebhookTriggersPath,
@@ -24,7 +24,7 @@ import {
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -33,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new WebhookSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
@@ -1,67 +1,17 @@
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import invariant from "tiny-invariant";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { useOrganization } from "~/hooks/useOrganizations";
import { organizationMatchId, useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { ProjectPresenter } from "~/presenters/ProjectPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { Handle } from "~/utils/handle";
import { projectPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = params;
invariant(projectParam, "projectParam not found");
try {
const presenter = new ProjectPresenter();
const project = await presenter.call({
userId,
slug: projectParam,
});
if (!project) {
throw new Response("Not Found", {
status: 404,
statusText: `Project ${projectParam} not found in your Organization.`,
});
}
telemetry.project.identify({ project });
const session = await setCurrentProjectId(project.id, request);
return typedjson(
{
project,
},
{
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
}
);
} catch (error) {
if (error instanceof Response) {
throw error;
}
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
import { loader as orgLoader } from "../_app.orgs.$organizationSlug/route";
export const handle: Handle = {
breadcrumb: (match) => {
const data = useTypedMatchData<typeof loader>(match);
breadcrumb: (match, matches) => {
const orgMatch = matches.find((m) => m.id === organizationMatchId);
const data = useTypedMatchData<typeof orgLoader>(orgMatch);
return <BreadcrumbLink to={match.pathname} title={data?.project.name ?? "Project"} />;
},
scripts: (match) => [
@@ -0,0 +1,270 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { r } from "tar";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
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 { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
} from "~/services/currentProject.server";
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
export function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
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: conform.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 userId = await requireUserId(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 = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
switch (submission.value.action) {
case "rename": {
await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
data: {
title: submission.value.organizationName,
},
});
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
`Organization renamed to ${submission.value.organizationName}`
);
}
case "delete": {
const deleteOrganizationService = new DeleteOrganizationService();
try {
await deleteOrganizationService.call({ organizationSlug, userId, request });
//we need to clear the project from the session
const removeProjectIdSession = await clearCurrentProjectId(request);
return redirect(rootPath(), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
},
});
} 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
);
}
}
}
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function Page() {
const organization = useOrganization();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [renameForm, { organizationName }] = useForm({
id: "rename-organization",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema(),
});
},
});
const [deleteForm, { organizationSlug }] = useForm({
id: "delete-organization",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(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>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${organization.title} organization settings`} />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-4">
<div>
<Form method="post" {...renameForm.props} className="max-w-md">
<input type="hidden" name="action" value="rename" />
<Fieldset>
<InputGroup>
<Label htmlFor={organizationName.id}>Rename your organization</Label>
<Input
{...conform.input(organizationName, { type: "text" })}
defaultValue={organization.title}
placeholder="Your organization name"
icon="folder"
autoFocus
/>
<FormError id={organizationName.errorId}>{organizationName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"primary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
>
Rename organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...deleteForm.props}
className="max-w-md 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
{...conform.input(organizationSlug, { type: "text" })}
placeholder="Your organization slug"
icon="warning"
autoFocus
/>
<FormError id={organizationSlug.errorId}>{organizationSlug.error}</FormError>
<FormError>{deleteForm.error}</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 ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -1,4 +1,4 @@
import { Outlet, UIMatch } from "@remix-run/react";
import { Outlet, ShouldRevalidateFunction, UIMatch } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -9,11 +9,10 @@ import { PageNavigationIndicator } from "~/components/navigation/PageNavigationI
import { SideMenu } from "~/components/navigation/SideMenu";
import { featuresForRequest } from "~/features.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { BillingService } from "~/services/billing.server";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { BillingService } from "~/services/billing.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
@@ -48,6 +47,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
telemetry.organization.identify({ organization });
telemetry.project.identify({ project });
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
@@ -56,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
organizations,
organization,
currentProject: project,
project,
isImpersonating: !!impersonationId,
currentPlan,
});
@@ -72,13 +72,10 @@ export const handle: Handle = {
};
export default function Organization() {
const { organization, currentProject, organizations, isImpersonating } =
const { organization, project, organizations, isImpersonating } =
useTypedLoaderData<typeof loader>();
const user = useUser();
//the side menu won't change projects when using the switcher unless we use the hook (on project pages)
const project = useOptionalProject() ?? currentProject;
return (
<>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
@@ -111,3 +108,23 @@ export function ErrorBoundary() {
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
);
}
export const shouldRevalidate: ShouldRevalidateFunction = ({
defaultShouldRevalidate,
currentParams,
nextParams,
}) => {
const current = ParamsSchema.safeParse(currentParams);
const next = ParamsSchema.safeParse(nextParams);
if (current.success && next.success) {
if (current.data.organizationSlug !== next.data.organizationSlug) {
return true;
}
if (current.data.projectParam !== next.data.projectParam) {
return true;
}
}
return defaultShouldRevalidate;
};
@@ -1,12 +1,14 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import type { ActionFunction } from "@remix-run/node";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
@@ -14,11 +16,44 @@ import { FormTitle } from "~/components/primitives/FormTitle";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { useOrganization } from "~/hooks/useOrganizations";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { createProject } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug, members: { some: { userId } } },
select: {
id: true,
title: true,
_count: {
select: {
projects: {
where: { deletedAt: null },
},
},
},
},
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
return typedjson({
organization: {
id: organization.id,
title: organization.title,
slug: organizationSlug,
projectsCount: organization._count.projects,
},
});
}
const schema = z.object({
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
@@ -54,7 +89,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function NewOrganizationPage() {
const organization = useOrganization();
const { organization } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const [form, { projectName }] = useForm({
@@ -71,10 +106,15 @@ export default function NewOrganizationPage() {
<div>
<FormTitle
LeadingIcon="folder"
title="Create a new Project"
description="Create a new Project to help you organize the Jobs you create."
title="Create a new project"
description={`This will create a new project in your "${organization.title}" organization. `}
/>
<Form method="post" {...form.props}>
{organization.projectsCount === 0 && (
<Callout variant="info" className="mb-4">
Organizations require at least one project, please create one to continue.
</Callout>
)}
<Fieldset>
<InputGroup>
<Label htmlFor={projectName.id}>Project name</Label>
@@ -93,9 +133,11 @@ export default function NewOrganizationPage() {
</Button>
}
cancelButton={
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
Cancel
</LinkButton>
organization.projectsCount > 0 ? (
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
Cancel
</LinkButton>
) : undefined
}
/>
</Fieldset>
@@ -41,10 +41,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}
const orgsPresenter = new OrganizationsPresenter();
const { organizations, organization, project } = await orgsPresenter.call({
const { project } = await orgsPresenter.call({
userId,
request,
organizationSlug,
projectSlug: undefined,
});
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
@@ -52,7 +53,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
export default function ChoosePlanPage() {
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
const project = useOptionalProject();
return (
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
@@ -63,7 +63,6 @@ export default function ChoosePlanPage() {
showActionText={false}
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
/>
<Sheet>
<SheetTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ChartBarIcon} leadingIconClassName="px-0">
@@ -26,6 +26,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
request,
organizationSlug,
projectSlug: undefined,
});
const { isManagedCloud } = featuresForRequest(request);
+12 -3
View File
@@ -3,7 +3,7 @@ import { parse } from "@conform-to/zod";
import { RadioGroup } from "@radix-ui/react-radio-group";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
@@ -23,7 +23,7 @@ import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { plansPath, projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
import { projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
@@ -86,6 +86,7 @@ export default function NewOrganizationPage() {
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
const [form, { orgName, projectName }] = useForm({
id: "create-organization",
@@ -95,8 +96,11 @@ export default function NewOrganizationPage() {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
shouldValidate: "onSubmit",
});
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
return (
<MainCenteredContainer className="max-w-[22rem]">
<FormTitle LeadingIcon="organization" title="Create an Organization" />
@@ -161,7 +165,12 @@ export default function NewOrganizationPage() {
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
<Button
type="submit"
variant={"primary/small"}
TrailingIcon="arrow-right"
disabled={isLoading}
>
Create
</Button>
}
@@ -171,7 +171,7 @@ export default function Page() {
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
label="Receive onboarding emails"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
+1 -4
View File
@@ -1,7 +1,6 @@
import { HomeIcon } from "@heroicons/react/24/outline";
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { redirect, typedjson } from "remix-typedjson";
import { getUser, requireUserId } from "~/services/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
@@ -19,8 +18,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Page() {
const data = useTypedLoaderData<typeof loader>();
return (
<div className="h-full w-full">
<Outlet />
@@ -0,0 +1,84 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { GetEvent } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
const ParamsSchema = z.object({
eventId: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: true,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return apiCors(request, json({ error: "Invalid or Missing eventId" }, { status: 400 }));
}
const { eventId } = parsed.data;
const event = await findEventRecord(eventId, authenticatedEnv.id);
if (!event) {
return apiCors(request, json({ error: "Event not found" }, { status: 404 }));
}
return apiCors(request, json(toJSON(event)));
}
function toJSON(eventRecord: FoundEventRecord): GetEvent {
return {
id: eventRecord.eventId,
name: eventRecord.name,
createdAt: eventRecord.createdAt,
updatedAt: eventRecord.updatedAt,
runs: eventRecord.runs.map((run) => ({
id: run.id,
status: run.status,
startedAt: run.startedAt,
completedAt: run.completedAt,
})),
};
}
type FoundEventRecord = NonNullable<Awaited<ReturnType<typeof findEventRecord>>>;
async function findEventRecord(eventId: string, environmentId: string) {
return await prisma.eventRecord.findUnique({
select: {
eventId: true,
name: true,
createdAt: true,
updatedAt: true,
runs: {
select: {
id: true,
status: true,
startedAt: true,
completedAt: true,
},
},
},
where: {
eventId_environmentId: {
eventId,
environmentId,
},
},
});
}
@@ -0,0 +1,82 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { apiCors } from "~/utils/apiCors";
const ParamsSchema = z.object({
runId: z.string(),
});
const RecordsSchema = z.array(JobRunStatusRecordSchema);
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request, { allowPublicKey: true });
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const { runId } = ParamsSchema.parse(params);
logger.debug("Get run statuses", {
runId,
});
try {
const run = await prisma.jobRun.findUnique({
where: {
id: runId,
},
select: {
id: true,
status: true,
output: true,
statuses: {
orderBy: {
createdAt: "asc",
},
},
},
});
if (!run) {
return apiCors(request, json({ error: `No run found for id ${runId}` }, { status: 404 }));
}
const parsedStatuses = RecordsSchema.parse(
run.statuses.map((s) => ({
...s,
state: s.state ?? undefined,
data: s.data ?? undefined,
history: s.history ?? undefined,
}))
);
return apiCors(
request,
json({
run: {
id: run.id,
status: run.status,
output: run.output,
},
statuses: parsedStatuses,
})
);
} catch (error) {
if (error instanceof Error) {
return apiCors(request, json({ error: error.message }, { status: 400 }));
}
return apiCors(request, json({ error: "Something went wrong" }, { status: 500 }));
}
}
@@ -0,0 +1,100 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
import { taskListToTree } from "~/utils/taskListToTree";
const ParamsSchema = z.object({
runId: z.string(),
});
const SearchQuerySchema = z.object({
cursor: z.string().optional(),
take: z.coerce.number().default(20),
subtasks: z.coerce.boolean().default(false),
taskdetails: z.coerce.boolean().default(false),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: true,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return apiCors(request, json({ error: "Invalid or missing runId" }, { status: 400 }));
}
const { runId } = parsed.data;
const url = new URL(request.url);
const parsedQuery = SearchQuerySchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsedQuery.success) {
return apiCors(
request,
json({ error: "Invalid or missing query parameters" }, { status: 400 })
);
}
const query = parsedQuery.data;
const showTaskDetails = query.taskdetails && authenticationResult.type === "PRIVATE";
const take = Math.min(query.take, 50);
const presenter = new ApiRunPresenter();
const jobRun = await presenter.call({
runId: runId,
maxTasks: take,
taskDetails: showTaskDetails,
subTasks: query.subtasks,
cursor: query.cursor,
});
if (!jobRun) {
return apiCors(request, json({ message: "Run not found" }, { status: 404 }));
}
if (jobRun.environmentId !== authenticatedEnv.id) {
return apiCors(request, json({ message: "Run not found" }, { status: 404 }));
}
const selectedTasks = jobRun.tasks.slice(0, take);
const tasks = taskListToTree(selectedTasks, query.subtasks);
const nextTask = jobRun.tasks[take];
return apiCors(
request,
json({
id: jobRun.id,
status: jobRun.status,
startedAt: jobRun.startedAt,
updatedAt: jobRun.updatedAt,
completedAt: jobRun.completedAt,
output: jobRun.output,
tasks: tasks.map((task) => {
const { parentId, ...rest } = task;
return { ...rest };
}),
statuses: jobRun.statuses.map((s) => ({
...s,
state: s.state ?? undefined,
data: s.data ?? undefined,
history: s.history ?? undefined,
})),
nextCursor: nextTask ? nextTask.id : undefined,
})
);
}
@@ -6,7 +6,7 @@ import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationBillingPath,
@@ -14,7 +14,7 @@ import {
} from "~/utils/pathBuilder";
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
@@ -33,7 +33,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
slug: organizationSlug,
members: {
some: {
userId: user.id,
userId,
},
},
},
@@ -1,13 +1,13 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectBackWithErrorMessage, redirectWithErrorMessage } from "~/models/message.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder";
export async function loader({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
@@ -18,7 +18,7 @@ export async function loader({ request, params }: ActionFunctionArgs) {
slug: organizationSlug,
members: {
some: {
userId: user.id,
userId,
},
},
},
@@ -6,9 +6,10 @@ import { CodeBlock } from "~/components/code/CodeBlock";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { ApiExample } from "~/services/externalApis/apis.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
await requireUser(request);
await requireUserId(request);
const url = new URL(request.url);
const codeUrl = url.searchParams.get("url");
invariant(typeof codeUrl === "string", "codeUrl is required");
@@ -1,6 +1,8 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { DeleteEndpointService } from "~/services/endpoints/deleteEndpointService";
import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server";
import { requireUserId } from "~/services/session.server";
import { workerQueue } from "~/services/worker.server";
const ParamsSchema = z.object({
@@ -8,21 +10,43 @@ const ParamsSchema = z.object({
endpointParam: z.string(),
});
export async function action({ params }: ActionFunctionArgs) {
const { endpointParam } = ParamsSchema.parse(params);
const BodySchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("refresh") }),
z.object({ action: z.literal("delete") }),
]);
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
if (request.method !== "POST") {
throw new Response(null, { status: 405 });
}
try {
const service = new IndexEndpointService();
await service.call(endpointParam, "MANUAL");
const { endpointParam } = ParamsSchema.parse(params);
const form = await request.formData();
const formObject = Object.fromEntries(form.entries());
const { action } = BodySchema.parse(formObject);
// Enqueue the endpoint to be probed in 10 seconds
await workerQueue.enqueue(
"probeEndpoint",
{ id: endpointParam },
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
);
switch (action) {
case "refresh": {
const service = new IndexEndpointService();
await service.call(endpointParam, "MANUAL");
return json({ success: true });
// Enqueue the endpoint to be probed in 10 seconds
await workerQueue.enqueue(
"probeEndpoint",
{ id: endpointParam },
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
);
return json({ success: true });
}
case "delete": {
const service = new DeleteEndpointService();
await service.call(endpointParam, userId);
return json({ success: true });
}
}
} catch (e) {
return json({ success: false, error: e }, { status: 400 });
}
@@ -1,4 +1,5 @@
import { ActionFunction } from "@remix-run/node";
import { ActionFunction, LoaderFunction, LoaderFunctionArgs, json } from "@remix-run/node";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
@@ -14,7 +15,90 @@ const ParamSchema = z.object({
jobId: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { jobId } = ParamSchema.parse(params);
const job = await prisma.job.findFirst({
select: {
id: true,
slug: true,
title: true,
aliases: {
select: {
version: {
select: {
version: true,
status: true,
concurrencyLimit: true,
concurrencyLimitGroup: {
select: {
name: true,
concurrencyLimit: true,
},
},
runs: {
select: {
createdAt: true,
status: true,
},
take: 1,
orderBy: [{ createdAt: "desc" }],
},
},
},
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
},
},
where: {
id: jobId,
deletedAt: null,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!job) {
throw new Response("Not Found", { status: 404 });
}
const environments = job.aliases.map((alias) => ({
type: alias.environment.type,
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
concurrencyLimit: alias.version.concurrencyLimit,
concurrencyLimitGroup: alias.version.concurrencyLimitGroup,
}));
return typedjson({
environments,
});
}
export const action: ActionFunction = async ({ request, params }) => {
if (request.method.toUpperCase() !== "DELETE") {
return { status: 405, body: "Method Not Allowed" };
}
const { jobId } = ParamSchema.parse(params);
const userId = await requireUserId(request);
@@ -1,12 +1,18 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
redirectBackWithErrorMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { ContinueRunService } from "~/services/runs/continueRun.server";
import { ReRunService } from "~/services/runs/reRun.server";
import { rootPath, runPath } from "~/utils/pathBuilder";
export const schema = z.object({
successRedirect: z.string(),
failureRedirect: z.string(),
});
const ParamSchema = z.object({
@@ -20,7 +26,11 @@ export const action: ActionFunction = async ({ request, params }) => {
const submission = parse(formData, { schema });
if (!submission.value) {
return json(submission);
return redirectWithErrorMessage(
rootPath(),
request,
submission.error ? JSON.stringify(submission.error) : "Invalid form"
);
}
try {
@@ -29,7 +39,11 @@ export const action: ActionFunction = async ({ request, params }) => {
const run = await rerunService.call({ runId });
if (!run) {
return redirectBackWithErrorMessage(request, "Unable to retry run");
return redirectWithErrorMessage(
submission.value.failureRedirect,
request,
"Unable to retry run"
);
}
return redirectWithSuccessMessage(
@@ -48,6 +62,10 @@ export const action: ActionFunction = async ({ request, params }) => {
);
}
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
return redirectWithErrorMessage(
submission.value.failureRedirect,
request,
error instanceof Error ? error.message : JSON.stringify(error)
);
}
};
@@ -0,0 +1,97 @@
import { parse } from "@conform-to/zod";
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
import { inspect } from "util";
import { z } from "zod";
import { env } from "~/env.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { requireUser } from "~/services/session.server";
import crypto from "node:crypto";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { prisma } from "~/db.server";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { LinkButton } from "~/components/primitives/Buttons";
import { rootPath } from "~/utils/pathBuilder";
import { FormTitle } from "~/components/primitives/FormTitle";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
export const ParamsSchema = z.object({
userId: z.string(),
token: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { userId, token } = ParamsSchema.parse(params);
try {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
return typedjson({
success: false as const,
message: "User not found",
});
}
//check that the token is valid for the userId
const hashedUserId = crypto
.createHash("sha256")
.update(`${userId}-${env.MAGIC_LINK_SECRET}`)
.digest("hex");
if (hashedUserId !== token) {
return typedjson({
success: false as const,
message: "This unsubscribe link was invalid so we can't unsubscribe you.",
});
}
await prisma.user.update({
where: { id: userId },
data: { marketingEmails: false },
});
return typedjson({ success: true as const, email: user.email });
} catch (e) {
const errorMessage = e instanceof Error ? e.message : JSON.stringify(e);
return typedjson({ success: false as const, message: errorMessage });
}
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
return (
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
{result.success ? (
<div>
<FormTitle LeadingIcon="envelope" title="Unsubscribed" />
<Paragraph spacing>
You have unsubscribed from onboarding emails, {result.email}.
</Paragraph>
<LinkButton variant="primary/medium" to={rootPath()}>
Dashboard
</LinkButton>
</div>
) : (
<div>
<FormTitle LeadingIcon="envelope" title="Unsubscribe failed" />
<Paragraph spacing>{result.message}</Paragraph>
<Paragraph spacing>
If you believe this is a bug, please{" "}
<TextLink href="https://trigger.dev/contact">contact support</TextLink>.
</Paragraph>
<LinkButton variant="primary/medium" to={rootPath()}>
Dashboard
</LinkButton>
</div>
)}
</MainCenteredContainer>
</AppContainer>
);
}
@@ -31,3 +31,9 @@ export async function setCurrentProjectId(id: string, request: Request) {
session.set("currentProjectId", id);
return session;
}
export async function clearCurrentProjectId(request: Request) {
const session = await getCurrentProjectSession(request);
session.unset("currentProjectId");
return session;
}
@@ -0,0 +1,97 @@
import { DateFormatter } from "@internationalized/date";
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "./billing.server";
import { DeleteProjectService } from "./deleteProject.server";
export class DeleteOrganizationService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
organizationSlug,
userId,
request,
}: {
organizationSlug: string;
userId: string;
request: Request;
}) {
const organization = await this.#prismaClient.organization.findFirst({
include: {
projects: true,
members: true,
},
where: {
slug: organizationSlug,
members: { some: { userId: userId } },
},
});
if (!organization) {
throw new Error("Organization not found");
}
if (organization.deletedAt) {
throw new Error("Organization already deleted");
}
//check if they have an active subscription
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const currentPlan = await billingPresenter.currentPlan(organization.id);
if (currentPlan && currentPlan.subscription && currentPlan.subscription.isPaying) {
//they've cancelled and that date hasn't passed yet
if (
currentPlan.subscription.canceledAt &&
new Date(currentPlan.subscription.canceledAt) > new Date()
) {
//a dateformatter that produces results like "Jan 1 2024"
const dateFormatter = new DateFormatter("en-us", {
year: "numeric",
month: "short",
day: "numeric",
});
throw new Error(
`This Organization has a canceled subscription. You can delete it when the cancelation date (${dateFormatter.format(
new Date(currentPlan.subscription.canceledAt)
)}) is in the past.`
);
}
throw new Error("You can't delete an Organization that has an active subscription");
}
// loop through the projects and delete them
const projectDeleteService = new DeleteProjectService();
for (const project of organization.projects) {
await projectDeleteService.call({ projectId: project.id, userId });
}
//set all the integrations to disabled
await this.#prismaClient.integrationConnection.updateMany({
where: {
organizationId: organization.id,
},
data: {
enabled: false,
},
});
//mark the organization as deleted
await this.#prismaClient.organization.update({
where: {
id: organization.id,
},
data: {
runsEnabled: false,
deletedAt: new Date(),
},
});
}
}
@@ -0,0 +1,141 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { DeleteEndpointService } from "./endpoints/deleteEndpointService";
import { logger } from "./logger.server";
import { DisableScheduleSourceService } from "./schedules/disableScheduleSource.server";
type Options = ({ projectId: string } | { projectSlug: string }) & {
userId: string;
};
export class DeleteProjectService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(options: Options) {
const projectId = await this.#getProjectId(options);
const project = await this.#prismaClient.project.findFirst({
include: {
environments: {
include: {
endpoints: true,
},
},
jobs: {
where: { deletedAt: null },
include: {
aliases: {
where: {
name: "latest",
},
include: {
version: true,
},
take: 1,
},
},
},
organization: true,
},
where: {
id: projectId,
organization: { members: { some: { userId: options.userId } } },
},
});
if (!project) {
throw new Error("Project not found");
}
if (project.deletedAt) {
return;
}
//disable and delete all jobs
const service = new DisableScheduleSourceService();
for (const environment of project.environments) {
//disable the event dispatchers
await this.#prismaClient.eventDispatcher.updateMany({
where: {
environmentId: environment.id,
},
data: {
enabled: false,
},
});
const eventDispatchers = await this.#prismaClient.eventDispatcher.findMany({
where: {
environmentId: environment.id,
},
});
logger.info("Deleting jobs", { jobs: project.jobs });
for (const job of project.jobs) {
//disable all the job versions
await this.#prismaClient.jobVersion.updateMany({
where: {
jobId: job.id,
},
data: {
status: "DISABLED",
},
});
await this.#prismaClient.job.update({
where: {
id: job.id,
},
data: {
deletedAt: new Date(),
},
});
//disable scheduled sources
for (const eventDispatcher of eventDispatchers) {
await service.call({
key: job.id,
dispatcher: eventDispatcher,
});
}
}
}
//delete all endpoints
const deleteEndpointService = new DeleteEndpointService();
for (const environment of project.environments) {
for (const endpoint of environment.endpoints) {
await deleteEndpointService.call(endpoint.id, options.userId);
}
}
//mark the project as deleted
await this.#prismaClient.project.update({
where: {
id: project.id,
},
data: {
deletedAt: new Date(),
},
});
}
async #getProjectId(options: Options) {
if ("projectId" in options) {
return options.projectId;
}
const { id } = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: options.projectSlug,
},
});
return id;
}
}
@@ -0,0 +1,28 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
export class DeleteEndpointService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string, userId: string): Promise<void> {
await this.#prismaClient.endpoint.update({
data: {
url: null,
},
where: {
id,
organization: {
members: {
some: {
userId,
},
},
},
},
});
}
}
@@ -54,6 +54,13 @@ export class PerformEndpointIndexService {
logger.debug("Performing endpoint index", endpointIndex);
if (!endpointIndex.endpoint.url) {
logger.debug("Endpoint URL is not set", endpointIndex);
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: "Endpoint URL is not set",
});
}
// Make a request to the endpoint to fetch a list of jobs
const client = new EndpointApi(
endpointIndex.endpoint.environment.apiKey,
@@ -29,6 +29,13 @@ export class ProbeEndpointService {
id,
});
if (!endpoint.url) {
logger.debug(`Endpoint has no url`, {
id,
});
return;
}
const client = new EndpointApi(endpoint.environment.apiKey, endpoint.url);
const { response, durationInMs } = await client.probe(MAX_RUN_CHUNK_EXECUTION_LIMIT);
@@ -16,6 +16,9 @@ export class RecurringEndpointIndexService {
const endpoints = await this.#prismaClient.endpoint.findMany({
where: {
url: {
not: null,
},
environment: {
type: {
in: [RuntimeEnvironmentType.PRODUCTION, RuntimeEnvironmentType.STAGING],
@@ -577,6 +577,13 @@ export class IntegrationAuthRepository {
throw new Error(`Connection ${connectionId} not found`);
}
if (!connection.enabled) {
logger.info("Connection is disabled", {
connection,
});
return;
}
let customOAuthClient: OAuthClient | undefined;
if (connection.integration.customClientReference) {
const secretStore = getSecretStore(env.SECRET_STORE);
@@ -687,9 +694,15 @@ export class IntegrationAuthRepository {
if (connection.expiresAt) {
const refreshBy = new Date(connection.expiresAt.getTime() - tokenRefreshThreshold * 1000);
if (refreshBy < new Date()) {
connection = await this.refreshConnection({
const refreshedConnection = await this.refreshConnection({
connectionId: connection.id,
});
if (!refreshedConnection) {
return;
}
connection = refreshedConnection;
}
}
@@ -96,6 +96,14 @@ export class HandleHttpEndpointService {
);
}
if (!httpEndpointEnvironment.endpoint.url) {
logger.debug("Endpoint has no url", {
httpEndpointId: httpEndpoint.id,
environmentId: environment.id,
});
return json({ error: true, message: "Endpoint has no url" }, { status: 404 });
}
const immediateResponseFilter = RequestFilterSchema.nullable().safeParse(
httpEndpointEnvironment.immediateResponseFilter
);
@@ -2,6 +2,7 @@ import type { Job } from "@trigger.dev/database";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { telemetry } from "../telemetry.server";
import { logger } from "../logger.server";
export class DeleteJobService {
#prismaClient: PrismaClient;
@@ -25,6 +26,7 @@ export class DeleteJobService {
const allDisabled = latestVersions.every((alias) => alias.version.status === "DISABLED");
if (!allDisabled) {
logger.info("Not all latest versions are disabled, cannot delete job", { jobId: job.id });
throw new Error("All latest versions must be disabled before deleting a job");
}
@@ -1,7 +1,14 @@
import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server";
import { ResumeRunService } from "./resumeRun.server";
const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "UNRESOLVED_AUTH", "ABORTED", "CANCELED"];
const RESUMABLE_STATUSES = [
"FAILURE",
"TIMED_OUT",
"UNRESOLVED_AUTH",
"ABORTED",
"CANCELED",
"INVALID_PAYLOAD",
];
export class ContinueRunService {
#prismaClient: PrismaClient;
@@ -37,6 +37,11 @@ export class CreateRunService {
},
});
if (!endpoint.url) {
logger.debug("Endpoint has no url", endpoint);
return;
}
const eventRecord = await this.#prismaClient.eventRecord.findUniqueOrThrow({
where: {
id: eventId,
@@ -97,6 +97,10 @@ export class DeliverRunSubscriptionService {
return true;
}
if (subscription.run.endpoint.url === null) {
return true;
}
const client = new EndpointApi(
subscription.run.environment.apiKey,
subscription.run.endpoint.url
@@ -135,6 +135,12 @@ export class PerformRunExecutionV3Service {
return;
}
if (!run.endpoint.url) {
return await this.#failRunExecution(this.#prismaClient, run, {
message: `Endpoint has no URL set`,
});
}
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
const event = eventRecordToApiJson(run.event);
@@ -201,6 +207,19 @@ export class PerformRunExecutionV3Service {
forceYieldCoordinator.deregisterRun(run.id);
//if the run has been canceled while it's being executed, we shouldn't do anything more
const updatedRun = await this.#prismaClient.jobRun.findUnique({
select: {
status: true,
},
where: {
id: run.id,
},
});
if (!updatedRun || updatedRun.status === "CANCELED") {
return;
}
if (!response) {
return await this.#failRunExecutionWithRetry(
run,
@@ -103,11 +103,11 @@ function validateSchedule(schedule: ScheduleMetadata): ScheduleMetadata {
}
function validateInterval(schedule: IntervalMetadata): ScheduleMetadata {
if (schedule.options.seconds < 60) {
if (schedule.options.seconds < 20) {
return {
type: "interval",
options: {
seconds: 60,
seconds: 20,
},
};
}
@@ -43,6 +43,10 @@ export class DeliverHttpSourceRequestService {
return;
}
if (!httpSourceRequest.endpoint.url) {
return;
}
const secretStore = getSecretStore(httpSourceRequest.source.secretReference.provider);
const secret = await secretStore.getSecret(
@@ -49,6 +49,10 @@ export class DeliverWebhookRequestService {
return;
}
if (!requestDelivery.endpoint.url) {
return;
}
const { secretReference } = requestDelivery.webhook.httpEndpoint;
const secretStore = getSecretStore(secretReference.provider);
@@ -35,6 +35,10 @@ export class InitializeTriggerService {
},
});
if (!endpoint.url) {
throw new Error("This environment's endpoint doesn't have a URL set");
}
const dynamicTrigger = await this.#prismaClient.dynamicTrigger.findUniqueOrThrow({
where: {
endpointId_slug_type: {
+8
View File
@@ -131,6 +131,10 @@ export function organizationBillingPath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
export function organizationSettingsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings`;
}
export function usagePath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
@@ -216,6 +220,10 @@ export function projectEventsPath(organization: OrgForPath, project: ProjectForP
return `${projectPath(organization, project)}/events`;
}
export function projectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
return `${projectPath(organization, project)}/settings`;
}
export function projectEventPath(
organization: OrgForPath,
project: ProjectForPath,
+1 -1
View File
@@ -64,7 +64,7 @@ client.defineJob({
name: "Example Job",
version: "0.1.0",
trigger: eventTrigger({ name: "example.event" }),
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
enabled: process.env.TRIGGER_JOBS_DISABLED !== "true",
run: async (payload, io, ctx) => {
// your Job code here
},
+1 -1
View File
@@ -12,7 +12,7 @@ description: "`intervalTrigger()` is set as a [Job's trigger](/sdk/job) to trigg
Intervals are set with a number of seconds. There are some important considerations:
- The Job will first run the specified number of seconds after it has first connected to an [Environment](/documentation/concepts/environments-endpoints). This will happen when you first [deploy](/documentation/guides/deployment) that Job.
- The minimum interval is 60 seconds (any input less than this it will default to 60).
- The minimum interval is 20 seconds (any input less than this it will default to 20).
- The maximum interval is 2_592_000 seconds (30 days), if you pass more than this it will trigger every 30 days.
If you wish to Run a Job at an exact time or less frequently than once pr day you should use a [cronTrigger()](/sdk/crontrigger) instead.
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "logger"
description: "Used to send log messages to the [Run log](/documentation/guides/viewing-runs)."
description: "Used to send log messages to the [Run log](/docs/documentation/guides/viewing-runs)."
---
There are 5 levels, that you can use to log messages.
+22
View File
@@ -1,5 +1,27 @@
# @trigger.dev/airtable
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/airtable",
"version": "2.3.15",
"version": "2.3.18",
"description": "Trigger.dev integration for airtable",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.15",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.18",
"@trigger.dev/sdk": "workspace:^2.3.18",
"airtable": "^0.12.1",
"zod": "3.22.3"
},
+22
View File
@@ -1,5 +1,27 @@
# @trigger.dev/github
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/github",
"version": "2.3.15",
"version": "2.3.18",
"description": "The official GitHub integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -30,8 +30,8 @@
"@octokit/request-error": "^5.0.1",
"@octokit/webhooks": "^12.0.10",
"octokit": "^3.1.2",
"@trigger.dev/integration-kit": "workspace:^2.3.15",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.18",
"@trigger.dev/sdk": "workspace:^2.3.18",
"zod": "3.22.3"
},
"engines": {
+22
View File
@@ -1,5 +1,27 @@
# @trigger.dev/linear
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/linear",
"version": "2.3.15",
"version": "2.3.18",
"description": "Trigger.dev integration for @linear/sdk",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@linear/sdk": "^8.0.0",
"@trigger.dev/integration-kit": "workspace:^2.3.15",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.18",
"@trigger.dev/sdk": "workspace:^2.3.18",
"zod": "3.22.3"
},
"engines": {
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/slack
## 2.3.18
### Patch Changes
- d69e4e71: Retry 400 status code, OpenAI returns this sometimes for no reason...
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/openai",
"version": "2.3.15",
"version": "2.3.18",
"description": "The official OpenAI integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -42,8 +42,8 @@
},
"dependencies": {
"openai": "^4.16.1",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.15"
"@trigger.dev/sdk": "workspace:^2.3.18",
"@trigger.dev/integration-kit": "workspace:^2.3.18"
},
"engines": {
"node": ">=18.0.0"
+29 -27
View File
@@ -60,11 +60,11 @@ function createTaskUsageProperties(
},
...("completion_tokens" in usage
? [
{
label: "Completion Usage",
text: String(usage.completion_tokens),
},
]
{
label: "Completion Usage",
text: String(usage.completion_tokens),
},
]
: []),
];
}
@@ -83,35 +83,35 @@ function createTaskRateLimitProperties(headers: Headers | undefined) {
return [
...(remainingRequests
? [
{
label: "Remaining Requests",
text: remainingRequests ?? "Unknown",
},
]
{
label: "Remaining Requests",
text: remainingRequests ?? "Unknown",
},
]
: []),
...(resetRequests
? [
{
label: "Reset Requests",
text: resetRequests ?? "Unknown",
},
]
{
label: "Reset Requests",
text: resetRequests ?? "Unknown",
},
]
: []),
...(remainingTokens
? [
{
label: "Remaining Tokens",
text: remainingTokens ?? "Unknown",
},
]
{
label: "Remaining Tokens",
text: remainingTokens ?? "Unknown",
},
]
: []),
...(resetTokens
? [
{
label: "Reset Tokens",
text: resetTokens ?? "Unknown",
},
]
{
label: "Reset Tokens",
text: resetTokens ?? "Unknown",
},
]
: []),
];
}
@@ -128,6 +128,8 @@ export function handleOpenAIError(error: unknown) {
}
return (
//sometimes OpenAI returns a 400 that when retried becomes a 200…
error.status === 400 ||
error.status === 429 ||
error.status === 408 ||
error.status === 409 ||
@@ -295,7 +297,7 @@ const requestOptionsKeys: KeysEnum<OpenAIRequestOptions> = {
export const isRequestOptions = (obj: unknown): obj is OpenAIRequestOptions => {
return (
typeof obj === 'object' &&
typeof obj === "object" &&
obj !== null &&
!isEmptyObj(obj) &&
Object.keys(obj).every((k) => hasOwn(requestOptionsKeys, k))
@@ -310,4 +312,4 @@ function isEmptyObj(obj: Object | null | undefined): boolean {
function hasOwn(obj: Object, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
}
+22
View File
@@ -1,5 +1,27 @@
# @trigger.dev/plain
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/plain",
"version": "2.3.15",
"version": "2.3.18",
"description": "The official Plain.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.15",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.18",
"@trigger.dev/sdk": "workspace:^2.3.18",
"@team-plain/typescript-sdk": "^2.7.0"
},
"engines": {
+22
View File
@@ -1,5 +1,27 @@
# @trigger.dev/replicate
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/replicate",
"version": "2.3.15",
"version": "2.3.18",
"description": "Trigger.dev integration for replicate",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.15",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.18",
"@trigger.dev/sdk": "workspace:^2.3.18",
"replicate": "^0.18.1",
"zod": "3.22.3"
},
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/resend
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- da90ee13: Fix resend integration example comment
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/resend",
"version": "2.3.15",
"version": "2.3.18",
"description": "The official Resend.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.15",
"@trigger.dev/sdk": "workspace:^2.3.15",
"@trigger.dev/integration-kit": "workspace:^2.3.18",
"@trigger.dev/sdk": "workspace:^2.3.18",
"resend": "^2.1.0"
},
"engines": {
+3 -3
View File
@@ -165,7 +165,7 @@ export class Resend implements TriggerIntegration {
* @example
* ```ts
* const response = await io.resend.audiences.create("📧", {
* name: payload.name,
* name: payload.name
* });
* ```
*/
@@ -182,8 +182,8 @@ export class Resend implements TriggerIntegration {
* email: payload.email,
* first_name: payload.first_name,
* last_name: payload.last_name,
* unsubscribed: false
* audienceId: payload.audienceId
* unsubscribed: payload.unsubscribed,
* audience_id: payload.audience_id
* });
* ```
*/
+22
View File
@@ -1,5 +1,27 @@
# @trigger.dev/sendgrid
## 2.3.18
### Patch Changes
- @trigger.dev/integration-kit@2.3.18
- @trigger.dev/sdk@2.3.18
## 2.3.17
### Patch Changes
- Updated dependencies [dd879c8e]
- @trigger.dev/sdk@2.3.17
- @trigger.dev/integration-kit@2.3.17
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes

Some files were not shown because too many files have changed in this diff Show More