Compare commits

...

53 Commits

Author SHA1 Message Date
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
Eric Allam 2c328ba93d Fix pnmp lock file again 2024-01-17 14:50:04 +00:00
github-actions[bot] 68dbc07ee8 chore: Update version for release (#854)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 14:49:19 +00:00
Eric Allam 58448243af Fix pnpm i 2024-01-17 14:41:51 +00:00
Eric Allam 6c4047cf21 Fix an issue where runs were stuck executing when a child task failed and the parent task retried 2024-01-17 14:39:06 +00:00
github-actions[bot] 831860eace chore: Update version for release (#853)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 14:13:34 +00:00
Matt Aitken db46f2a69a Add explicit return type to deprecated sendEmail function because tsup was complaining 2024-01-17 14:07:45 +00:00
Kritik Jiyaviya 4dd6cf18dc feat: Add support for the new Resend audience API endpoints (#850)
* feat: Add support for the new Resend audience API endpoints

* update pnpm-lock.yaml

* update pnpm-lock.yaml

* Merge branch 'main' into feat/support-for-resend-audience-api

* latest pnpm-lock.yaml

* Create lovely-items-end.md

* Update pnpm-lock.yaml

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-17 14:06:45 +00:00
Eric Allam adf66d23b8 Update pnpm lock file
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 15s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-17 11:15:30 +00:00
github-actions[bot] 5c42831a60 chore: Update version for release (#846)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 11:14:52 +00:00
Eric Allam 26f5e7774d Fix yalt.dev connection timeouts by increasing timeout from 1s to 5s (#851)
* Increase the yalt tunnel connection timeout to 5 seconds

* Add changeset
2024-01-17 10:27:57 +00:00
Matt Aitken f209a3b364 Ability to disable organization runs + more obvious if you're over the free limit (#848)
* Fix for typo in error when a Job invoke fails

* Can disable runs for an Organization

* Don’t perform runs if runsEnabled === false for an Org

* Exceeded runs messages made into errors, they were very subtle

* Return the runsEnabled property for each org

* More obvious message if you’re over the free run limit

* Added a red border to the free usage panel if you’re over your run limit

* Added missing property to Storybook for the FreePlanUsage component
2024-01-17 09:42:58 +00:00
Son Yen Nguyen a93b554f8b Feat: Update cron parser human readable string (#847)
* Update cron parser human readable string

* Create hot-cobras-flow.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-16 17:22:35 +00:00
Matt Aitken 0f342cd1be Don't show duplicate Job warning if it's an internal job 2024-01-16 15:54:20 +00:00
Matt Aitken 7df2f85a1f Latest lockfile 2024-01-16 15:22:21 +00:00
Matt Aitken f14180d13c Also hide "dev.trigger.scheduled" events from the event list 2024-01-16 15:21:45 +00:00
github-actions[bot] ab6b9514cd chore: Update version for release (#844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-16 15:00:28 +00:00
Matt Aitken 98345d67d9 Allow unchecked array access in the CLI tsconfig. This matches the other packages… 2024-01-16 14:46:31 +00:00
Matt Aitken 38f5a90399 Auto-yielding is skipped for tasks that are no-ops and subtasks 2024-01-16 14:01:05 +00:00
Matt Aitken 1b2635ae4a Don’t show “trigger.scheduled” events in the list 2024-01-16 12:14:08 +00:00
Matt Aitken 129f023d11 EventTrigger source wasn’t getting passed through to the class 2024-01-16 12:07:11 +00:00
Matt Aitken 795e637dec caniuse-lite updated, it was logging out an annoying message 2024-01-16 11:52:08 +00:00
Matt Aitken 5238c424fc Absolute date filtering (#845)
* WIP on absolute date filtering for runs

* Use the date hook instead

* Reworked the date field again so the state behaves nicely

* Way better date filtering

* Setting the absolute date is working well, also clearing filters

* Reverse date format

* Turn off the guide

* Added time filtering and clearing to the events page
2024-01-16 11:44:15 +00:00
Matt Aitken 1bbd7e6dc3 V3 CLI started, with Personal Access Tokens and Who Am I (#841)
* Initial CLI commit

* Hooked up the CLI so it actually shows stuff…

* Totally reworked the v3 CLI to be based on our existing CLI (Commander)

* WIP on PersonalAccessTokens and AuthorizationCodes

* WIP creating a Personal Access Tokens page. Created a new sidenav for account pages

* Creating tokens is working but the form is broken

* Tokens are created in the UI

* Creating and revoking access tokens from the UI is working

* Improved the create form and copy

* Tokens are a bit shorter and only lowercase

* API endpoint for creating AuthorizationCodes and the web page users hit to create PATs from them

* V3_ENABLED env var and hook that can be used in the UI to show/hide things

* API route to get a PAT (within 10 mins of creating an auth code). Moved some code to core

* Start to build the login command

* Nicer banner when starting the CLI

* Nicer update checking

* Update command style improved

* Removed the template step

* Login command options are now working

* The new CLI is logging in using the Personal Access TOken. But I need to save it still

* Logging in and saving the token is working

* Deal with already being logged in

* Who Am I working with PAT

* Deleted old account side menu header

* Improved the copy on the Auth code page

* Added docs for the login and whoami commands

* Added readme instructions for the update command

* Removed some unused things

* Remove fetchUseProxy for now
2024-01-15 14:45:41 +00:00
Kritik Jiyaviya 3bb82ed9a5 feat: runs filtering by relative timeframe (#832)
* feat: runs filtering by relative timeframe

* improve logic

* Used formatDateTime

* fix: logic of  determineTimeFrame() function

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 14:13:20 +00:00
Abhimanyu Yadav ff4ff869ab feat(logger): accepting Error objects as parameters in io.logger.error() (#797)
* feat(logger): accepting Error objects as parameters in io.logger.error()

* Allow errors for any log level

* Create silent-ties-vanish.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 14:02:41 +00:00
Matt Aitken 1dcee2b338 Changed docs analytics to PostHog EU 2024-01-15 13:51:02 +00:00
Aditya Tripathi 0a798446c8 feat: managed cloud check for search indexing (#825)
* feat: managed cloud check for search indexing

* fix: vice versa on the robots meta tag

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 09:35:43 +00:00
Kritik Jiyaviya 209942a63d fix: integrations list scrollbar (#829) 2024-01-15 09:29:42 +00:00
Manthan Mallikarjun 57a9b35870 Fix typo in deployment-automatic docs (#842)
Env var is wrong
2024-01-15 09:28:37 +00:00
Fabian B a16b65f666 docs: fix syntax error (#839) 2024-01-14 16:55:01 +00:00
226 changed files with 8745 additions and 1152 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>
);
}
@@ -13,8 +13,15 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
);
const hasHitLimit = cappedPercentage >= 1;
return (
<div className="rounded border border-slate-900 bg-[#101722] p-2.5">
<div
className={cn(
"rounded border border-slate-900 bg-[#101722] p-2.5",
hasHitLimit && "border-rose-800/60"
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
@@ -5,6 +5,7 @@ import { formatNumberCompact } from "~/utils/numberFormatter";
import { plansPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Callout } from "../primitives/Callout";
type UpgradePromptProps = {
organization: MatchedOrganization;
@@ -18,19 +19,25 @@ export function UpgradePrompt({ organization }: UpgradePromptProps) {
}
return (
<div className="flex h-full w-full items-center gap-4 bg-gradient-to-r from-transparent to-indigo-900/50 pr-1.5">
<Paragraph variant="extra-small" className="text-rose-500">
You have exceeded the monthly {formatNumberCompact(currentPlan.usage.runCountCap)} runs
limit
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
<Callout variant="error" className="flex h-full items-center rounded-none px-1 py-0">
<div className="flex items-center justify-between gap-3">
<Paragraph variant="extra-small" className="text-white">
{organization.runsEnabled
? `You have exceeded the monthly ${formatNumberCompact(
currentPlan.usage.runCountCap
)} runs
limit`
: `No runs are executing because you have exceeded the free limit`}
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
</Callout>
);
}
@@ -5,4 +5,12 @@ export const EventListSearchSchema = z.object({
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
environment: FilterableEnvironment.optional(),
from: z
.string()
.transform((value) => parseInt(value))
.optional(),
to: z
.string()
.transform((value) => parseInt(value))
.optional(),
});
@@ -12,14 +12,19 @@ import {
} from "../primitives/Select";
import { EventListSearchSchema } from "./EventStatuses";
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
import { useCallback } from "react";
import { Button } from "../primitives/Buttons";
export function EventsFilters() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environment } = EventListSearchSchema.parse(Object.fromEntries(searchParams.entries()));
const { environment, from, to } = EventListSearchSchema.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = (filterType: string, value: string | undefined) => {
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
@@ -28,12 +33,38 @@ export function EventsFilters() {
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
};
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
handleFilterChange("environment", value === "ALL" ? undefined : value);
};
const clearFilters = useCallback(() => {
searchParams.delete("status");
searchParams.delete("environment");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex flex-row justify-between gap-x-2">
<SelectGroup>
@@ -62,6 +93,12 @@ export function EventsFilters() {
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
Clear
</Button>
</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;
@@ -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 }) {
@@ -99,7 +99,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
{job.properties && (
<SimpleTooltip
button={
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
<div className="flex max-w-[300px] items-start justify-start gap-5 truncate">
{job.properties.map((property, index) => (
<LabelValueStack
key={index}
@@ -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>
@@ -0,0 +1,104 @@
import { Link } from "@remix-run/react";
import { User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
import { Header3 } from "../primitives/Headers";
import { ArrowLeftIcon, ChevronLeftIcon } from "@heroicons/react/24/solid";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { Feedback } from "../Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { useV3Enabled } from "~/root";
export function AccountSideMenu({ user }: { user: User }) {
const v3Enabled = useV3Enabled();
return (
<div
className={cn(
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
)}
>
<div className="flex h-full flex-col">
<div
className={cn("flex items-center justify-between border-b bg-background p-px transition")}
>
<LinkButton
variant="tertiary/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
>
Account
</LinkButton>
</div>
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
<SideMenuItem
name="Your profile"
icon="account"
iconColor="text-indigo-500"
to={accountPath()}
data-action="account"
/>
</div>
{v3Enabled && (
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title="Security" />
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldCheckIcon}
iconColor="text-emerald-500"
to={personalAccessTokensPath()}
data-action="tokens"
/>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
data-action="help & feedback"
fullWidth
textAlignLeft
>
Help & Feedback
</Button>
}
/>
</div>
</div>
</div>
);
}
@@ -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)} />;
}
}
@@ -5,15 +5,14 @@ import {
ChartBarIcon,
CursorArrowRaysIcon,
EllipsisHorizontalIcon,
ShieldCheckIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useFeatures } from "~/hooks/useFeatures";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
@@ -28,11 +27,12 @@ import {
organizationIntegrationsPath,
organizationPath,
organizationTeamPath,
personalAccessTokensPath,
projectEnvironmentsPath,
projectEventsPath,
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
projectEventsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
@@ -42,11 +42,10 @@ import { LogoIcon } from "../LogoIcon";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { Button, LinkButton } from "../primitives/Buttons";
import { Button } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { type IconNames } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
@@ -57,7 +56,9 @@ import {
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
import { SideMenuHeader } from "./SideMenuHeader";
import { useV3Enabled } from "~/root";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
@@ -380,6 +381,7 @@ function ProjectSelector({
function UserMenu({ user }: { user: SideMenuUser }) {
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
const navigation = useNavigation();
const v3Enabled = useV3Enabled();
useEffect(() => {
setProfileMenuOpen(false);
@@ -417,6 +419,14 @@ function UserMenu({ user }: { user: SideMenuUser }) {
icon={UserProfilePhoto}
leadingIconClassName="text-indigo-500"
/>
{v3Enabled && (
<PopoverMenuItem
to={personalAccessTokensPath()}
title="Personal Access Tokens"
icon={ShieldCheckIcon}
leadingIconClassName="text-emerald-500"
/>
)}
<PopoverMenuItem
to={logoutPath()}
title="Log out"
@@ -429,99 +439,3 @@ function UserMenu({ user }: { user: SideMenuUser }) {
</Popover>
);
}
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
</div>
);
}
function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -0,0 +1,38 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
) : null}
</div>
);
}
@@ -0,0 +1,73 @@
import { AnchorHTMLAttributes } from "react";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { IconNames } from "../primitives/NamedIcon";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { Icon } from "../primitives/Icon";
import { IconExclamationCircle } from "@tabler/icons-react";
export function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
export function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -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}
@@ -3,6 +3,7 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
const ClientTabs = TabsPrimitive.Root;
@@ -48,4 +49,47 @@ const ClientTabsContent = React.forwardRef<
));
ClientTabsContent.displayName = TabsPrimitive.Content.displayName;
export type TabsProps = {
tabs: {
label: string;
value: string;
}[];
currentValue: string;
className?: string;
layoutId: string;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-slate-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-slate-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
@@ -0,0 +1,243 @@
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
import { useDateFieldState } from "@react-stately/datepicker";
import { Granularity } from "@react-types/datepicker";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { useLocales } from "./LocaleProvider";
import { Button } from "./Buttons";
type DateFieldProps = {
label?: string;
defaultValue?: Date;
minValue?: Date;
maxValue?: Date;
className?: string;
fieldClassName?: string;
granularity: Granularity;
showGuide?: boolean;
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
};
export function DateField({
label,
defaultValue,
onValueChange,
minValue,
maxValue,
granularity,
className,
fieldClassName,
showGuide = false,
showNowButton = false,
showClearButton = false,
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
);
const state = useDateFieldState({
value: value,
onChange: (value) => {
if (value) {
setValue(value);
onValueChange?.(value.toDate("utc"));
}
},
minValue: utcDateToCalendarDate(minValue),
maxValue: utcDateToCalendarDate(maxValue),
shouldForceLeadingZeros: true,
granularity,
locale: "en-US",
createCalendar: (name: string) => {
return createCalendar(name);
},
});
//if the passed in value changes, we should update the date
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const calendarDate = utcDateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
return;
}
setValue(calendarDate);
}, [defaultValue]);
const ref = useRef<null | HTMLDivElement>(null);
const { labelProps, fieldProps } = useDateField(
{
label,
},
state,
ref
);
//render if reverse date order
const yearSegment = state.segments.find((s) => s.type === "year")!;
const monthSegment = state.segments.find((s) => s.type === "month")!;
const daySegment = state.segments.find((s) => s.type === "day")!;
const hourSegment = state.segments.find((s) => s.type === "hour")!;
const minuteSegment = state.segments.find((s) => s.type === "minute")!;
const secondSegment = state.segments.find((s) => s.type === "second")!;
const dayPeriodSegment = state.segments.find((s) => s.type === "dayPeriod")!;
return (
<div className={`flex flex-col items-start ${className || ""}`}>
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-slate-300">
{label}
</span>
<div className="flex flex-row items-center gap-1">
<div
{...fieldProps}
ref={ref}
className={cn(
"flex rounded-sm border border-slate-800 bg-midnight-900 p-0.5 px-1.5 transition-colors focus-within:border-slate-500 hover:border-slate-700 focus-within:hover:border-slate-500",
fieldClassName
)}
>
<DateSegment segment={yearSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={monthSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={daySegment} state={state} />
<DateSegment segment={literalSegment(", ")} state={state} />
<DateSegment segment={hourSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={minuteSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={secondSegment} state={state} />
<DateSegment segment={literalSegment(" ")} state={state} />
<DateSegment segment={dayPeriodSegment} state={state} />
</div>
{showNowButton && (
<Button
variant="secondary/small"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
onValueChange?.(now);
}}
>
Now
</Button>
)}
{showClearButton && (
<Button
variant="secondary/small"
LeadingIcon={"close"}
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
state.clearSegment("year");
state.clearSegment("month");
state.clearSegment("day");
state.clearSegment("hour");
state.clearSegment("minute");
state.clearSegment("second");
}}
/>
)}
</div>
{showGuide && (
<div className="mt-1 flex px-2">
{state.segments.map((segment, i) => (
<DateSegmentGuide key={i} segment={segment} />
))}
</div>
)}
</div>
);
}
function utcDateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds()
)
: undefined;
}
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
};
function DateSegment({ segment, state }: DateSegmentProps) {
const ref = useRef<null | HTMLDivElement>(null);
const { segmentProps } = useDateSegment(segment, state, ref);
return (
<div
{...segmentProps}
ref={ref}
style={{
...segmentProps.style,
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
!segment.isEditable ? "text-slate-500" : "text-bright"
}`}
>
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
<span
aria-hidden="true"
className="block text-center italic text-slate-500 group-focus:text-white"
style={{
visibility: segment.isPlaceholder ? undefined : "hidden",
height: segment.isPlaceholder ? "" : 0,
pointerEvents: "none",
}}
>
{segment.placeholder}
</span>
{segment.isPlaceholder ? "" : segment.text}
</div>
);
}
function literalSegment(text: string): DateSegment {
return {
type: "literal",
text,
isPlaceholder: false,
isEditable: false,
placeholder: "",
};
}
function minWidthForSegment(segment: DateSegment) {
if (segment.type === "literal") {
return undefined;
}
return String(`${segment.maxValue}`).length + "ch";
}
function DateSegmentGuide({ segment }: { segment: DateSegment }) {
return (
<div
style={{
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
!segment.isEditable ? "text-slate-500" : "text-bright"
}`}
>
<span className="block text-center italic text-slate-500">
{segment.type !== "literal" ? segment.placeholder : segment.text}
</span>
</div>
);
}
@@ -2,6 +2,7 @@ import * as React from "react";
import { cn } from "~/utils/cn";
import type { IconNamesOrString } from "./NamedIcon";
import { NamedIcon } from "./NamedIcon";
import { Icon, RenderIcon } from "./Icon";
const variants = {
large: {
@@ -44,7 +45,7 @@ const variants = {
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
variant?: keyof typeof variants;
icon?: IconNamesOrString;
icon?: RenderIcon;
shortcut?: string;
fullWidth?: boolean;
};
@@ -59,7 +60,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className={cn("relative", fullWidth ? "w-full" : "max-w-max")}>
{icon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center">
<NamedIcon name={icon} className={cn(iconClassName, "text-dimmed")} />
<Icon icon={icon} className={cn(iconClassName, "text-dimmed")} />
</div>
)}
<input
@@ -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>
);
}
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
avoidCollisions={true}
className={cn(
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-max rounded-md border border-slate-700 bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
style={{
@@ -56,7 +56,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md bg-popover text-bright shadow-md animate-in fade-in-40",
"relative z-50 min-w-max overflow-hidden rounded-md border border-slate-700 bg-popover text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
>
<SelectPrimitive.Viewport
className={cn(
"border border-slate-800 px-1 py-0",
"px-1 py-0",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
@@ -122,10 +122,26 @@ type TableCellProps = TableCellBasicProps & {
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
hasAction?: boolean;
isSticky?: boolean;
};
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
(
{
className,
alignment = "left",
children,
colSpan,
to,
onClick,
hasAction = false,
isSticky = false,
},
ref
) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
@@ -154,6 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
? "cursor-pointer group-hover:bg-slate-900"
: "px-4 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
className
)}
colSpan={colSpan}
@@ -174,9 +191,6 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
);
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{
@@ -189,7 +203,8 @@ export const TableCellChevron = forwardRef<
>(({ className, to, children, isSticky, onClick }, ref) => {
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
className={className}
isSticky={isSticky}
to={to}
onClick={onClick}
ref={ref}
@@ -213,7 +228,8 @@ export const TableCellMenu = forwardRef<
const [isOpen, setIsOpen] = useState(false);
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
@@ -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">
+43 -7
View File
@@ -5,6 +5,7 @@ import {
NoSymbolIcon,
PauseCircleIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
@@ -27,16 +28,19 @@ import {
environmentKeys,
statusKeys,
} from "./RunStatuses";
import { TimeFrameFilter } from "./TimeFrameFilter";
import { Button } from "../primitives/Buttons";
import { useCallback } from "react";
export function RunsFilters() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environment, status } = RunListSearchSchema.parse(
const { environment, status, from, to } = RunListSearchSchema.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = (filterType: string, value: string | undefined) => {
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
@@ -45,15 +49,41 @@ export function RunsFilters() {
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
};
}, []);
const handleStatusChange = (value: FilterableStatus | "ALL") => {
const handleStatusChange = useCallback((value: FilterableStatus | "ALL") => {
handleFilterChange("status", value === "ALL" ? undefined : value);
};
}, []);
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
const handleEnvironmentChange = useCallback((value: FilterableEnvironment | "ALL") => {
handleFilterChange("environment", value === "ALL" ? undefined : value);
};
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const clearFilters = useCallback(() => {
searchParams.delete("status");
searchParams.delete("environment");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex flex-row justify-between gap-x-2">
@@ -108,6 +138,12 @@ export function RunsFilters() {
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
Clear
</Button>
</div>
);
}
@@ -156,6 +156,14 @@ export const RunListSearchSchema = z.object({
direction: DirectionSchema.optional(),
status: FilterableStatus.optional(),
environment: FilterableEnvironment.optional(),
from: z
.string()
.transform((value) => parseInt(value))
.optional(),
to: z
.string()
.transform((value) => parseInt(value))
.optional(),
});
export const filterableStatuses: Record<FilterableStatus, JobRunStatus[]> = {
@@ -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) => {
@@ -0,0 +1,243 @@
import { ChevronDownIcon } from "lucide-react";
import { useCallback, useState } from "react";
import {
Calendar,
CalendarDateTime,
DateValue,
getLocalTimeZone,
today,
} from "@internationalized/date";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { ClientTabs, ClientTabsContent, ClientTabsWithUnderline } from "../primitives/ClientTabs";
import { formatDateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { DateField } from "../primitives/DateField";
import { useLocales } from "../primitives/LocaleProvider";
import { createCalendar } from "@internationalized/date";
type RunTimeFrameFilterProps = {
from?: number;
to?: number;
onRangeChanged: (range: { from?: number; to?: number }) => void;
};
type Mode = "absolute" | "relative";
export function TimeFrameFilter({ from, to, onRangeChanged }: RunTimeFrameFilterProps) {
const [activeTab, setActiveTab] = useState<Mode>("absolute");
const [isOpen, setIsOpen] = useState(false);
const [relativeTimeSeconds, setRelativeTimeSeconds] = useState<number | undefined>();
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
const relativeTimeFrameChanged = useCallback((value: number) => {
const to = new Date().getTime();
const from = to - value;
onRangeChanged({ from, to });
setRelativeTimeSeconds(value);
}, []);
const absoluteTimeFrameChanged = useCallback(({ from, to }: { from?: Date; to?: Date }) => {
setRelativeTimeSeconds(undefined);
const fromTime = from?.getTime();
const toTime = to?.getTime();
onRangeChanged({ from: fromTime, to: toTime });
}, []);
return (
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen} modal>
<PopoverTrigger asChild>
<Button
variant="secondary/small"
className="bg-slate-800 group-hover:bg-tertiary-foreground"
>
<Paragraph variant="extra-small" className="mr-2">
{title(from, to, relativeTimeSeconds)}
</Paragraph>
<ChevronDownIcon className="h-4 w-4 text-bright" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="bg-popover p-2">
<ClientTabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as Mode)}
className="p-1"
>
<ClientTabsWithUnderline
tabs={[
{ label: "Absolute", value: "absolute" },
{ label: "Relative", value: "relative" },
]}
currentValue={activeTab}
layoutId={"time-tabs"}
/>
<ClientTabsContent value={"absolute"}>
<AbsoluteTimeFrame
from={fromDate}
to={toDate}
onValueChange={absoluteTimeFrameChanged}
/>
</ClientTabsContent>
<ClientTabsContent value={"relative"}>
<RelativeTimeFrame
value={relativeTimeSeconds}
onValueChange={relativeTimeFrameChanged}
/>
</ClientTabsContent>
</ClientTabs>
</PopoverContent>
</Popover>
);
}
function title(
from: number | undefined,
to: number | undefined,
relativeTimeSeconds: number | undefined
): string {
if (!from && !to) {
return "All time periods";
}
if (relativeTimeSeconds !== undefined) {
return timeFrameValues.find((t) => t.value === relativeTimeSeconds)?.label ?? "Timeframe";
}
let fromString = from ? formatDateTime(new Date(from), "UTC", ["en-US"], false, true) : undefined;
let toString = to ? formatDateTime(new Date(to), "UTC", ["en-US"], false, true) : undefined;
if (from && !to) {
return `From ${fromString} (UTC)`;
}
if (!from && to) {
return `To ${toString} (UTC)`;
}
return `${fromString} - ${toString} (UTC)`;
}
function RelativeTimeFrame({
value,
onValueChange,
}: {
value?: number;
onValueChange: (value: number) => void;
}) {
return (
<div className="grid grid-cols-3 gap-1 pt-2">
{timeFrameValues.map((timeframe) => (
<Button
key={timeframe.value}
variant={value === timeframe.value ? "primary/small" : "tertiary/small"}
className={cn(
"w-full",
value !== timeframe.value && "border border-slate-700 group-hover:bg-slate-700"
)}
onClick={() => {
onValueChange(timeframe.value);
}}
>
<Paragraph variant="extra-small">{timeframe.label}</Paragraph>
</Button>
))}
</div>
);
}
const timeFrameValues = [
{
label: "5 mins",
value: 5 * 60 * 1000,
},
{
label: "15 mins",
value: 15 * 60 * 1000,
},
{
label: "30 mins",
value: 30 * 60 * 1000,
},
{
label: "1 hour",
value: 60 * 60 * 1000,
},
{
label: "3 hours",
value: 3 * 60 * 60 * 1000,
},
{
label: "6 hours",
value: 6 * 60 * 60 * 1000,
},
{
label: "1 day",
value: 24 * 60 * 60 * 1000,
},
{
label: "3 days",
value: 3 * 24 * 60 * 60 * 1000,
},
{
label: "7 days",
value: 7 * 24 * 60 * 60 * 1000,
},
{
label: "10 days",
value: 10 * 24 * 60 * 60 * 1000,
},
{
label: "14 days",
value: 14 * 24 * 60 * 60 * 1000,
},
{
label: "30 days",
value: 30 * 24 * 60 * 60 * 1000,
},
];
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
function AbsoluteTimeFrame({
from,
to,
onValueChange,
}: {
from?: Date;
to?: Date;
onValueChange: (value: { from?: Date; to?: Date }) => void;
}) {
return (
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col justify-start gap-2">
<div className="flex flex-row items-center gap-1">
<DateField
label="From (UTC)"
defaultValue={from}
onValueChange={(value) => {
onValueChange({ from: value, to: to });
}}
granularity="second"
showNowButton
showClearButton
/>
</div>
<div className="flex flex-row items-center gap-1">
<DateField
label="To (UTC)"
defaultValue={to}
onValueChange={(value) => {
onValueChange({ from: from, to: value });
}}
granularity="second"
showNowButton
showClearButton
/>
</div>
</div>
</div>
);
}
@@ -21,7 +21,7 @@ const mockOrganization: MatchedOrganization = {
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
],
hasUnconfiguredIntegrations: false,
memberCount: 1,
runsEnabled: true,
};
export const ProgressBar: Story = {
+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
+3
View File
@@ -67,6 +67,9 @@ const EnvironmentSchema = z.object({
TUNNEL_HOST: z.string().optional(),
TUNNEL_SECRET_KEY: z.string().optional(),
//v3
V3_ENABLED: z.string().default("false"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+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>({
@@ -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: {
@@ -10,6 +10,8 @@ type EventListOptions = {
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
@@ -31,32 +33,32 @@ export class EventListPresenter {
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: EventListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
// 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,
@@ -91,12 +93,16 @@ export class EventListPresenter {
},
where: {
internal: false,
name: {
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
},
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,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
//take an extra record to tell if there are more
@@ -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,38 @@ export class JobListPresenter {
orderBy: [{ title: "asc" }],
});
const 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
"public"."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 +142,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);
}
@@ -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,14 @@
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 {
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";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -16,40 +20,140 @@ 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 new Response("Project not found", { status: 404 });
}
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) {
if (!projectSlug) {
const bestProject = await this.#selectBestProjectForOrganization(organizationSlug, userId);
const session = await setCurrentProjectId(bestProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
//use the project param to find the project
const project = await prisma.project.findFirst({
select: {
id: true,
slug: true,
},
where: {
organization: {
slug: organizationSlug,
},
slug: projectSlug,
},
});
if (!project) {
throw redirect(newProjectPath({ slug: organizationSlug }));
}
const session = await setCurrentProjectId(project.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
//no project slug, so just return the session id
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,
},
},
});
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 } } },
orderBy: { createdAt: "desc" },
include: {
select: {
id: true,
slug: true,
title: true,
runsEnabled: true,
projects: {
orderBy: { name: "asc" },
include: {
select: {
id: true,
slug: true,
name: true,
_count: {
select: {
jobs: {
@@ -67,10 +171,10 @@ export class OrganizationsPresenter {
},
},
},
orderBy: { name: "asc" },
},
_count: {
select: {
members: true,
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
@@ -93,38 +197,36 @@ 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
) {
const projectPresenter = new ProjectPresenter();
async #selectBestProjectForOrganization(organizationSlug: string, userId: string) {
const projects = await this.#prismaClient.project.findMany({
select: {
id: true,
slug: true,
},
where: {
organization: {
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;
}
const project = await projectPresenter.call({ userId, slug: projectSlug });
if (!project) {
logger.info("Not Found: proj 2", { projectSlug, organization, project });
if (projects.length === 0) {
logger.info("Didn't find a project in this org", { organizationSlug, projects });
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,6 @@ 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" }],
},
_count: {
select: {
sources: {
@@ -100,19 +39,6 @@ export class ProjectPresenter {
httpEndpoints: true,
},
},
organization: {
select: {
_count: {
select: {
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
},
},
},
},
},
},
environments: {
select: {
id: true,
@@ -127,7 +53,7 @@ export class ProjectPresenter {
},
},
},
where: { slug, organization: { members: { some: { userId } } } },
where: { id, organization: { members: { some: { userId } } } },
});
if (!project) {
@@ -19,6 +19,8 @@ type RunListOptions = {
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
@@ -43,6 +45,8 @@ export class RunListPresenter {
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: RunListOptions) {
const filterStatuses = filterStatus ? filterableStatuses[filterStatus] : undefined;
@@ -50,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 } },
@@ -58,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: {
@@ -128,11 +131,12 @@ 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: {
gte: from ? new Date(from).toISOString() : undefined,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
//take an extra record to tell if there are more
+22 -2
View File
@@ -1,7 +1,15 @@
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useMatches,
} from "@remix-run/react";
import { metaV1 } from "@remix-run/v1-meta";
import { TypedMetaFunction, typedjson, useTypedLoaderData } from "remix-typedjson";
import { ExternalScripts } from "remix-utils/external-scripts";
@@ -16,6 +24,7 @@ import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { useHighlight } from "./hooks/useHighlight";
import { usePostHog } from "./hooks/usePostHog";
import { useTypedMatchesData } from "./hooks/useTypedMatchData";
import { getUser } from "./services/session.server";
import { appEnvTitleTag } from "./utils";
@@ -28,14 +37,24 @@ export const meta: TypedMetaFunction<typeof loader> = (args) => {
title: `Trigger.dev${appEnvTitleTag(args.data?.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
robots: args.data.features.isManagedCloud ? "index, follow" : "noindex, nofollow",
});
};
export function useV3Enabled() {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "root",
});
return routeMatch?.v3Enabled ?? false;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const highlightProjectId = env.HIGHLIGHT_PROJECT_ID;
const v3Enabled = env.V3_ENABLED === "true";
const features = featuresForRequest(request);
return typedjson(
@@ -47,6 +66,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
features,
appEnv: env.APP_ENV,
appOrigin: env.APP_ORIGIN,
v3Enabled,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
);
@@ -59,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={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small">
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>
);
@@ -65,7 +65,7 @@ export default function Page() {
</Callout>
)}
{hitRunLimit && (
<Callout variant={"pricing"}>
<Callout variant={"error"}>
{`You have exceeded the monthly
${formatNumberCompact(currentPlan!.subscription!.limits.runs!)} runs limit. Upgrade so you
can continue to perform runs.`}
@@ -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,
});
@@ -99,7 +91,7 @@ export default function Integrations() {
</PageHeader>
<PageBody scrollable={false}>
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] gap-4 divide-x divide-slate-900 overflow-hidden">
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] divide-x divide-slate-900 overflow-hidden">
<PossibleIntegrationsList
options={options}
organizationId={organization.id}
@@ -142,7 +134,7 @@ function PossibleIntegrationsList({
return (
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="py-4 pl-4">
<div className="p-4">
<div className="flex items-center justify-between">
<Header2 className="mb-2">Connect an API</Header2>
<Switch
@@ -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={
@@ -52,6 +52,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({ event, list });
@@ -37,6 +37,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
pageSize: 25,
});
@@ -5,8 +5,8 @@ import { cn } from "~/utils/cn";
type List = {
pagination: {
next: string | undefined;
previous: string | undefined;
next?: string | undefined;
previous?: string | undefined;
};
};
@@ -41,6 +41,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({
@@ -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,
@@ -40,15 +42,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
direction: searchParams.direction,
cursor: searchParams.cursor,
pageSize: 25,
from: searchParams.from,
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();
@@ -77,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>
@@ -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,
@@ -4,7 +4,7 @@ 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";
@@ -13,55 +13,12 @@ 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) => [
@@ -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;
};
@@ -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);
@@ -0,0 +1,193 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { updateUser } from "~/models/user.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { accountPath } from "~/utils/pathBuilder";
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parse(formData, { schema: formSchema, async: true });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Profile"} />;
},
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
},
});
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Your profile" />
</PageTitleRow>
</PageHeader>
<PageBody>
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</Form>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,93 @@
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { title } from "process";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { Icon } from "~/components/primitives/Icon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { logger } from "~/services/logger.server";
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { rootPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
authorizationCode: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
throw new Response(undefined, {
status: 400,
statusText: "Invalid params",
});
}
try {
const personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
parsedParams.data.authorizationCode,
userId
);
return typedjson({
success: true as const,
});
} catch (error) {
if (error instanceof Response) {
throw error;
}
if (error instanceof Error) {
return typedjson({
success: false as const,
error: error.message,
});
}
logger.error(JSON.stringify(error));
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
return (
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
<div className="flex flex-col items-center space-y-4">
{result.success ? (
<div>
<Header1 className="mb-2 flex items-center gap-1">
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
authenticated
</Header1>
<Paragraph>Return to your terminal to continue.</Paragraph>
</div>
) : (
<div>
<Header1 className="mb-2">Authentication failed</Header1>
<Callout variant="error" className="my-2">
{result.error}
</Callout>
<Paragraph spacing>
There was a problem authenticating you, please try logging in with your CLI again.
</Paragraph>
</div>
)}
</div>
</MainCenteredContainer>
</AppContainer>
);
}
@@ -0,0 +1,312 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useFetcher } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { redirectWithSuccessMessage } from "~/models/message.server";
import {
CreatedPersonalAccessToken,
ObfuscatedPersonalAccessToken,
createPersonalAccessToken,
getValidPersonalAccessTokens,
revokePersonalAccessToken,
} from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { personalAccessTokensPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
try {
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
return typedjson({
personalAccessTokens,
});
} 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.",
});
}
};
const CreateTokenSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("create"),
tokenName: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
}),
z.object({
action: z.literal("revoke"),
tokenId: z.string(),
}),
]);
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const submission = parse(formData, { schema: CreateTokenSchema });
if (!submission.value) {
return json(submission);
}
switch (submission.value.action) {
case "create": {
try {
const tokenResult = await createPersonalAccessToken({
name: submission.value.tokenName,
userId,
});
return json({ ...submission, payload: { token: tokenResult } });
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
case "revoke": {
try {
await revokePersonalAccessToken(submission.value.tokenId);
return redirectWithSuccessMessage(
personalAccessTokensPath(),
request,
"Personal Access Token revoked"
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
default: {
return json({ errors: { body: "Invalid action" } }, { status: 400 });
}
}
};
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Personal Access Tokens"} />;
},
};
export default function Page() {
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Personal Access Tokens" />
<PageButtons>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small">Create new token</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Create a Personal Access Token</DialogHeader>
<CreatePersonalAccessToken />
</DialogContent>
</Dialog>
</PageButtons>
</PageTitleRow>
<PageDescription>Personal Access Tokens can be used with our CLI and API.</PageDescription>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Token</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Last accessed</TableHeaderCell>
<TableHeaderCell hiddenLabel>Delete</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{personalAccessTokens.length > 0 ? (
personalAccessTokens.map((personalAccessToken) => {
return (
<TableRow key={personalAccessToken.id} className="group">
<TableCell>{personalAccessToken.name}</TableCell>
<TableCell>{personalAccessToken.obfuscatedToken}</TableCell>
<TableCell>
<DateTime date={personalAccessToken.createdAt} />
</TableCell>
<TableCell>
{personalAccessToken.lastAccessedAt ? (
<DateTime date={personalAccessToken.lastAccessedAt} />
) : (
"Never"
)}
</TableCell>
<TableCell alignment="right">
<RevokePersonalAccessToken token={personalAccessToken} />
</TableCell>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={5}>
<Paragraph variant="small" className="flex items-center justify-center">
You have no Personal Access Tokens (that haven't been revoked).
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</PageBody>
</PageContainer>
);
}
function CreatePersonalAccessToken() {
const fetcher = useFetcher<typeof action>();
const lastSubmission = fetcher.data as any;
const [form, { tokenName }] = useForm({
id: "create-personal-access-token",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateTokenSchema });
},
});
const token = lastSubmission?.payload?.token
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
: undefined;
return (
<div className="max-w-full overflow-x-hidden">
{token ? (
<div className="flex flex-col gap-2 p-2">
<Header2>Successfully generated a new token</Header2>
<Callout variant="success">
Copy this access token and store it in a secure place - you will not be able to see it
again.
</Callout>
<ClipboardField
secure
value={token.token}
variant={"secondary/medium"}
icon={<ShieldExclamationIcon className="h-5 w-5 text-emerald-500" />}
/>
</div>
) : (
<fetcher.Form method="post" {...form.props}>
<input type="hidden" name="action" value="create" />
<Fieldset>
<InputGroup>
<Label htmlFor={tokenName.id}>Name</Label>
<Input
{...conform.input(tokenName, { type: "text" })}
placeholder="The name of your Personal Access Token"
defaultValue=""
icon={ShieldCheckIcon}
autoComplete="off"
data-1p-ignore
/>
<Hint>
This will help you to identify your token. Tokens called "cli" are automatically
generated when you login with our CLI.
</Hint>
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</fetcher.Form>
)}
</div>
);
}
function RevokePersonalAccessToken({ token }: { token: ObfuscatedPersonalAccessToken }) {
const lastSubmission = useActionData();
const [form, { tokenId }] = useForm({
id: "revoke-personal-access-token",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateTokenSchema });
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs" />
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Revoke Personal Access Token</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Are you sure you want to revoke "{token.name}"? This can't be reversed.
</Paragraph>
<Form method="post" {...form.props}>
<input type="hidden" name="action" value="revoke" />
<input type="hidden" name="tokenId" value={token.id} />
<Button type="submit" variant="danger/medium" fullWidth>
Revoke token
</Button>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
+22 -172
View File
@@ -1,185 +1,35 @@
import { Form, useActionData } from "@remix-run/react";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Outlet } from "@remix-run/react";
import { AppContainer } from "~/components/layout/AppLayout";
import { AccountSideMenu } from "~/components/navigation/AccountSideMenu";
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
import { useUser } from "~/hooks/useUser";
import { z } from "zod";
import { ActionFunction, json, redirect } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { parse } from "@conform-to/zod";
import { accountPath, rootPath } from "~/utils/pathBuilder";
import { conform, useForm } from "@conform-to/react";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { Checkbox } from "~/components/primitives/Checkbox";
import { updateUser } from "~/models/user.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { prisma } from "~/db.server";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Handle } from "~/utils/handle";
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parse(formData, { schema: formSchema, async: true });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Account"} />;
},
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
},
});
return (
<AppContainer>
<MainCenteredContainer>
<FormTitle LeadingIcon="user" title="Profile" />
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<AccountSideMenu user={user} />
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
cancelButton={
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
<div className="flex w-full items-center justify-between border-b border-ui-border">
<Breadcrumb />
<div className="flex h-full items-center gap-4">
<PageNavigationIndicator className="mr-2" />
</div>
</div>
<Outlet />
</div>
</div>
</AppContainer>
);
}
+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,40 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { CreateAuthorizationCodeResponse } from "@trigger.dev/core";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { createAuthorizationCode } from "~/services/personalAccessToken.server";
/** Used to create an AuthorizationCode, that can then be used to obtain a Personal Access Token by logging in with the provided URL */
export async function action({ request }: ActionFunctionArgs) {
logger.info("Creating AuthorizationCode", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
//there is no authentication on this endpoint, anyone can create an AuthorizationCode.
//they're only used to allow a user to login, when they'll then receive a Personal Access Token
try {
const authorizationCode = await createAuthorizationCode();
const responseJson: CreateAuthorizationCodeResponse = {
authorizationCode: authorizationCode.code,
url: `${env.APP_ORIGIN}/account/authorization-code/${authorizationCode.code}`,
};
return json(responseJson);
} catch (error) {
if (error instanceof Error) {
logger.error("Error creating AuthorizationCode", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -72,7 +72,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
if (!run) {
return json({ error: "Job count not be invoked" }, { status: 500 });
return json({ error: "Job could not be invoked" }, { status: 500 });
}
return json({ id: run.id });
+50
View File
@@ -0,0 +1,50 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
GetPersonalAccessTokenRequestSchema,
GetPersonalAccessTokenResponse,
} from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { logger } from "~/services/logger.server";
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
export async function action({ request }: ActionFunctionArgs) {
logger.info("Getting PersonalAccessToken from AuthorizationCode", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
//There is no authentication on this endpoint, anyone can create an AuthorizationCode.
//But only a logged in user can create a PersonalAccessToken, so for a user who can't login to the app this will always fail.
// Now parse the request body
const anyBody = await request.json();
const body = GetPersonalAccessTokenRequestSchema.safeParse(anyBody);
if (!body.success) {
return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 });
}
try {
const personalAccessToken = await getPersonalAccessTokenFromAuthorizationCode(
body.data.authorizationCode
);
const responseJson: GetPersonalAccessTokenResponse = {
token: personalAccessToken.token,
};
return json(responseJson);
} catch (error) {
if (error instanceof Error) {
logger.error("Error getting PersonalAccessToken from AuthorizationCode", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -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,
})
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { WhoAmIResponse } from "@trigger.dev/core";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
export async function loader({ request }: LoaderFunctionArgs) {
logger.info("whoami v2", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const user = await prisma.user.findUnique({
select: {
email: true,
},
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "User not found" }, { status: 404 });
}
const result: WhoAmIResponse = {
userId: authenticationResult.userId,
email: user.email,
};
return json(result);
}
@@ -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 { DeleteEndpointIndexService } 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 DeleteEndpointIndexService();
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,28 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
export class DeleteEndpointIndexService {
#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],
@@ -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
);
@@ -0,0 +1,322 @@
import { customAlphabet, nanoid } from "nanoid";
import nodeCrypto from "node:crypto";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { PersonalAccessToken } from "@trigger.dev/database";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
};
/** Returns obfuscated access tokens that aren't revoked */
export async function getValidPersonalAccessTokens(userId: string) {
const personalAccessTokens = await prisma.personalAccessToken.findMany({
select: {
id: true,
name: true,
obfuscatedToken: true,
createdAt: true,
lastAccessedAt: true,
},
where: {
userId,
revokedAt: null,
},
});
return personalAccessTokens.map((pat) => ({
id: pat.id,
name: pat.name,
obfuscatedToken: pat.obfuscatedToken,
createdAt: pat.createdAt,
lastAccessedAt: pat.lastAccessedAt,
}));
}
export type ObfuscatedPersonalAccessToken = Awaited<
ReturnType<typeof getValidPersonalAccessTokens>
>[number];
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
export async function getPersonalAccessTokenFromAuthorizationCode(authorizationCode: string) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
select: {
personalAccessToken: true,
},
where: {
code: authorizationCode,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, or code expired");
}
//there's no PersonalAccessToken associated with this code
if (!code.personalAccessToken) {
return {
token: null,
};
}
const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken);
return {
token: {
token: decryptedToken,
obfuscatedToken: code.personalAccessToken.obfuscatedToken,
},
};
}
export async function revokePersonalAccessToken(tokenId: string) {
await prisma.personalAccessToken.update({
where: {
id: tokenId,
},
data: {
revokedAt: new Date(),
},
});
}
type PersonalAccessTokenAuthenticationResult = {
userId: string;
};
const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
tag: z.string(),
});
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export async function authenticateApiRequestWithPersonalAccessToken(
request: Request
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
const token = getPersonalAccessTokenFromRequest(request);
if (!token) {
return;
}
return authenticatePersonalAccessToken(token);
}
function getPersonalAccessTokenFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const personalAccessToken = authorization.data.replace(/^Bearer /, "");
return personalAccessToken;
}
export async function authenticatePersonalAccessToken(
token: string
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
if (!token.startsWith(tokenPrefix)) {
return;
}
const hashedToken = hashToken(token);
const personalAccessToken = await prisma.personalAccessToken.update({
where: {
hashedToken,
revokedAt: null,
},
data: {
lastAccessedAt: new Date(),
},
});
if (!personalAccessToken) {
return;
}
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
if (decryptedToken !== token) {
logger.error(
`PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`
);
return;
}
return {
userId: personalAccessToken.userId,
};
}
export function createAuthorizationCode() {
return prisma.authorizationCode.create({
data: {
code: nanoid(64),
},
});
}
/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessTokenFromAuthorizationCode(
authorizationCode: string,
userId: string
) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
where: {
code: authorizationCode,
personalAccessTokenId: null,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, code already used, or code expired");
}
const existingCliPersonalAccessToken = await prisma.personalAccessToken.findFirst({
where: {
userId,
name: "cli",
},
});
//we only allow you to have one CLI PAT at a time
if (existingCliPersonalAccessToken) {
await prisma.personalAccessToken.delete({
where: {
id: existingCliPersonalAccessToken.id,
},
});
}
const token = await createPersonalAccessToken({
name: "cli",
userId,
});
await prisma.authorizationCode.update({
where: {
code: authorizationCode,
},
data: {
personalAccessTokenId: token.id,
},
});
return token;
}
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessToken({
name,
userId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
name,
userId,
encryptedToken,
obfuscatedToken: obfuscateToken(token),
hashedToken: hashToken(token),
},
});
return {
id: personalAccessToken.id,
name,
userId,
token,
obfuscatedToken: personalAccessToken.obfuscatedToken,
};
}
export type CreatedPersonalAccessToken = Awaited<ReturnType<typeof createPersonalAccessToken>>;
const tokenPrefix = "tr_pat_";
/** Creates a PersonalAccessToken that starts with tr_pat_ */
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
function obfuscateToken(token: string) {
const withoutPrefix = token.replace(tokenPrefix, "");
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
return `${tokenPrefix}${obfuscated}`;
}
function encryptToken(value: string) {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
if (!encryptedData.success) {
throw new Error(
`Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`
);
}
const decryptedToken = decryptToken(
encryptedData.data.nonce,
encryptedData.data.ciphertext,
encryptedData.data.tag
);
return decryptedToken;
}
function decryptToken(nonce: string, ciphertext: string, tag: string): string {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
env.ENCRYPTION_KEY,
Buffer.from(nonce, "hex")
);
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}
@@ -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;
@@ -3,6 +3,7 @@ import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { logger } from "../logger.server";
export class CreateRunService {
#prismaClient: PrismaClientOrTransaction;
@@ -25,12 +26,22 @@ export class CreateRunService {
},
options: { callbackUrl?: string } = {}
) {
if (!environment.organization.runsEnabled) {
logger.debug("Runs are disabled for this organization", environment);
return;
}
const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({
where: {
id: version.endpointId,
},
});
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);
@@ -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: {
+4
View File
@@ -82,6 +82,10 @@ export function accountPath() {
return `/account`;
}
export function personalAccessTokensPath() {
return `/account/tokens`;
}
export function invitesPath() {
return `/invites`;
}
+9 -2
View File
@@ -46,6 +46,7 @@
"@heroicons/react": "^2.0.12",
"@highlight-run/node": "^3.1.0",
"@highlight-run/react": "^3.2.0",
"@internationalized/date": "^3.5.1",
"@lezer/highlight": "^1.1.6",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
@@ -57,6 +58,9 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5",
"@react-aria/datepicker": "^3.9.1",
"@react-stately/datepicker": "^3.9.1",
"@react-types/datepicker": "^3.7.1",
"@remix-run/express": "2.1.0",
"@remix-run/node": "2.1.0",
"@remix-run/react": "2.1.0",
@@ -66,8 +70,8 @@
"@tabler/icons-react": "^2.39.0",
"@tailwindcss/container-queries": "^0.1.1",
"@team-plain/typescript-sdk": "^3.5.0",
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/billing": "^1.0.10",
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-backend": "workspace:*",
"@trigger.dev/database": "workspace:*",
@@ -105,8 +109,10 @@
"prismjs": "^1.29.0",
"random-words": "^2.0.0",
"react": "^18.2.0",
"react-aria": "^3.31.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1",
"react-stately": "^3.29.1",
"react-use": "^17.4.0",
"recharts": "^2.8.0",
"remix-auth": "^3.6.0",
@@ -172,6 +178,7 @@
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"autoprefixer": "^10.4.13",
"datepicker": "link:@types/@react-aria/datepicker",
"esbuild": "^0.15.10",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
@@ -193,4 +200,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
@@ -34,7 +34,7 @@ You can add a step to the GitHub Action that deploys your app.
```yaml .github/workflows/release.yml
- name: 🚀 Refresh Trigger.dev Jobs
env:
DEPLOY_TEST_HOOK: ${{ secrets.TRIGGER_ENDPOINT_HOOK }}
TRIGGER_ENDPOINT_HOOK: ${{ secrets.TRIGGER_ENDPOINT_HOOK }}
run: |
curl -X POST $TRIGGER_ENDPOINT_HOOK
```
+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
},
+16 -7
View File
@@ -26,13 +26,22 @@ Send an email to a recipient with a text payload. [Official Resend Docs](https:/
## Tasks
| Function Name | Description |
| --------------- | -------------------------------- |
| `emails.send` | Send an email |
| `emails.create` | Create an email |
| `emails.get` | Get an email |
| `batch.send` | Send a batch of emails at once |
| `batch.create` | Create a batch of emails at once |
| Function Name | Description |
| ------------------ | -------------------------------- |
| `emails.send` | Send an email |
| `emails.create` | Create an email |
| `emails.get` | Get an email |
| `batch.send` | Send a batch of emails at once |
| `batch.create` | Create a batch of emails at once |
| `audiences.create` | Create an audience |
| `audiences.get` | Get an audience |
| `audiences.remove` | Remove an audience |
| `audiences.list` | List audiences |
| `contacts.create` | Create a contact |
| `contacts.get` | Get a contact |
| `contacts.update` | Update a contact |
| `contacts.remove` | Remove a contact |
| `contacts.list` | List contacts |
## Example
+1 -1
View File
@@ -436,7 +436,7 @@
},
"analytics": {
"posthog": {
"apiKey": "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW"
"apiKey": "phc_9aSDbJCaDUMdZdHxxMPTvcj7A9fsl3mCgM1RBPmPsl7"
}
}
}

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