Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7358fcb891 | |||
| 28052daad9 | |||
| 364c8c5f7f | |||
| 4b3b418abb | |||
| 2e354d342c | |||
| dd879c8e4a | |||
| af485b9180 | |||
| a739ebaa88 | |||
| 3ebc2578e0 | |||
| 0b657b33f9 | |||
| 7ac942dc0e | |||
| 9b12016428 | |||
| d6b44de4ba | |||
| 17df4839d7 | |||
| e3f78178f7 | |||
| da90ee13c3 | |||
| 583da458ec | |||
| dcf95c4eb2 | |||
| 32cf5790cb | |||
| c272e38de2 | |||
| ca78ddc2c2 | |||
| 07ed8c346a | |||
| d4391f2e2d | |||
| 2c328ba93d | |||
| 68dbc07ee8 | |||
| 58448243af | |||
| 6c4047cf21 | |||
| 831860eace | |||
| db46f2a69a | |||
| 4dd6cf18dc | |||
| adf66d23b8 | |||
| 5c42831a60 | |||
| 26f5e7774d | |||
| f209a3b364 | |||
| a93b554f8b | |||
| 0f342cd1be | |||
| 7df2f85a1f | |||
| f14180d13c | |||
| ab6b9514cd | |||
| 98345d67d9 | |||
| 38f5a90399 | |||
| 1b2635ae4a | |||
| 129f023d11 | |||
| 795e637dec | |||
| 5238c424fc | |||
| 1bbd7e6dc3 | |||
| 3bb82ed9a5 | |||
| ff4ff869ab | |||
| 1dcee2b338 | |||
| 0a798446c8 | |||
| 209942a63d | |||
| 57a9b35870 | |||
| a16b65f666 | |||
| 5871745a92 | |||
| 87b5dfbf11 | |||
| 5af2003516 | |||
| 85ce729bf2 | |||
| 5701d1da42 | |||
| bc61d83764 | |||
| 5bf125be0d |
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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,
|
||||
});
|
||||
|
||||
+29
-7
@@ -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={
|
||||
|
||||
+2
@@ -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 });
|
||||
|
||||
+2
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
type List = {
|
||||
pagination: {
|
||||
next: string | undefined;
|
||||
previous: string | undefined;
|
||||
next?: string | undefined;
|
||||
previous?: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -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({
|
||||
|
||||
+2
-8
@@ -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";
|
||||
|
||||
|
||||
+51
-16
@@ -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>
|
||||
|
||||
+3
-3
@@ -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,
|
||||
});
|
||||
|
||||
+3
-3
@@ -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,
|
||||
});
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+5
-48
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
+35
-11
@@ -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: {
|
||||
|
||||
@@ -82,6 +82,10 @@ export function accountPath() {
|
||||
return `/account`;
|
||||
}
|
||||
|
||||
export function personalAccessTokensPath() {
|
||||
return `/account/tokens`;
|
||||
}
|
||||
|
||||
export function invitesPath() {
|
||||
return `/invites`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@ client.defineJob({
|
||||
name: "Example Job",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({ name: "example.event" }),
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED !== "true",
|
||||
run: async (payload, io, ctx) => {
|
||||
// your Job code here
|
||||
},
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
---
|
||||
title: "Deploy to Kubernetes"
|
||||
description: "Deploy self hosted version of [Trigger.dev](https://trigger.dev) to your kubernetes cluster using our helm chart"
|
||||
description: "Deploy [Trigger.dev](https://trigger.dev) to your kubernetes cluster using our helm chart"
|
||||
---
|
||||
|
||||
<Warning>WARNING: Kubernetes deploys are not officially supported yet, please only use these instructions as a general guide and starting point.</Warning>
|
||||
|
||||
**Prerequisites**
|
||||
- You have understanding of [Kubernetes](https://kubernetes.io/)
|
||||
- Installed [Helm package manager](https://helm.sh/) version v3.11.3 or greater
|
||||
- You have an understanding of [Kubernetes](https://kubernetes.io/)
|
||||
- You have [Helm](https://helm.sh/) version v3.11.3 or greater installed
|
||||
- You have [kubectl](https://kubernetes.io/docs/reference/kubectl/kubectl/) installed and connected to your kubernetes cluster
|
||||
|
||||
By deploying Trigger.dev on Kubernetes, you can take advantage of its features to ensure that the application is fault-tolerant, highly available, and scalable.
|
||||
@@ -13,76 +16,85 @@ To make the installation process easier and more streamlined, we have created a
|
||||
Helm is a package manager for Kubernetes that simplifies the installation and management of Kubernetes applications.
|
||||
With our Helm chart, you can easily install Trigger.dev on Kubernetes, configure it to your liking, and scale it up or down as needed.
|
||||
|
||||
## Install Trigger.dev Helm repository
|
||||
## Get our Helm chart
|
||||
|
||||
As our charts aren't published for official use yet, you'll need a copy of the `helm-charts` dir:
|
||||
|
||||
```bash
|
||||
TODO: Add helm repo to artifact hub or cloudsmith
|
||||
git clone https://github.com/triggerdotdev/trigger.dev
|
||||
cd trigger.dev/helm-charts
|
||||
```
|
||||
|
||||
## Add Helm values
|
||||
## Add Helm values
|
||||
|
||||
Create a values.yaml file to configure various installation settings, such as the docker image tags and environment variables. To explore all configurable properties for your values file, [visit this page](https://github.com/triggerdotdev/trigger.dev/tree/main/helm-charts/).
|
||||
Create a `my-values.yaml` file to configure various installation settings, such as the docker image tags and environment variables. To explore all configurable parameters for your values file visit our [readme](https://github.com/triggerdotdev/trigger.dev/tree/main/helm-charts#parameters).
|
||||
|
||||
#### Set image tags
|
||||
### Set image tags
|
||||
|
||||
By default, the application will use the latest tag to retrieve the required Docker images, which may be appropriate for most cases.
|
||||
However, we recommend that you use a specific version of the Docker image to avoid unexpected changes to the application.
|
||||
|
||||
<Tip>
|
||||
To find the latest version number of Trigger.dev, follow the link below
|
||||
- [Trigger.dev image on github packaes](https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev)
|
||||
You can find valid image tags on [GitHub Packages](https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev).
|
||||
</Tip>
|
||||
|
||||
```yaml simple-values-example.yaml
|
||||
```yaml my-values.yaml
|
||||
trigger:
|
||||
name: trigger
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: ghcr.io/triggerdotdev/trigger.dev
|
||||
tag: "latest" # <--- frontend version
|
||||
tag: "latest" # <--- image tag
|
||||
pullPolicy: Always
|
||||
```
|
||||
|
||||
#### Configure environment variables
|
||||
### Configure environment variables
|
||||
|
||||
You can configure environment variables for trigger in your Helm values file under the property `envVars`. View configurable [environment variables](../configuration/envars).
|
||||
You can configure environment variables for trigger in your Helm values file under the property `trigger.env`. See examples for some of these values [here](https://github.com/triggerdotdev/trigger.dev/blob/main/.env.example).
|
||||
|
||||
Infisical requires the following backend environment variables to be defined: _`MAGIC_LINK_SECRET`_, _`SESSION_SECRET`_, _`ENCRYPTION_KEY`_, _`DIRECT_URL`_, and _`DATABASE_URL`_ .
|
||||
At a bare minimum, Trigger.dev requires the following environment variables to be defined:
|
||||
- `MAGIC_LINK_SECRET`
|
||||
- `SESSION_SECRET`
|
||||
- `ENCRYPTION_KEY`
|
||||
- `DIRECT_URL`
|
||||
- `DATABASE_URL`
|
||||
|
||||
However, when the above environment variables are not defined, the Helm chart
|
||||
will automatically generate these environment variables for you. The generated environment variables will be saved to a Kubernetes secret and will be preserved between upgrades or uninstalls.
|
||||
When the above environment variables are not defined, the Helm chart will automatically generate values for you. It will persist them in a secret which is preserved between upgrades or uninstalls. It is however strongly recommended to define your own values!
|
||||
|
||||
```yaml simple-values-example.yaml
|
||||
...
|
||||
envVars:
|
||||
ENCRYPTION_KEY: "b1ebe43a6a6e24b2aa8fa0707d3890e3"
|
||||
MAGIC_LINK_SECRET: "842727396bcee22da68518f959c5730b"
|
||||
```yaml my-values.yaml
|
||||
trigger:
|
||||
...
|
||||
env:
|
||||
ENCRYPTION_KEY: "b1ebe43a6a6e24b2aa8fa0707d3890e3"
|
||||
MAGIC_LINK_SECRET: "842727396bcee22da68518f959c5730b"
|
||||
...
|
||||
```
|
||||
#### Routing external traffic
|
||||
By default, Trigger.dev takes all traffic coming to your external load balancer's IP address and routes them Trigger.dev's services.
|
||||
Infisical uses Nginx to route external traffic. You can install Nginx along with Trigger by setting `ingress.enabled` to `true` in the Helm values file. View all [properties for ingress](https://github.com/triggerdotdev/trigger.dev/tree/main/helm-charts/).
|
||||
|
||||
```yaml simple-values-example.yaml
|
||||
### Routing external traffic
|
||||
By default, Trigger.dev takes all traffic coming to your external load balancer's IP address and routes them Trigger.dev's services.
|
||||
Trigger.dev uses Nginx to route external traffic. You can install Nginx along with Trigger by setting `ingress.enabled` to `true` in the Helm values file. View all [parameters for ingress](https://github.com/triggerdotdev/trigger.dev/tree/main/helm-charts#ingress-parameters).
|
||||
|
||||
```yaml my-values.yaml
|
||||
...
|
||||
ingress:
|
||||
nginx:
|
||||
enabled: true #<-- if you would like to install nginx along with Trigger.dev
|
||||
enabled: true # <-- if you would like to install nginx along with Trigger.dev
|
||||
```
|
||||
|
||||
#### Database
|
||||
Trigger.dev uses a SQL database as its persistence layer. With this Helm chart, you spin up a PostgreSQL instance powered by Bitnami along side other Trigger.dev services in your cluster.
|
||||
When persistence is enabled, the data will be stored as Kubernetes Persistence Volume. View all [properties for postgresql](https://github.com/triggerdotdev/trigger.dev/tree/main/helm-charts/).
|
||||
### Database
|
||||
With this Helm chart, you spin up a PostgreSQL instance powered by Bitnami alongside other Trigger.dev services in your cluster.
|
||||
When persistence is enabled, the data will be stored as a Kubernetes Persistence Volume. View all [parameters for postgres](https://github.com/triggerdotdev/trigger.dev/tree/main/helm-charts#postgres-parameters).
|
||||
|
||||
```yaml simple-values-example.yaml
|
||||
```yaml my-values.yaml
|
||||
postgresql:
|
||||
enabled: true
|
||||
persistence:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
#### Example helm values
|
||||
```yaml simple-values-example.yaml
|
||||
### Example values
|
||||
|
||||
```yaml my-values.yaml
|
||||
trigger:
|
||||
name: trigger
|
||||
replicaCount: 2
|
||||
@@ -90,19 +102,17 @@ trigger:
|
||||
repository: ghcr.io/triggerdotdev/trigger.dev
|
||||
tag: "latest"
|
||||
pullPolicy: Always
|
||||
|
||||
envVars:
|
||||
ENCRYPTION_KEY: "b1ebe43a6a6e24b2aa8fa0707d3890e3"
|
||||
MAGIC_LINK_SECRET: "842727396bcee22da68518f959c5730b"
|
||||
env:
|
||||
ENCRYPTION_KEY: "b1ebe43a6a6e24b2aa8fa0707d3890e3"
|
||||
MAGIC_LINK_SECRET: "842727396bcee22da68518f959c5730b"
|
||||
|
||||
ingress:
|
||||
nginx:
|
||||
enabled: true #<-- if you would like to install nginx along with Infisical
|
||||
|
||||
enabled: true # <-- if you would like to install nginx along with Trigger.dev
|
||||
```
|
||||
|
||||
<Accordion title="Full helm values example">
|
||||
```yaml values.yaml
|
||||
```yaml my-values.yaml
|
||||
ingress:
|
||||
nginx:
|
||||
enabled: true
|
||||
@@ -122,13 +132,9 @@ ingress:
|
||||
annotations: {}
|
||||
type: ClusterIP
|
||||
nodePort: ""
|
||||
|
||||
# View all environment variables TODO: Docs for all env vars
|
||||
envVars:
|
||||
DATABASE_URL: <>
|
||||
DIRECT_URL: <>
|
||||
ENCRYPTION_KEY: <>
|
||||
|
||||
env:
|
||||
ENCRYPTION_KEY: "b1ebe43a6a6e24b2aa8fa0707d3890e3"
|
||||
MAGIC_LINK_SECRET: "842727396bcee22da68518f959c5730b"
|
||||
|
||||
## Postgresql DB persistence
|
||||
postgresql:
|
||||
@@ -140,7 +146,7 @@ ingress:
|
||||
enabled: true
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod" # <-- if you are setting up HTTPS
|
||||
hostName: app.yourdomain.com ## <- Replace with your own domain
|
||||
hostName: app.yourdomain.com ## <-- replace with your own domain
|
||||
trigger:
|
||||
path: /
|
||||
pathType: Prefix
|
||||
@@ -148,24 +154,48 @@ ingress:
|
||||
- secretName: echo-tls
|
||||
hosts:
|
||||
- app.yourdomain.com
|
||||
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
## Install the Helm chart
|
||||
## Install the Helm chart
|
||||
|
||||
By default, the helm chart will be installed on your default namespace. If you wish to install the Chart on a different namespace, you may specify
|
||||
that by adding the `--namespace <namespace-to-install-to>` to your `helm install` command.
|
||||
The following command will install our chart into the `trigger` namespace:
|
||||
|
||||
```bash
|
||||
## Installs to default namespace
|
||||
TODO: not published
|
||||
# with custom values read from my-values.yaml
|
||||
helm upgrade --install --atomic --namespace trigger --create-namespace trigger . --values my-values.yaml
|
||||
|
||||
# with default values
|
||||
helm upgrade --install --atomic --namespace trigger --create-namespace trigger .
|
||||
|
||||
# with inlined values
|
||||
helm upgrade --install --atomic --namespace trigger --create-namespace trigger . --set trigger.replicaCount=3
|
||||
```
|
||||
|
||||
To watch the pods coming up, simply run this from another terminal:
|
||||
|
||||
```bash
|
||||
kubectl --namespace trigger get pods -w
|
||||
```
|
||||
|
||||
## Access Trigger.dev
|
||||
Allow 3-5 minutes for the deployment to complete. Once done, you should now be able to access Trigger.dev on the IP address exposed via Ingress on your load balancer. If you are not sure what the IP address is run `kubectl get ingress` to view the external IP address exposing Trigger.dev.
|
||||
|
||||
|
||||
Once the deployment is ready, you should be able to access Trigger.dev on the IP address exposed via Ingress on your load balancer. If you are not sure what the IP address is run `kubectl get ingress` to view the external IP address exposing Trigger.dev.
|
||||
|
||||
<Info>
|
||||
Once installation is complete, you will have to create the first account. No default account is provided.
|
||||
</Info>
|
||||
|
||||
### Local access
|
||||
|
||||
Forward a local port to access the webapp directly from your device:
|
||||
|
||||
```bash
|
||||
kubectl --namespace trigger port-forward svc/trigger 2024:3000
|
||||
```
|
||||
|
||||
Log in via email at `http://localhost:2024` then check your logs for the magic link:
|
||||
|
||||
```bash
|
||||
kubectl --namespace trigger logs deployments/trigger
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user