chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { Outlet, type ShouldRevalidateFunction, type UIMatch } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
|
||||
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { canManageBillingLimits } from "~/services/routeBuilders/permissions.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { organizationPath } from "~/utils/pathBuilder";
|
||||
import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route";
|
||||
import { isBillingLimitSettingsFormSubmission } from "../_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
projectParam: z.string().optional(),
|
||||
envParam: z.string().optional(),
|
||||
});
|
||||
|
||||
export function useCurrentPlan(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof loader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
|
||||
return data?.currentPlan;
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (params) => {
|
||||
const { currentParams, nextParams } = params;
|
||||
|
||||
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;
|
||||
}
|
||||
if (current.data.envParam !== next.data.envParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate if the environment has been paused or resumed
|
||||
if (isEnvironmentPauseResumeFormSubmission(params.formMethod, params.formData)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBillingLimitSettingsFormSubmission(params.formMethod, params.formData)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// This prevents revalidation when there are search params changes
|
||||
// IMPORTANT: If the loader function depends on search params, this should be updated
|
||||
return params.currentUrl.pathname !== params.nextUrl.pathname;
|
||||
};
|
||||
|
||||
// IMPORTANT: Make sure to update shouldRevalidate if this loader depends on search params
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam } = ParamsSchema.parse(params);
|
||||
|
||||
const orgsPresenter = new OrganizationsPresenter();
|
||||
const { organizations, organization, project, environment } = await orgsPresenter.call({
|
||||
user,
|
||||
request,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
});
|
||||
|
||||
telemetry.organization.identify({ organization });
|
||||
telemetry.project.identify({ project });
|
||||
|
||||
//1st day of the month
|
||||
const firstDayOfMonth = new Date();
|
||||
firstDayOfMonth.setUTCDate(1);
|
||||
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
// Using the 1st day of next month means we get the usage for the current month
|
||||
// and the cache key for getCachedUsage is stable over the month
|
||||
const firstDayOfNextMonth = new Date();
|
||||
firstDayOfNextMonth.setUTCDate(1);
|
||||
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
|
||||
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
|
||||
|
||||
const shouldLoadRegions = !!projectParam && !!environment && environment.type !== "DEVELOPMENT";
|
||||
|
||||
const [sessionAuth, plan, usage, billingLimit, customDashboards, regions] = await Promise.all([
|
||||
rbac
|
||||
.authenticateSession(request, {
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
.catch(() => ({ ok: false as const, reason: "unauthorized" as const })),
|
||||
getCurrentPlan(organization.id),
|
||||
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
|
||||
getBillingLimit(organization.id),
|
||||
prisma.metricsDashboard.findMany({
|
||||
where: { organizationId: organization.id },
|
||||
select: {
|
||||
friendlyId: true,
|
||||
title: true,
|
||||
layout: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
shouldLoadRegions
|
||||
? new RegionsPresenter()
|
||||
.call({ userId: user.id, projectSlug: projectParam! })
|
||||
.then(({ regions }) => regions)
|
||||
.catch(() => [] as Region[])
|
||||
: Promise.resolve([] as Region[]),
|
||||
]);
|
||||
const userCanManageBillingLimits = sessionAuth.ok
|
||||
? canManageBillingLimits(sessionAuth.ability)
|
||||
: false;
|
||||
|
||||
let hasExceededFreeTier = false;
|
||||
let usagePercentage = 0;
|
||||
if (plan?.v3Subscription && !plan.v3Subscription.isPaying && plan.v3Subscription.plan && usage) {
|
||||
hasExceededFreeTier = usage.cents > plan.v3Subscription.plan.limits.includedUsage;
|
||||
usagePercentage = usage.cents / plan.v3Subscription.plan.limits.includedUsage;
|
||||
}
|
||||
|
||||
// Derive metric dashboard limit from plan, fallback to 3
|
||||
const metricDashboardsLimitValue = plan?.v3Subscription?.plan?.limits?.metricDashboards;
|
||||
const dashboardLimit =
|
||||
typeof metricDashboardsLimitValue === "number"
|
||||
? metricDashboardsLimitValue
|
||||
: (metricDashboardsLimitValue?.number ?? 3);
|
||||
|
||||
// Derive widget-per-dashboard limit from plan, fallback to 16
|
||||
const metricWidgetsLimitValue = plan?.v3Subscription?.plan?.limits?.metricWidgetsPerDashboard;
|
||||
const widgetLimitPerDashboard =
|
||||
typeof metricWidgetsLimitValue === "number"
|
||||
? metricWidgetsLimitValue
|
||||
: (metricWidgetsLimitValue?.number ?? 16);
|
||||
|
||||
// Compute widget counts per dashboard from layout JSON
|
||||
const customDashboardsWithWidgetCount = customDashboards.map((d) => {
|
||||
let widgetCount = 0;
|
||||
try {
|
||||
const layout = JSON.parse(String(d.layout)) as Record<string, unknown>;
|
||||
const widgets = layout.widgets;
|
||||
if (widgets && typeof widgets === "object") {
|
||||
widgetCount = Object.keys(widgets as Record<string, unknown>).length;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return {
|
||||
friendlyId: d.friendlyId,
|
||||
title: d.title,
|
||||
widgetCount,
|
||||
};
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
organizations,
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
regions,
|
||||
isImpersonating: !!impersonationId,
|
||||
currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } },
|
||||
billingLimit,
|
||||
customDashboards: customDashboardsWithWidgetCount,
|
||||
dashboardLimits: {
|
||||
used: customDashboards.length,
|
||||
limit: dashboardLimit,
|
||||
},
|
||||
widgetLimitPerDashboard,
|
||||
canManageBillingLimits: userCanManageBillingLimits,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Organization() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const org = useOptionalOrganization();
|
||||
return org ? (
|
||||
<RouteErrorDisplay button={{ title: org.title, to: organizationPath(org) }} />
|
||||
) : (
|
||||
<RouteErrorDisplay button={{ title: "Go to homepage", to: "/" }} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user