Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f9bd0d70 | |||
| a1860dbaae | |||
| d69e4e712d | |||
| 7fae67c47d | |||
| 9ae0ca64af | |||
| da69fa0613 | |||
| 336029b842 | |||
| 29edcd3df9 | |||
| a9ff32418e | |||
| 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 | |||
| babe1c0e54 | |||
| 1224fceb18 | |||
| 8277f4d249 | |||
| 73cb8839a5 | |||
| 5d0a731cf6 | |||
| 25a152517e | |||
| d1092fcd2c |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import {
|
||||
RunPanel,
|
||||
RunPanelBody,
|
||||
RunPanelDivider,
|
||||
RunPanelIconProperty,
|
||||
RunPanelIconSection,
|
||||
} from "~/components/run/RunCard";
|
||||
import { Event } from "~/presenters/EventPresenter.server";
|
||||
|
||||
export function EventDetail({ event }: { event: Event }) {
|
||||
const { id, name, payload, context, timestamp, deliveredAt } = event;
|
||||
|
||||
return (
|
||||
<RunPanel selected={false}>
|
||||
<RunPanelBody>
|
||||
<RunPanelIconSection>
|
||||
<RunPanelIconProperty
|
||||
icon="calendar"
|
||||
label="Created"
|
||||
value={<DateTime date={timestamp} />}
|
||||
/>
|
||||
{deliveredAt && (
|
||||
<RunPanelIconProperty
|
||||
icon="flag"
|
||||
label="Delivered"
|
||||
value={<DateTime date={deliveredAt} />}
|
||||
/>
|
||||
)}
|
||||
<RunPanelIconProperty icon="id" label="Event name" value={name} />
|
||||
<RunPanelIconProperty icon="account" label="Event ID" value={id} />
|
||||
</RunPanelIconSection>
|
||||
<RunPanelDivider />
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Header3>Payload</Header3>
|
||||
<CodeBlock code={payload} />
|
||||
<Header3>Context</Header3>
|
||||
<CodeBlock code={context} />
|
||||
</div>
|
||||
</RunPanelBody>
|
||||
</RunPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { DirectionSchema, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
|
||||
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(),
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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, from, to } = EventListSearchSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
searchParams.delete(filterType);
|
||||
}
|
||||
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>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environment ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph variant="extra-small" className="pl-0.5">
|
||||
All environments
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{environmentKeys.map((env) => (
|
||||
<SelectItem key={env} value={env}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnvironmentLabel environment={{ type: env }} />
|
||||
<Paragraph variant="extra-small">environment</Paragraph>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { StopIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { RuntimeEnvironmentType, User } from "@trigger.dev/database";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellChevron,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "../primitives/Table";
|
||||
|
||||
type EventTableItem = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
environment: {
|
||||
type: RuntimeEnvironmentType;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
};
|
||||
createdAt: Date | null;
|
||||
isTest: boolean;
|
||||
deliverAt: Date | null;
|
||||
deliveredAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
runs: number;
|
||||
};
|
||||
|
||||
type EventsTableProps = {
|
||||
total: number;
|
||||
hasFilters: boolean;
|
||||
events: EventTableItem[];
|
||||
isLoading?: boolean;
|
||||
eventsParentPath: string;
|
||||
currentUser: User;
|
||||
};
|
||||
|
||||
export function EventsTable({
|
||||
total,
|
||||
hasFilters,
|
||||
events,
|
||||
isLoading = false,
|
||||
eventsParentPath,
|
||||
currentUser,
|
||||
}: EventsTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Event</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Received Time</TableHeaderCell>
|
||||
<TableHeaderCell>Delivery Time</TableHeaderCell>
|
||||
<TableHeaderCell>Delivered</TableHeaderCell>
|
||||
<TableHeaderCell>Canceled Time</TableHeaderCell>
|
||||
<TableHeaderCell>Test</TableHeaderCell>
|
||||
<TableHeaderCell>Runs</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to page</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<NoEvents title="No events found" />
|
||||
</TableBlankRow>
|
||||
) : events.length === 0 ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<NoEvents title="No events match your filters" />
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
events.map((event) => {
|
||||
const path = `${eventsParentPath}/events/${event.id}`;
|
||||
const usernameForEnv =
|
||||
currentUser.id !== event.environment.userId ? event.environment.userName : undefined;
|
||||
|
||||
return (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell to={path}>{typeof event.name === "string" ? event.name : "-"}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel environment={event.environment} userName={usernameForEnv} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.createdAt ? <DateTime date={event.createdAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.deliverAt ? <DateTime date={event.deliverAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.deliveredAt ? <DateTime date={event.deliveredAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.cancelledAt ? <DateTime date={event.cancelledAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.isTest ? (
|
||||
<CheckIcon className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<StopIcon className="h-4 w-4 text-slate-850" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{event.runs}</TableCell>
|
||||
<TableCellChevron to={path} isSticky />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-slate-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function NoEvents({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">{title}</Paragraph>
|
||||
</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)} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@ import {
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChartBarIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
CursorArrowRaysIcon,
|
||||
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 { useV3Enabled } from "~/root";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
@@ -26,11 +26,15 @@ import {
|
||||
organizationBillingPath,
|
||||
organizationIntegrationsPath,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
projectEnvironmentsPath,
|
||||
projectEventsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
projectSettingsPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -40,11 +44,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,
|
||||
@@ -55,7 +58,8 @@ import {
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<
|
||||
@@ -103,11 +107,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
showHeaderDivider ? " border-border" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
project={project}
|
||||
/>
|
||||
<ProjectSelector organizations={organizations} project={project} />
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
@@ -115,7 +115,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={project.name || "No project found"}>
|
||||
<SideMenuHeader title={"Project"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
@@ -144,6 +144,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
data-action="triggers"
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Events"
|
||||
icon={CursorArrowRaysIcon}
|
||||
iconColor="text-sky-500"
|
||||
to={projectEventsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
@@ -159,9 +165,16 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={projectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={organization.title}>
|
||||
<SideMenuHeader title={"Organization"}>
|
||||
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
|
||||
<PopoverMenuItem
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
@@ -197,10 +210,17 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
iconColor="text-green-600"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Organization settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="organization-settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
{currentPlan?.subscription?.isPaying === true ? (
|
||||
{currentPlan?.subscription?.isPaying === true && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -256,16 +276,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
@@ -308,11 +326,9 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
|
||||
function ProjectSelector({
|
||||
project,
|
||||
organization,
|
||||
organizations,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
}) {
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
@@ -330,7 +346,7 @@ function ProjectSelector({
|
||||
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
|
||||
>
|
||||
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
|
||||
<span className="truncate">{organization.title ?? "Select an organization"}</span>
|
||||
<span className="truncate">{project.name ?? "Select a project"}</span>
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
@@ -341,23 +357,31 @@ function ProjectSelector({
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{organization.projects.length > 0 ? (
|
||||
organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New project"
|
||||
icon="plus"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -372,6 +396,7 @@ function ProjectSelector({
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const v3Enabled = useV3Enabled();
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
@@ -409,6 +434,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"
|
||||
@@ -421,99 +454,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">
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
FilterableEnvironment,
|
||||
FilterableStatus,
|
||||
RunListSearchSchema,
|
||||
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, from, to } = RunListSearchSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
searchParams.delete(filterType);
|
||||
}
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback((value: FilterableStatus | "ALL") => {
|
||||
handleFilterChange("status", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environment ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph variant="extra-small" className="pl-0.5">
|
||||
All environments
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{environmentKeys.map((env) => (
|
||||
<SelectItem key={env} value={env}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnvironmentLabel environment={{ type: env }} />
|
||||
<Paragraph variant="extra-small">environment</Paragraph>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select name="status" value={status ?? "ALL"} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder="Select status" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph variant="extra-small" className="pl-0.5">
|
||||
All statuses
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{statusKeys.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<FilterStatusIcon status={status} className="h-4 w-4" />
|
||||
<FilterStatusLabel status={status} />
|
||||
</span>
|
||||
}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterStatusLabel({ status }: { status: FilterableStatus }) {
|
||||
return <span className={filterStatusClassNameColor(status)}>{filterStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function FilterStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: FilterableStatus;
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "WAITING":
|
||||
return <ClockIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "QUEUED":
|
||||
return <PauseCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "IN_PROGRESS":
|
||||
return <Spinner className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "TIMEDOUT":
|
||||
return (
|
||||
<ExclamationTriangleIcon className={cn(filterStatusClassNameColor(status), className)} />
|
||||
);
|
||||
case "CANCELED":
|
||||
return <NoSymbolIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "FAILED":
|
||||
return <XCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterStatusTitle(status: FilterableStatus): string {
|
||||
switch (status) {
|
||||
case "QUEUED":
|
||||
return "Queued";
|
||||
case "IN_PROGRESS":
|
||||
return "In progress";
|
||||
case "WAITING":
|
||||
return "Waiting";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
case "CANCELED":
|
||||
return "Canceled";
|
||||
case "TIMEDOUT":
|
||||
return "Timed out";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterStatusClassNameColor(status: FilterableStatus): string {
|
||||
switch (status) {
|
||||
case "QUEUED":
|
||||
return "text-slate-500";
|
||||
case "IN_PROGRESS":
|
||||
return "text-blue-500";
|
||||
case "WAITING":
|
||||
return "text-blue-500";
|
||||
case "COMPLETED":
|
||||
return "text-green-500";
|
||||
case "FAILED":
|
||||
return "text-rose-500";
|
||||
case "CANCELED":
|
||||
return "text-slate-500";
|
||||
case "TIMEDOUT":
|
||||
return "text-amber-300";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { JobRunStatus } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { z } from "zod";
|
||||
|
||||
export function RunStatus({ status }: { status: JobRunStatus }) {
|
||||
return (
|
||||
@@ -127,3 +128,52 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
|
||||
export type Direction = z.infer<typeof DirectionSchema>;
|
||||
|
||||
export const FilterableStatus = z.union([
|
||||
z.literal("QUEUED"),
|
||||
z.literal("IN_PROGRESS"),
|
||||
z.literal("WAITING"),
|
||||
z.literal("COMPLETED"),
|
||||
z.literal("FAILED"),
|
||||
z.literal("TIMEDOUT"),
|
||||
z.literal("CANCELED"),
|
||||
]);
|
||||
export type FilterableStatus = z.infer<typeof FilterableStatus>;
|
||||
|
||||
export const FilterableEnvironment = z.union([
|
||||
z.literal("DEVELOPMENT"),
|
||||
z.literal("STAGING"),
|
||||
z.literal("PRODUCTION"),
|
||||
]);
|
||||
export type FilterableEnvironment = z.infer<typeof FilterableEnvironment>;
|
||||
export const environmentKeys: FilterableEnvironment[] = ["DEVELOPMENT", "STAGING", "PRODUCTION"];
|
||||
|
||||
export const RunListSearchSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
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[]> = {
|
||||
QUEUED: ["QUEUED", "WAITING_TO_EXECUTE", "PENDING", "WAITING_ON_CONNECTIONS"],
|
||||
IN_PROGRESS: ["STARTED", "EXECUTING", "PREPROCESSING"],
|
||||
WAITING: ["WAITING_TO_CONTINUE"],
|
||||
COMPLETED: ["SUCCESS"],
|
||||
FAILED: ["FAILURE", "UNRESOLVED_AUTH", "INVALID_PAYLOAD", "ABORTED"],
|
||||
TIMEDOUT: ["TIMED_OUT"],
|
||||
CANCELED: ["CANCELED"],
|
||||
};
|
||||
|
||||
export const statusKeys: FilterableStatus[] = Object.keys(filterableStatuses) as FilterableStatus[];
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
|
||||
export function useOptimisticLocation() {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
|
||||
if (navigation.state === "idle" || !navigation.location) {
|
||||
return location;
|
||||
}
|
||||
|
||||
return navigation.location;
|
||||
}
|
||||
@@ -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>({
|
||||
|
||||
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function findEnvironmentByApiKey(apiKey: string) {
|
||||
},
|
||||
});
|
||||
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -28,24 +33,10 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
|
||||
},
|
||||
});
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
export async function getEnvironmentForOrganization(organizationSlug: string, slug: string) {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = organization.environments.find((environment) => environment.slug === slug);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ClientEndpoint =
|
||||
state: "configured";
|
||||
id: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
url: string | null;
|
||||
indexWebhookPath: string;
|
||||
latestIndex?: {
|
||||
status: EndpointIndexStatus;
|
||||
@@ -102,6 +102,11 @@ export class EnvironmentsPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
url: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Direction, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
type EventListOptions = {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
direction?: Direction;
|
||||
filterEnvironment?: FilterableEnvironment;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type EventList = Awaited<ReturnType<EventListPresenter["call"]>>;
|
||||
|
||||
export class EventListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
filterEnvironment,
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await this.#prismaClient.eventRecord.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deliverAt: true,
|
||||
deliveredAt: true,
|
||||
isTest: true,
|
||||
createdAt: true,
|
||||
cancelledAt: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
name: {
|
||||
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
|
||||
},
|
||||
projectId: project.id,
|
||||
organizationId: organization.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
|
||||
take: directionMultiplier * (pageSize + 1),
|
||||
//skip the cursor if there is one
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor
|
||||
? {
|
||||
id: cursor,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const hasMore = events.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? events.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = events[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
if (hasMore) {
|
||||
previous = events[1]?.id;
|
||||
next = events[pageSize]?.id;
|
||||
} else {
|
||||
next = events[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const eventsToReturn =
|
||||
direction === "backward" && hasMore
|
||||
? events.slice(1, pageSize + 1)
|
||||
: events.slice(0, pageSize);
|
||||
|
||||
return {
|
||||
events: eventsToReturn.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
deliverAt: event.deliverAt,
|
||||
deliveredAt: event.deliveredAt,
|
||||
createdAt: event.createdAt,
|
||||
cancelledAt: event.cancelledAt,
|
||||
isTest: event.isTest,
|
||||
environment: {
|
||||
type: event.environment.type,
|
||||
slug: event.environment.slug,
|
||||
userId: event.environment.orgMember?.user.id,
|
||||
userName: getUsername(event.environment.orgMember?.user),
|
||||
},
|
||||
runs: event.runs.length,
|
||||
})),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
|
||||
export type Event = NonNullable<Awaited<ReturnType<EventPresenter["call"]>>>;
|
||||
|
||||
export class EventPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
eventId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
eventId: string;
|
||||
}) {
|
||||
// Find the organization that the user is a member of
|
||||
const organization = await this.#prismaClient.organization.findFirstOrThrow({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const event = await this.#prismaClient.eventRecord.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
payload: true,
|
||||
context: true,
|
||||
timestamp: true,
|
||||
deliveredAt: true,
|
||||
},
|
||||
where: {
|
||||
id: eventId,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new Error("Could not find Event");
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
timestamp: event.timestamp,
|
||||
payload: JSON.stringify(event.payload, null, 2),
|
||||
context: JSON.stringify(event.context, null, 2),
|
||||
deliveredAt: event.deliveredAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { z } from "zod";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
import { JobRunStatus } from "@trigger.dev/database";
|
||||
|
||||
export type ProjectJob = Awaited<ReturnType<JobListPresenter["call"]>>[0];
|
||||
|
||||
@@ -43,52 +44,34 @@ export class JobListPresenter {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
integrations: {
|
||||
select: {
|
||||
version: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
integrations: {
|
||||
select: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
triggerLink: true,
|
||||
triggerHelp: true,
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
triggerLink: true,
|
||||
triggerHelp: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 1,
|
||||
},
|
||||
dynamicTriggers: {
|
||||
select: {
|
||||
@@ -115,50 +98,47 @@ export class JobListPresenter {
|
||||
orderBy: [{ title: "asc" }],
|
||||
});
|
||||
|
||||
let latestRuns = [] as {
|
||||
createdAt: Date;
|
||||
status: JobRunStatus;
|
||||
jobId: string;
|
||||
rn: BigInt;
|
||||
}[];
|
||||
|
||||
if (jobs.length > 0) {
|
||||
latestRuns = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
createdAt: Date;
|
||||
status: JobRunStatus;
|
||||
jobId: string;
|
||||
rn: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
"id",
|
||||
"createdAt",
|
||||
"status",
|
||||
"jobId",
|
||||
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
|
||||
FROM
|
||||
"JobRun"
|
||||
WHERE
|
||||
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
|
||||
) t
|
||||
WHERE rn = 1;`;
|
||||
}
|
||||
|
||||
return jobs
|
||||
.map((job) => {
|
||||
//the best alias to select:
|
||||
// 1. Logged-in user dev
|
||||
// 2. Prod
|
||||
// 3. Any other user's dev
|
||||
const sortedAliases = job.aliases.sort((a, b) => {
|
||||
if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.environment.type === "PRODUCTION") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.environment.type === "PRODUCTION") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const alias = sortedAliases.at(0);
|
||||
|
||||
if (!alias) {
|
||||
throw new Error(`No aliases found for job ${job.id}, this should never happen.`);
|
||||
.flatMap((job) => {
|
||||
const version = job.versions.at(0);
|
||||
if (!version) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification);
|
||||
const eventSpecification = EventSpecificationSchema.parse(version.eventSpecification);
|
||||
|
||||
const lastRuns = job.aliases
|
||||
.map((alias) => alias.version.runs.at(0))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
});
|
||||
|
||||
const lastRun = lastRuns.at(0);
|
||||
|
||||
const integrations = alias.version.integrations.map((integration) => ({
|
||||
const integrations = job.integrations.map((integration) => ({
|
||||
key: integration.key,
|
||||
title: integration.integration.slug,
|
||||
icon: integration.integration.definition.icon ?? integration.integration.definition.id,
|
||||
@@ -171,44 +151,41 @@ export class JobListPresenter {
|
||||
properties = [...properties, ...eventSpecification.properties];
|
||||
}
|
||||
|
||||
if (alias.version.properties) {
|
||||
const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties);
|
||||
if (version.properties) {
|
||||
const versionProperties = z.array(DisplayPropertySchema).parse(version.properties);
|
||||
properties = [...properties, ...versionProperties];
|
||||
}
|
||||
|
||||
const environments = job.aliases.map((alias) => ({
|
||||
type: alias.environment.type,
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
}));
|
||||
const latestRun = latestRuns.find((r) => r.jobId === job.id);
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: alias.version.version,
|
||||
status: alias.version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
icon: eventSpecification.icon,
|
||||
source: eventSpecification.source,
|
||||
link: projectSlug
|
||||
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
|
||||
alias.version.triggerLink
|
||||
}`
|
||||
: undefined,
|
||||
return [
|
||||
{
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
icon: eventSpecification.icon,
|
||||
source: eventSpecification.source,
|
||||
link: projectSlug
|
||||
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
|
||||
version.triggerLink
|
||||
}`
|
||||
: undefined,
|
||||
},
|
||||
integrations,
|
||||
hasIntegrationsRequiringAction: integrations.some(
|
||||
(i) => i.setupStatus === "MISSING_FIELDS"
|
||||
),
|
||||
environment: version.environment,
|
||||
lastRun: latestRun,
|
||||
properties,
|
||||
projectSlug: job.project.slug,
|
||||
},
|
||||
integrations,
|
||||
hasIntegrationsRequiringAction: integrations.some(
|
||||
(i) => i.setupStatus === "MISSING_FIELDS"
|
||||
),
|
||||
lastRun,
|
||||
properties,
|
||||
environments,
|
||||
projectSlug: job.project.slug,
|
||||
};
|
||||
];
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,16 @@ export class NewOrganizationPresenter {
|
||||
|
||||
public async call({ userId }: { userId: User["id"] }) {
|
||||
const organizations = await this.#prismaClient.organization.findMany({
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
where: { members: { some: { userId } } },
|
||||
});
|
||||
|
||||
return {
|
||||
hasOrganizations: organizations.length > 0,
|
||||
hasOrganizations: organizations.filter((o) => o.projects.length > 0).length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { estimate } from "@trigger.dev/billing";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export class OrgUsagePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -25,7 +23,7 @@ export class OrgUsagePresenter {
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
// Get count of runs since the start of the current month
|
||||
@@ -108,7 +106,7 @@ export class OrgUsagePresenter {
|
||||
|
||||
const ThirtyDaysAgo = new Date();
|
||||
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
|
||||
ThirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
ThirtyDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const hasConcurrencyData = concurrencyChartRawData.length > 0;
|
||||
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
|
||||
@@ -117,6 +115,13 @@ export class OrgUsagePresenter {
|
||||
concurrencyChartRawData
|
||||
);
|
||||
|
||||
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
|
||||
{ day: Date; runs: BigInt }[]
|
||||
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
|
||||
|
||||
const hasDailyRunsData = dailyRunsRawData.length > 0;
|
||||
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
|
||||
|
||||
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
|
||||
endOfMonth.setDate(endOfMonth.getDate() - 1);
|
||||
const projectedRunsCount = Math.round(
|
||||
@@ -146,12 +151,12 @@ export class OrgUsagePresenter {
|
||||
|
||||
const periodStart = new Date();
|
||||
periodStart.setDate(1);
|
||||
periodStart.setHours(0, 0, 0, 0);
|
||||
periodStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setDate(1);
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
periodEnd.setHours(0, 0, 0, 0);
|
||||
periodEnd.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
return {
|
||||
id: organization.id,
|
||||
@@ -161,6 +166,8 @@ export class OrgUsagePresenter {
|
||||
hasMonthlyRunData,
|
||||
concurrencyData: concurrencyChartRawDataFilledIn,
|
||||
hasConcurrencyData,
|
||||
dailyRunsData: dailyRunsDataFilledIn,
|
||||
hasDailyRunsData,
|
||||
runCostEstimation,
|
||||
projectedRunCostEstimation,
|
||||
periodStart,
|
||||
@@ -224,6 +231,33 @@ function fillInMissingConcurrencyDays(
|
||||
return outputData;
|
||||
}
|
||||
|
||||
function fillInMissingDailyRuns(
|
||||
startDate: Date,
|
||||
days: number,
|
||||
data: Array<{ day: Date; runs: BigInt }>
|
||||
) {
|
||||
const outputData: Array<{ date: Date; runs: number }> = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
|
||||
if (!foundData) {
|
||||
outputData.push({
|
||||
date,
|
||||
runs: 0,
|
||||
});
|
||||
} else {
|
||||
outputData.push({
|
||||
date,
|
||||
runs: Number(foundData.runs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outputData;
|
||||
}
|
||||
|
||||
// Start month will be like 2023-03 and endMonth will be like 2023-10
|
||||
// The result should be an array of months between these two months, including the start and end month
|
||||
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
getCurrentProjectId,
|
||||
setCurrentProjectId,
|
||||
} from "~/services/currentProject.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type Org = Awaited<ReturnType<OrganizationsPresenter["getOrganizations"]>>[number];
|
||||
import { newProjectPath } from "~/utils/pathBuilder";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { match } from "assert";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -16,40 +23,167 @@ export class OrganizationsPresenter {
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
request,
|
||||
projectSlug,
|
||||
request,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string | undefined;
|
||||
request: Request;
|
||||
projectSlug?: string;
|
||||
}) {
|
||||
const organizations = await this.getOrganizations(userId);
|
||||
//first get the project id, this redirects if there's no session
|
||||
const projectId = await this.#getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
});
|
||||
|
||||
const organizations = await this.#getOrganizations(userId);
|
||||
const organization = organizations.find((o) => o.slug === organizationSlug);
|
||||
if (!organization) {
|
||||
logger.info("Not Found: organization", {
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
request,
|
||||
organization,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const project = await this.getProject(organization, projectSlug, request, userId);
|
||||
const projectPresenter = new ProjectPresenter(this.#prismaClient);
|
||||
const project = await projectPresenter.call({
|
||||
id: projectId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects found in organization"
|
||||
);
|
||||
}
|
||||
|
||||
return { organizations, organization, project };
|
||||
}
|
||||
|
||||
async getOrganizations(userId: string) {
|
||||
async #getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
}: {
|
||||
request: Request;
|
||||
projectSlug: string | undefined;
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
}): Promise<string> {
|
||||
const sessionProjectId = await getCurrentProjectId(request);
|
||||
|
||||
//no project in session, let's set one
|
||||
if (!sessionProjectId) {
|
||||
//no session id and no project slug so we need to select the best project
|
||||
if (!projectSlug) {
|
||||
const bestProject = await this.#selectBestProjectForOrganization(
|
||||
organizationSlug,
|
||||
userId,
|
||||
request
|
||||
);
|
||||
const session = await setCurrentProjectId(bestProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
//get all the projects
|
||||
const projects = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
slug: projectSlug,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects in this organization"
|
||||
);
|
||||
}
|
||||
|
||||
//try get the project which matches the URL
|
||||
let matchingProject = projects.find((p) => p.slug === projectSlug);
|
||||
|
||||
//if there's no matching project, just use the most recently updated one
|
||||
if (!matchingProject) {
|
||||
matchingProject = projects[0];
|
||||
}
|
||||
|
||||
//set the session
|
||||
const session = await setCurrentProjectId(matchingProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
if (!projectSlug) {
|
||||
return sessionProjectId;
|
||||
}
|
||||
|
||||
//check session id matches the project slug
|
||||
const project = await prisma.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found in organization", { status: 404 });
|
||||
}
|
||||
|
||||
if (project.id !== sessionProjectId) {
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
return project.id;
|
||||
}
|
||||
|
||||
async #getOrganizations(userId: string) {
|
||||
const orgs = await this.#prismaClient.organization.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
where: { members: { some: { userId } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
runsEnabled: true,
|
||||
projects: {
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
where: { deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: {
|
||||
jobs: {
|
||||
@@ -67,10 +201,10 @@ export class OrganizationsPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
integrations: {
|
||||
where: {
|
||||
setupStatus: "MISSING_FIELDS",
|
||||
@@ -93,38 +227,41 @@ export class OrganizationsPresenter {
|
||||
jobCount: project._count.jobs,
|
||||
})),
|
||||
hasUnconfiguredIntegrations: org._count.integrations > 0,
|
||||
memberCount: org._count.members,
|
||||
runsEnabled: org.runsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getProject(
|
||||
organization: Org,
|
||||
projectSlug: string | undefined,
|
||||
request: Request,
|
||||
userId: string
|
||||
async #selectBestProjectForOrganization(
|
||||
organizationSlug: string,
|
||||
userId: string,
|
||||
request: Request
|
||||
) {
|
||||
const projectPresenter = new ProjectPresenter();
|
||||
const projects = await this.#prismaClient.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
jobs: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (!projectSlug) {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
const orgProject = organization.projects.find((p) => p.id === projectId);
|
||||
if (!orgProject) {
|
||||
logger.info("Not Found: proj 1", {
|
||||
projectId,
|
||||
organization,
|
||||
projectSlug: projectSlug ?? null,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
projectSlug = orgProject.slug;
|
||||
if (projects.length === 0) {
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }), request);
|
||||
}
|
||||
|
||||
const project = await projectPresenter.call({ userId, slug: projectSlug });
|
||||
if (!project) {
|
||||
logger.info("Not Found: proj 2", { projectSlug, organization, project });
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return project;
|
||||
return projects[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class ProjectPresenter {
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
slug,
|
||||
}: Pick<Project, "slug"> & {
|
||||
id,
|
||||
}: Pick<Project, "id"> & {
|
||||
userId: User["id"];
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
@@ -23,67 +23,7 @@ export class ProjectPresenter {
|
||||
organizationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
jobs: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
integrations: {
|
||||
select: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
},
|
||||
dynamicTriggers: {
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ title: "asc" }],
|
||||
},
|
||||
deletedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
sources: {
|
||||
@@ -100,19 +40,6 @@ export class ProjectPresenter {
|
||||
httpEndpoints: true,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
integrations: {
|
||||
where: {
|
||||
setupStatus: "MISSING_FIELDS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -127,7 +54,7 @@ export class ProjectPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { slug, organization: { members: { some: { userId } } } },
|
||||
where: { id, deletedAt: null, organization: { members: { some: { userId } } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
@@ -141,6 +68,7 @@ export class ProjectPresenter {
|
||||
organizationId: project.organizationId,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
deletedAt: project.deletedAt,
|
||||
hasInactiveExternalTriggers: project._count.sources > 0,
|
||||
jobCount: project._count.jobs,
|
||||
httpEndpointCount: project._count.httpEndpoints,
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Direction,
|
||||
FilterableEnvironment,
|
||||
FilterableStatus,
|
||||
filterableStatuses,
|
||||
} from "~/components/runs/RunStatuses";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { DirectionSchema } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
export type Direction = z.infer<typeof DirectionSchema>;
|
||||
|
||||
type RunListOptions = {
|
||||
userId: string;
|
||||
eventId?: string;
|
||||
jobSlug?: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
direction?: Direction;
|
||||
filterStatus?: JobRunStatus[];
|
||||
filterEnvironment?: RuntimeEnvironmentType;
|
||||
filterStatus?: FilterableStatus;
|
||||
filterEnvironment?: FilterableEnvironment;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
@@ -31,6 +36,7 @@ export class RunListPresenter {
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
eventId,
|
||||
jobSlug,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
@@ -39,11 +45,18 @@ export class RunListPresenter {
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
from,
|
||||
to,
|
||||
}: RunListOptions) {
|
||||
const filterStatuses = filterStatus ? filterableStatuses[filterStatus] : undefined;
|
||||
|
||||
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 } },
|
||||
@@ -52,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: {
|
||||
@@ -74,6 +83,10 @@ export class RunListPresenter {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const event = eventId
|
||||
? await this.#prismaClient.eventRecord.findUnique({ where: { id: eventId } })
|
||||
: undefined;
|
||||
|
||||
const runs = await this.#prismaClient.jobRun.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -114,14 +127,16 @@ export class RunListPresenter {
|
||||
},
|
||||
},
|
||||
where: {
|
||||
eventId: event?.id,
|
||||
jobId: job?.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentId: {
|
||||
in: environments.map((environment) => environment.id),
|
||||
},
|
||||
status: filterStatus ? { in: filterStatus } : undefined,
|
||||
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
|
||||
|
||||
@@ -14,7 +14,7 @@ export class SelectBestProjectPresenter {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
if (projectId) {
|
||||
const project = await this.#prismaClient.project.findUnique({
|
||||
where: { id: projectId, organization: { members: { some: { userId } } } },
|
||||
where: { id: projectId, deletedAt: null, organization: { members: { some: { userId } } } },
|
||||
include: { organization: true },
|
||||
});
|
||||
if (project) {
|
||||
@@ -28,6 +28,7 @@ export class SelectBestProjectPresenter {
|
||||
organization: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TriggerSource, User } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { Direction, RunList, RunListPresenter } from "./RunListPresenter.server";
|
||||
import { RunList, RunListPresenter } from "./RunListPresenter.server";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
|
||||
export class TriggerSourcePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Direction } from "./RunListPresenter.server";
|
||||
|
||||
type RunListOptions = {
|
||||
userId: string;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { User, Webhook } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { Direction } from "./RunListPresenter.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { WebhookDeliveryListPresenter } from "./WebhookDeliveryListPresenter.server";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
|
||||
export class WebhookDeliveryPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -2,8 +2,9 @@ import { User, Webhook } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { Direction, RunListPresenter } from "./RunListPresenter.server";
|
||||
import { RunListPresenter } from "./RunListPresenter.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
|
||||
export class WebhookSourcePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -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={
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EventParamSchema, projectEventsPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { EventDetail } from "~/components/event/EventDetail";
|
||||
import { EventPresenter } from "~/presenters/EventPresenter.server";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { Fragment } from "react";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
|
||||
import { RunsTable } from "~/components/runs/RunsTable";
|
||||
import { RunsFilters } from "~/components/runs/RunFilters";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { eventParam, projectParam, organizationSlug } = EventParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = RunListSearchSchema.parse(s);
|
||||
|
||||
const presenter = new EventPresenter();
|
||||
try {
|
||||
const event = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
eventId: eventParam,
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const runsPresenter = new RunListPresenter();
|
||||
|
||||
const list = await runsPresenter.call({
|
||||
userId,
|
||||
filterEnvironment: searchParams.environment,
|
||||
filterStatus: searchParams.status,
|
||||
eventId: event.id,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
return typedjson({ event, list });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new Response(e instanceof Error ? e.message : JSON.stringify(e), { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => {
|
||||
const eventData = useTypedMatchData<typeof loader>(match);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{eventData && eventData.event && (
|
||||
<BreadcrumbLink to={match.pathname} title={eventData.event.name} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { event, list } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle
|
||||
title={event.name}
|
||||
backButton={{
|
||||
to: projectEventsPath(organization, project),
|
||||
text: "Events",
|
||||
}}
|
||||
/>
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full grid-cols-2">
|
||||
<div className="overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<EventDetail event={event} />
|
||||
</div>
|
||||
|
||||
<div className="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 />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={false}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
showJob={true}
|
||||
runsParentPath={projectPath(organization, project)}
|
||||
currentUser={user}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
PageButtons,
|
||||
PageDescription,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { EventsTable } from "~/components/events/EventsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { EventListPresenter } from "~/presenters/EventListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, docsPath, projectPath, trimTrailingSlash } from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { EventListSearchSchema } from "~/components/events/EventStatuses";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { EventsFilters } from "~/components/events/EventsFilters";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = EventListSearchSchema.parse(s);
|
||||
|
||||
const presenter = new EventListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
filterEnvironment: searchParams.environment,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
list,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { list } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${project.name} events`} />
|
||||
<PageButtons>
|
||||
<LinkButton
|
||||
LeadingIcon={"docs"}
|
||||
to={docsPath("documentation/concepts/triggers/events")}
|
||||
variant="secondary/small"
|
||||
>
|
||||
Event documentation
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageDescription>All events in this project</PageDescription>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<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">
|
||||
<EventsFilters />
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
<EventsTable
|
||||
total={list.events.length}
|
||||
hasFilters={false}
|
||||
events={list.events}
|
||||
isLoading={isLoading}
|
||||
eventsParentPath={projectPath(organization, project)}
|
||||
currentUser={user}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { Handle } from "~/utils/handle";
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Events" />,
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <Outlet />;
|
||||
}
|
||||
+9
-9
@@ -1,16 +1,16 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Direction, RunList } from "~/presenters/RunListPresenter.server";
|
||||
import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function ListPagination({
|
||||
list,
|
||||
className,
|
||||
}: {
|
||||
list: RunList | WebhookDeliveryList;
|
||||
className?: string;
|
||||
}) {
|
||||
type List = {
|
||||
pagination: {
|
||||
next?: string | undefined;
|
||||
previous?: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function ListPagination({ list, className }: { list: List; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<PreviousButton cursor={list.pagination.previous} />
|
||||
|
||||
+13
-10
@@ -20,13 +20,8 @@ import {
|
||||
organizationIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "./ListPagination";
|
||||
|
||||
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
|
||||
|
||||
export const RunListSearchSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: DirectionSchema.optional(),
|
||||
});
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { RunsFilters } from "~/components/runs/RunFilters";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -39,11 +34,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const presenter = new RunListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
filterEnvironment: searchParams.environment,
|
||||
filterStatus: searchParams.status,
|
||||
jobSlug: jobParam,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
@@ -73,10 +72,14 @@ export default function Page() {
|
||||
{(open) => (
|
||||
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-end gap-x-2">
|
||||
<HelpTrigger title="How do I run my Job?" />
|
||||
<ListPagination list={list} />
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
<RunsFilters />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<HelpTrigger title="How do I run my Job?" />
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={false}
|
||||
|
||||
+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";
|
||||
|
||||
|
||||
+55
-294
@@ -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";
|
||||
@@ -18,67 +18,10 @@ import { RunListPresenter } from "~/presenters/RunListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { ChartBarIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { RunsFilters } from "~/components/runs/RunFilters";
|
||||
import { Suspense } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { NoSymbolIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
// Filter -> status types
|
||||
const ExtendedJobRunStatus = {
|
||||
ALL: "ALL" as const,
|
||||
...JobRunStatus,
|
||||
} as const;
|
||||
type ExtendedJobRunStatusKey = keyof typeof ExtendedJobRunStatus;
|
||||
|
||||
type FilterableStatus =
|
||||
| "ALL"
|
||||
| "QUEUED"
|
||||
| "IN_PROGRESS"
|
||||
| "WAITING"
|
||||
| "COMPLETED"
|
||||
| "FAILED"
|
||||
| "CANCELED"
|
||||
| "TIMEDOUT";
|
||||
|
||||
const filterableStatuses: Record<FilterableStatus, ExtendedJobRunStatusKey[]> = {
|
||||
ALL: ["ALL"],
|
||||
QUEUED: ["QUEUED", "WAITING_TO_EXECUTE", "PENDING", "WAITING_ON_CONNECTIONS"],
|
||||
IN_PROGRESS: ["STARTED", "EXECUTING", "PREPROCESSING"],
|
||||
WAITING: ["WAITING_TO_CONTINUE"],
|
||||
COMPLETED: ["SUCCESS"],
|
||||
FAILED: ["FAILURE", "UNRESOLVED_AUTH", "INVALID_PAYLOAD", "ABORTED"],
|
||||
TIMEDOUT: ["TIMED_OUT"],
|
||||
CANCELED: ["CANCELED"],
|
||||
};
|
||||
|
||||
const statusKeys: FilterableStatus[] = Object.keys(filterableStatuses) as FilterableStatus[];
|
||||
|
||||
// Filter -> Environment types
|
||||
const ExtendedRuntimeEnvironment = {
|
||||
ALL: "ALL" as const,
|
||||
...RuntimeEnvironmentType,
|
||||
} as const;
|
||||
type ExtendedRuntimeEnvironmentType = keyof typeof ExtendedRuntimeEnvironment;
|
||||
const environmentKeys: ExtendedRuntimeEnvironmentType[] = Object.keys(
|
||||
ExtendedRuntimeEnvironment
|
||||
) as ExtendedRuntimeEnvironmentType[];
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -88,69 +31,33 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = RunListSearchSchema.parse(s);
|
||||
|
||||
const status = url.searchParams.get("status");
|
||||
const environment = url.searchParams.get("environment");
|
||||
|
||||
let filterStatus: JobRunStatus[] | undefined;
|
||||
if (status && status !== "ALL") {
|
||||
if (filterableStatuses.hasOwnProperty(status)) {
|
||||
filterStatus = filterableStatuses[status as FilterableStatus] as JobRunStatus[];
|
||||
}
|
||||
}
|
||||
|
||||
let filterEnvironment: RuntimeEnvironmentType | undefined;
|
||||
if (environment && environment !== "ALL") {
|
||||
if (environmentKeys.includes(environment)) {
|
||||
filterEnvironment = environment as RuntimeEnvironmentType;
|
||||
}
|
||||
}
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
|
||||
const list = await presenter.call({
|
||||
const list = presenter.call({
|
||||
userId,
|
||||
filterEnvironment: filterEnvironment,
|
||||
filterStatus: filterStatus,
|
||||
filterEnvironment: searchParams.environment,
|
||||
filterStatus: searchParams.status,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
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();
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const url = new URLSearchParams(location.search);
|
||||
|
||||
const selectedEnvironment = url.get("environment") || ExtendedRuntimeEnvironment.ALL;
|
||||
const selectedStatus = url.get("status") || ExtendedJobRunStatus.ALL;
|
||||
|
||||
const handleFilterChange = (filterType: string, value: string) => {
|
||||
url.set(filterType, value);
|
||||
url.delete("cursor");
|
||||
url.delete("direction");
|
||||
navigate(`${location.pathname}?${url.toString()}`);
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: FilterableStatus) => {
|
||||
handleFilterChange("status", value);
|
||||
};
|
||||
|
||||
const handleEnvironmentChange = (value: string) => {
|
||||
handleFilterChange("environment", value);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -173,198 +80,52 @@ export default function Page() {
|
||||
<PageBody scrollable={false}>
|
||||
<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">
|
||||
<div className="flex flex-row justify-between gap-x-2">
|
||||
{/* environment filter */}
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={selectedEnvironment}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder="Select environment" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{environmentKeys.map((env) => (
|
||||
<SelectItem key={env} value={env}>
|
||||
<div className="flex gap-x-2">
|
||||
{env !== "ALL" && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
|
||||
filterEnvironmentColorClassName(env)
|
||||
)}
|
||||
>
|
||||
{filterEnvironmentTitle(env)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 items-center justify-center pl-1 text-xxs font-medium uppercase tracking-wider text-dimmed"
|
||||
)}
|
||||
>
|
||||
{env === "ALL" ? env + " Environments" : env}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
{/* status filter */}
|
||||
<SelectGroup>
|
||||
<Select name="status" value={selectedStatus} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder="Select environment" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusKeys.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{
|
||||
<span className="flex items-center gap-1 text-xxs font-medium uppercase tracking-wider">
|
||||
<FilterStatusIcon status={status} className="h-4 w-4" />
|
||||
<FilterStatusLabel status={status} />
|
||||
</span>
|
||||
}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
</div>
|
||||
|
||||
<ListPagination list={list} />
|
||||
<RunsFilters />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function filterEnvironmentTitle(environment: ExtendedRuntimeEnvironmentType) {
|
||||
switch (environment) {
|
||||
case "ALL":
|
||||
return "All";
|
||||
case "PRODUCTION":
|
||||
return "Prod";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Dev";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
function filterEnvironmentColorClassName(environment: ExtendedRuntimeEnvironmentType) {
|
||||
switch (environment) {
|
||||
case "ALL":
|
||||
return "bg-indigo-500";
|
||||
case "PRODUCTION":
|
||||
return "bg-green-500";
|
||||
case "STAGING":
|
||||
return "bg-amber-500";
|
||||
case "DEVELOPMENT":
|
||||
return "bg-pink-500";
|
||||
case "PREVIEW":
|
||||
return "bg-yellow-500";
|
||||
}
|
||||
}
|
||||
|
||||
export function FilterStatusLabel({ status }: { status: FilterableStatus }) {
|
||||
return <span className={filterStatusClassNameColor(status)}>{filterStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function FilterStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: FilterableStatus;
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "ALL":
|
||||
return <span className="w-[0.0625rem]"></span>;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "WAITING":
|
||||
return <ClockIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "QUEUED":
|
||||
return <PauseCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "IN_PROGRESS":
|
||||
return <Spinner className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "TIMEDOUT":
|
||||
return (
|
||||
<ExclamationTriangleIcon className={cn(filterStatusClassNameColor(status), className)} />
|
||||
);
|
||||
case "CANCELED":
|
||||
return <NoSymbolIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "FAILED":
|
||||
return <XCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterStatusTitle(status: FilterableStatus): string {
|
||||
switch (status) {
|
||||
case "ALL":
|
||||
return "All Status";
|
||||
case "QUEUED":
|
||||
return "Queued";
|
||||
case "IN_PROGRESS":
|
||||
return "In progress";
|
||||
case "WAITING":
|
||||
return "Waiting";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
case "CANCELED":
|
||||
return "Canceled";
|
||||
case "TIMEDOUT":
|
||||
return "Timed out";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterStatusClassNameColor(status: FilterableStatus): string {
|
||||
switch (status) {
|
||||
case "ALL":
|
||||
return "text-dimmed";
|
||||
case "QUEUED":
|
||||
return "text-slate-500";
|
||||
case "IN_PROGRESS":
|
||||
return "text-blue-500";
|
||||
case "WAITING":
|
||||
return "text-blue-500";
|
||||
case "COMPLETED":
|
||||
return "text-green-500";
|
||||
case "FAILED":
|
||||
return "text-rose-500";
|
||||
case "CANCELED":
|
||||
return "text-slate-500";
|
||||
case "TIMEDOUT":
|
||||
return "text-amber-300";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
} from "~/services/currentProject.server";
|
||||
import { DeleteProjectService } from "~/services/deleteProject.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
await prisma.project.update({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
name: submission.value.projectName,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
projectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const deleteProjectService = new DeleteProjectService();
|
||||
try {
|
||||
await deleteProjectService.call({ projectSlug: projectParam, userId });
|
||||
|
||||
//we need to clear the project from the session
|
||||
const removeProjectIdSession = await clearCurrentProjectId(request);
|
||||
return redirect(organizationPath({ slug: organizationSlug }), {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession) },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.error("Project could not be deleted", {
|
||||
error: error instanceof Error ? error.message : JSON.stringify(error),
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const project = useProject();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${project.name} project settings`} />
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Form method="post" {...renameForm.props} className="max-w-md">
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Rename your project</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Your project name"
|
||||
icon="folder"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
disabled={isRenameLoading}
|
||||
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
|
||||
>
|
||||
Rename project
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<Form
|
||||
method="post"
|
||||
{...deleteForm.props}
|
||||
className="max-w-md rounded-sm border border-rose-500/40"
|
||||
>
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<Fieldset className="p-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...conform.input(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon="warning"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
Delete project
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+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,
|
||||
});
|
||||
|
||||
+14
-16
@@ -1,11 +1,16 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
@@ -18,36 +23,29 @@ import {
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { RunsTable } from "~/components/runs/RunsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
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";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
rootPath,
|
||||
projectTriggersPath,
|
||||
externalTriggerPath,
|
||||
externalTriggerRunsParentPath,
|
||||
projectTriggersPath,
|
||||
trimTrailingSlash,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { z } from "zod";
|
||||
import { ActivateSourceService } from "~/services/sources/activateSource.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
|
||||
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);
|
||||
@@ -56,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,
|
||||
|
||||
+13
-18
@@ -1,40 +1,35 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { RunsTable } from "~/components/runs/RunsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
projectTriggersPath,
|
||||
externalTriggerPath,
|
||||
projectWebhookTriggersPath,
|
||||
trimTrailingSlash,
|
||||
webhookTriggerRunsParentPath,
|
||||
projectWebhookTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { z } from "zod";
|
||||
import { ActivateSourceService } from "~/services/sources/activateSource.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
|
||||
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);
|
||||
@@ -43,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,
|
||||
|
||||
+6
-6
@@ -5,10 +5,13 @@ import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
@@ -19,12 +22,9 @@ import {
|
||||
webhookTriggerPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
|
||||
import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable";
|
||||
|
||||
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,
|
||||
|
||||
+6
-11
@@ -2,7 +2,6 @@ import { Outlet } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import {
|
||||
PageHeader,
|
||||
PageInfoGroup,
|
||||
@@ -12,20 +11,20 @@ import {
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
projectWebhookTriggersPath,
|
||||
webhookDeliveryPath,
|
||||
webhookTriggerPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
|
||||
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);
|
||||
@@ -34,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,
|
||||
@@ -77,11 +76,7 @@ export default function Page() {
|
||||
value={trigger.integration.slug}
|
||||
to={trigger.integrationLink}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon="webhook"
|
||||
label="HTTP Endpoint"
|
||||
to={trigger.httpEndpointLink}
|
||||
/>
|
||||
<PageInfoProperty icon="webhook" label="HTTP Endpoint" to={trigger.httpEndpointLink} />
|
||||
</PageInfoGroup>
|
||||
</PageInfoRow>
|
||||
<PageTabs
|
||||
|
||||
+5
-55
@@ -1,67 +1,17 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { organizationMatchId, useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { ProjectPresenter } from "~/presenters/ProjectPresenter.server";
|
||||
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam } = params;
|
||||
invariant(projectParam, "projectParam not found");
|
||||
|
||||
try {
|
||||
const presenter = new ProjectPresenter();
|
||||
|
||||
const project = await presenter.call({
|
||||
userId,
|
||||
slug: projectParam,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Not Found", {
|
||||
status: 404,
|
||||
statusText: `Project ${projectParam} not found in your Organization.`,
|
||||
});
|
||||
}
|
||||
|
||||
telemetry.project.identify({ project });
|
||||
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
|
||||
return typedjson(
|
||||
{
|
||||
project,
|
||||
},
|
||||
{
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
import { loader as orgLoader } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => {
|
||||
const data = useTypedMatchData<typeof loader>(match);
|
||||
breadcrumb: (match, matches) => {
|
||||
const orgMatch = matches.find((m) => m.id === organizationMatchId);
|
||||
const data = useTypedMatchData<typeof orgLoader>(orgMatch);
|
||||
return <BreadcrumbLink to={match.pathname} title={data?.project.name ?? "Project"} />;
|
||||
},
|
||||
scripts: (match) => [
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { r } from "tar";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
} from "~/services/currentProject.server";
|
||||
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
organizationName: z
|
||||
.string()
|
||||
.min(3, "Organization name must have at least 3 characters")
|
||||
.max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
organizationSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, organizationSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${organizationSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = params;
|
||||
if (!organizationSlug) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === organizationSlug, organizationSlug };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: submission.value.organizationName,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Organization renamed to ${submission.value.organizationName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const deleteOrganizationService = new DeleteOrganizationService();
|
||||
try {
|
||||
await deleteOrganizationService.call({ organizationSlug, userId, request });
|
||||
|
||||
//we need to clear the project from the session
|
||||
const removeProjectIdSession = await clearCurrentProjectId(request);
|
||||
return redirect(rootPath(), {
|
||||
headers: {
|
||||
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
logger.error("Organization could not be deleted", {
|
||||
error: errorMessage,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
errorMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [renameForm, { organizationName }] = useForm({
|
||||
id: "rename-organization",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteForm, { organizationSlug }] = useForm({
|
||||
id: "delete-organization",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({
|
||||
isMatch: slug === organization.slug,
|
||||
organizationSlug: organization.slug,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${organization.title} organization settings`} />
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Form method="post" {...renameForm.props} className="max-w-md">
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={organizationName.id}>Rename your organization</Label>
|
||||
<Input
|
||||
{...conform.input(organizationName, { type: "text" })}
|
||||
defaultValue={organization.title}
|
||||
placeholder="Your organization name"
|
||||
icon="folder"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={organizationName.errorId}>{organizationName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
disabled={isRenameLoading}
|
||||
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
|
||||
>
|
||||
Rename organization
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<Form
|
||||
method="post"
|
||||
{...deleteForm.props}
|
||||
className="max-w-md rounded-sm border border-rose-500/40"
|
||||
>
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<Fieldset className="p-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor={organizationSlug.id}>Delete organization</Label>
|
||||
<Input
|
||||
{...conform.input(organizationSlug, { type: "text" })}
|
||||
placeholder="Your organization slug"
|
||||
icon="warning"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={organizationSlug.errorId}>{organizationSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Organization slug
|
||||
<InlineCode variant="extra-small">{organization.slug}</InlineCode> and then
|
||||
press Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
Delete organization
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, UIMatch } from "@remix-run/react";
|
||||
import { Outlet, ShouldRevalidateFunction, UIMatch } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
@@ -9,11 +9,10 @@ import { PageNavigationIndicator } from "~/components/navigation/PageNavigationI
|
||||
import { SideMenu } from "~/components/navigation/SideMenu";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
@@ -48,6 +47,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
});
|
||||
|
||||
telemetry.organization.identify({ organization });
|
||||
telemetry.project.identify({ project });
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
const billingPresenter = new BillingService(isManagedCloud);
|
||||
@@ -56,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return typedjson({
|
||||
organizations,
|
||||
organization,
|
||||
currentProject: project,
|
||||
project,
|
||||
isImpersonating: !!impersonationId,
|
||||
currentPlan,
|
||||
});
|
||||
@@ -72,13 +72,10 @@ export const handle: Handle = {
|
||||
};
|
||||
|
||||
export default function Organization() {
|
||||
const { organization, currentProject, organizations, isImpersonating } =
|
||||
const { organization, project, organizations, isImpersonating } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
//the side menu won't change projects when using the switcher unless we use the hook (on project pages)
|
||||
const project = useOptionalProject() ?? currentProject;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
|
||||
@@ -111,3 +108,23 @@ export function ErrorBoundary() {
|
||||
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
|
||||
);
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
defaultShouldRevalidate,
|
||||
currentParams,
|
||||
nextParams,
|
||||
}) => {
|
||||
const current = ParamsSchema.safeParse(currentParams);
|
||||
const next = ParamsSchema.safeParse(nextParams);
|
||||
|
||||
if (current.success && next.success) {
|
||||
if (current.data.organizationSlug !== next.data.organizationSlug) {
|
||||
return true;
|
||||
}
|
||||
if (current.data.projectParam !== next.data.projectParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
+51
-9
@@ -1,12 +1,14 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import type { ActionFunction } from "@remix-run/node";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
@@ -14,11 +16,44 @@ import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
_count: {
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
organization: {
|
||||
id: organization.id,
|
||||
title: organization.title,
|
||||
slug: organizationSlug,
|
||||
projectsCount: organization._count.projects,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
@@ -54,7 +89,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function NewOrganizationPage() {
|
||||
const organization = useOrganization();
|
||||
const { organization } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { projectName }] = useForm({
|
||||
@@ -71,10 +106,15 @@ export default function NewOrganizationPage() {
|
||||
<div>
|
||||
<FormTitle
|
||||
LeadingIcon="folder"
|
||||
title="Create a new Project"
|
||||
description="Create a new Project to help you organize the Jobs you create."
|
||||
title="Create a new project"
|
||||
description={`This will create a new project in your "${organization.title}" organization. `}
|
||||
/>
|
||||
<Form method="post" {...form.props}>
|
||||
{organization.projectsCount === 0 && (
|
||||
<Callout variant="info" className="mb-4">
|
||||
Organizations require at least one project, please create one to continue.
|
||||
</Callout>
|
||||
)}
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
@@ -93,9 +133,11 @@ export default function NewOrganizationPage() {
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
organization.projectsCount > 0 ? (
|
||||
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
@@ -41,10 +41,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
const orgsPresenter = new OrganizationsPresenter();
|
||||
const { organizations, organization, project } = await orgsPresenter.call({
|
||||
const { project } = await orgsPresenter.call({
|
||||
userId,
|
||||
request,
|
||||
organizationSlug,
|
||||
projectSlug: undefined,
|
||||
});
|
||||
|
||||
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
|
||||
@@ -52,7 +53,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
|
||||
const project = useOptionalProject();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
|
||||
@@ -63,7 +63,6 @@ export default function ChoosePlanPage() {
|
||||
showActionText={false}
|
||||
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
|
||||
/>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="tertiary/small" LeadingIcon={ChartBarIcon} leadingIconClassName="px-0">
|
||||
|
||||
@@ -26,6 +26,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
userId,
|
||||
request,
|
||||
organizationSlug,
|
||||
projectSlug: undefined,
|
||||
});
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parse } from "@conform-to/zod";
|
||||
import { RadioGroup } from "@radix-ui/react-radio-group";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
@@ -23,7 +23,7 @@ import { createOrganization } from "~/models/organization.server";
|
||||
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
|
||||
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { plansPath, projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
|
||||
import { projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(3).max(50),
|
||||
@@ -86,6 +86,7 @@ export default function NewOrganizationPage() {
|
||||
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [form, { orgName, projectName }] = useForm({
|
||||
id: "create-organization",
|
||||
@@ -95,8 +96,11 @@ export default function NewOrganizationPage() {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
shouldValidate: "onSubmit",
|
||||
});
|
||||
|
||||
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
|
||||
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
<FormTitle LeadingIcon="organization" title="Create an Organization" />
|
||||
@@ -161,7 +165,12 @@ export default function NewOrganizationPage() {
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
TrailingIcon="arrow-right"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -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 onboarding emails"
|
||||
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 { DeleteEndpointService } from "~/services/endpoints/deleteEndpointService";
|
||||
import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -8,21 +10,43 @@ const ParamsSchema = z.object({
|
||||
endpointParam: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ params }: ActionFunctionArgs) {
|
||||
const { endpointParam } = ParamsSchema.parse(params);
|
||||
const BodySchema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("refresh") }),
|
||||
z.object({ action: z.literal("delete") }),
|
||||
]);
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
if (request.method !== "POST") {
|
||||
throw new Response(null, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new IndexEndpointService();
|
||||
await service.call(endpointParam, "MANUAL");
|
||||
const { endpointParam } = ParamsSchema.parse(params);
|
||||
const form = await request.formData();
|
||||
const formObject = Object.fromEntries(form.entries());
|
||||
const { action } = BodySchema.parse(formObject);
|
||||
|
||||
// Enqueue the endpoint to be probed in 10 seconds
|
||||
await workerQueue.enqueue(
|
||||
"probeEndpoint",
|
||||
{ id: endpointParam },
|
||||
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
|
||||
);
|
||||
switch (action) {
|
||||
case "refresh": {
|
||||
const service = new IndexEndpointService();
|
||||
await service.call(endpointParam, "MANUAL");
|
||||
|
||||
return json({ success: true });
|
||||
// Enqueue the endpoint to be probed in 10 seconds
|
||||
await workerQueue.enqueue(
|
||||
"probeEndpoint",
|
||||
{ id: endpointParam },
|
||||
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
|
||||
);
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
case "delete": {
|
||||
const service = new DeleteEndpointService();
|
||||
await service.call(endpointParam, userId);
|
||||
return json({ success: true });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return json({ success: false, error: e }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActionFunction } from "@remix-run/node";
|
||||
import { ActionFunction, LoaderFunction, LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
@@ -14,7 +15,90 @@ const ParamSchema = z.object({
|
||||
jobId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { jobId } = ParamSchema.parse(params);
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
version: true,
|
||||
status: true,
|
||||
concurrencyLimit: true,
|
||||
concurrencyLimitGroup: {
|
||||
select: {
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: jobId,
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environments = job.aliases.map((alias) => ({
|
||||
type: alias.environment.type,
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
concurrencyLimit: alias.version.concurrencyLimit,
|
||||
concurrencyLimitGroup: alias.version.concurrencyLimitGroup,
|
||||
}));
|
||||
|
||||
return typedjson({
|
||||
environments,
|
||||
});
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
if (request.method.toUpperCase() !== "DELETE") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const { jobId } = ParamSchema.parse(params);
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ContinueRunService } from "~/services/runs/continueRun.server";
|
||||
import { ReRunService } from "~/services/runs/reRun.server";
|
||||
import { rootPath, runPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const schema = z.object({
|
||||
successRedirect: z.string(),
|
||||
failureRedirect: z.string(),
|
||||
});
|
||||
|
||||
const ParamSchema = z.object({
|
||||
@@ -20,7 +26,11 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
return redirectWithErrorMessage(
|
||||
rootPath(),
|
||||
request,
|
||||
submission.error ? JSON.stringify(submission.error) : "Invalid form"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -29,7 +39,11 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
const run = await rerunService.call({ runId });
|
||||
|
||||
if (!run) {
|
||||
return redirectBackWithErrorMessage(request, "Unable to retry run");
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.failureRedirect,
|
||||
request,
|
||||
"Unable to retry run"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
@@ -48,6 +62,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.failureRedirect,
|
||||
request,
|
||||
error instanceof Error ? error.message : JSON.stringify(error)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
|
||||
import { inspect } from "util";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import crypto from "node:crypto";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export const ParamsSchema = z.object({
|
||||
userId: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { userId, token } = ParamsSchema.parse(params);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return typedjson({
|
||||
success: false as const,
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
//check that the token is valid for the userId
|
||||
const hashedUserId = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${userId}-${env.MAGIC_LINK_SECRET}`)
|
||||
.digest("hex");
|
||||
if (hashedUserId !== token) {
|
||||
return typedjson({
|
||||
success: false as const,
|
||||
message: "This unsubscribe link was invalid so we can't unsubscribe you.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { marketingEmails: false },
|
||||
});
|
||||
|
||||
return typedjson({ success: true as const, email: user.email });
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : JSON.stringify(e);
|
||||
return typedjson({ success: false as const, message: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const result = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
{result.success ? (
|
||||
<div>
|
||||
<FormTitle LeadingIcon="envelope" title="Unsubscribed" />
|
||||
<Paragraph spacing>
|
||||
You have unsubscribed from onboarding emails, {result.email}.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/medium" to={rootPath()}>
|
||||
Dashboard
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FormTitle LeadingIcon="envelope" title="Unsubscribe failed" />
|
||||
<Paragraph spacing>{result.message}</Paragraph>
|
||||
<Paragraph spacing>
|
||||
If you believe this is a bug, please{" "}
|
||||
<TextLink href="https://trigger.dev/contact">contact support</TextLink>.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/medium" to={rootPath()}>
|
||||
Dashboard
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
@@ -31,3 +31,9 @@ export async function setCurrentProjectId(id: string, request: Request) {
|
||||
session.set("currentProjectId", id);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function clearCurrentProjectId(request: Request) {
|
||||
const session = await getCurrentProjectSession(request);
|
||||
session.unset("currentProjectId");
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DateFormatter } from "@internationalized/date";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { BillingService } from "./billing.server";
|
||||
import { DeleteProjectService } from "./deleteProject.server";
|
||||
|
||||
export class DeleteOrganizationService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
organizationSlug,
|
||||
userId,
|
||||
request,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
request: Request;
|
||||
}) {
|
||||
const organization = await this.#prismaClient.organization.findFirst({
|
||||
include: {
|
||||
projects: true,
|
||||
members: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId: userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
if (organization.deletedAt) {
|
||||
throw new Error("Organization already deleted");
|
||||
}
|
||||
|
||||
//check if they have an active subscription
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
const billingPresenter = new BillingService(isManagedCloud);
|
||||
const currentPlan = await billingPresenter.currentPlan(organization.id);
|
||||
|
||||
if (currentPlan && currentPlan.subscription && currentPlan.subscription.isPaying) {
|
||||
//they've cancelled and that date hasn't passed yet
|
||||
if (
|
||||
currentPlan.subscription.canceledAt &&
|
||||
new Date(currentPlan.subscription.canceledAt) > new Date()
|
||||
) {
|
||||
//a dateformatter that produces results like "Jan 1 2024"
|
||||
const dateFormatter = new DateFormatter("en-us", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
throw new Error(
|
||||
`This Organization has a canceled subscription. You can delete it when the cancelation date (${dateFormatter.format(
|
||||
new Date(currentPlan.subscription.canceledAt)
|
||||
)}) is in the past.`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("You can't delete an Organization that has an active subscription");
|
||||
}
|
||||
|
||||
// loop through the projects and delete them
|
||||
const projectDeleteService = new DeleteProjectService();
|
||||
for (const project of organization.projects) {
|
||||
await projectDeleteService.call({ projectId: project.id, userId });
|
||||
}
|
||||
|
||||
//set all the integrations to disabled
|
||||
await this.#prismaClient.integrationConnection.updateMany({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
//mark the organization as deleted
|
||||
await this.#prismaClient.organization.update({
|
||||
where: {
|
||||
id: organization.id,
|
||||
},
|
||||
data: {
|
||||
runsEnabled: false,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user