Merge branch 'provisioning-room' into dev

This commit is contained in:
Owen
2026-03-30 16:19:11 -07:00
21 changed files with 875 additions and 110 deletions

View File

@@ -0,0 +1,83 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "../../../../../components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
type ProvisioningKeysPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ProvisioningKeysPage(
props: ProvisioningKeysPageProps
) {
const params = await props.params;
const t = await getTranslations();
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
[];
try {
const res = await internal.get<
AxiosResponse<ListSiteProvisioningKeysResponse>
>(
`/org/${params.orgId}/site-provisioning-keys`,
await authCookieHeader()
);
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
} catch (e) {}
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
name: k.name,
id: k.siteProvisioningKeyId,
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
createdAt: k.createdAt,
lastUsed: k.lastUsed,
maxBatchSize: k.maxBatchSize,
numUsed: k.numUsed,
validUntil: k.validUntil
}));
return (
<>
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("provisioningKeysBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("provisioningKeysBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("provisioningKeysBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}

View File

@@ -0,0 +1,38 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
interface ProvisioningLayoutProps {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
}
export default async function ProvisioningLayout({
children,
params
}: ProvisioningLayoutProps) {
const { orgId } = await params;
const t = await getTranslations();
const navItems = [
{
title: t("provisioningKeys"),
href: `/${orgId}/settings/provisioning/keys`
},
{
title: t("pendingSites"),
href: `/${orgId}/settings/provisioning/pending`
}
];
return (
<>
<SettingsSectionTitle
title={t("provisioningManage")}
description={t("provisioningDescription")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}

View File

@@ -1,60 +1,10 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "../../../../components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { redirect } from "next/navigation";
type ProvisioningPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ProvisioningPage(props: ProvisioningPageProps) {
const params = await props.params;
const t = await getTranslations();
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
[];
try {
const res = await internal.get<
AxiosResponse<ListSiteProvisioningKeysResponse>
>(
`/org/${params.orgId}/site-provisioning-keys`,
await authCookieHeader()
);
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
} catch (e) {}
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
name: k.name,
id: k.siteProvisioningKeyId,
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
createdAt: k.createdAt,
lastUsed: k.lastUsed,
maxBatchSize: k.maxBatchSize,
numUsed: k.numUsed,
validUntil: k.validUntil
}));
return (
<>
<SettingsSectionTitle
title={t("provisioningKeysManage")}
description={t("provisioningKeysDescription")}
/>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}
redirect(`/${params.orgId}/settings/provisioning/keys`);
}

View File

@@ -0,0 +1,110 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import { SiteRow } from "@app/components/SitesTable";
import PendingSitesTable from "@app/components/PendingSitesTable";
import { getTranslations } from "next-intl/server";
import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
type PendingSitesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function PendingSitesPage(props: PendingSitesPageProps) {
const params = await props.params;
const incomingSearchParams = new URLSearchParams(await props.searchParams);
incomingSearchParams.set("status", "pending");
let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
`/org/${params.orgId}/sites?${incomingSearchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
sites = responseData.sites;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
function formatSize(mb: number, type: string): string {
if (type === "local") {
return "-";
}
if (mb >= 1024 * 1024) {
return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) });
} else if (mb >= 1024) {
return t("gigabytes", { count: (mb / 1024).toFixed(2) });
} else {
return t("megabytes", { count: mb.toFixed(2) });
}
}
const siteRows: SiteRow[] = sites.map((site) => ({
name: site.name,
id: site.siteId,
nice: site.niceId.toString(),
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
orgId: params.orgId,
type: site.type as any,
online: site.online,
newtVersion: site.newtVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
remoteExitNodeId: (site as any).remoteExitNodeId || undefined
}));
return (
<>
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("pendingSitesBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("pendingSitesBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("pendingSitesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
<PendingSitesTable
sites={siteRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -18,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) {
const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = {