mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 14:36:46 +00:00
Compare commits
3 Commits
alerting-r
...
provisioni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e9544af07 | ||
|
|
fcf92d4e2c | ||
|
|
77cef554be |
@@ -331,6 +331,11 @@
|
||||
"provisioningKeysTitle": "Provisioning Key",
|
||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||
"provisioningManage": "Provisioning",
|
||||
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
||||
"pendingSites": "Pending Sites",
|
||||
"siteApproveSuccess": "Site approved successfully",
|
||||
"siteApproveError": "Error approving site",
|
||||
"provisioningKeys": "Provisioning Keys",
|
||||
"searchProvisioningKeys": "Search provisioning keys...",
|
||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||
@@ -360,9 +365,17 @@
|
||||
"provisioningKeysNeverUsed": "Never",
|
||||
"provisioningKeysEdit": "Edit Provisioning Key",
|
||||
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.",
|
||||
"provisioningKeysApproveNewSites": "Approve new sites",
|
||||
"provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.",
|
||||
"provisioningKeysUpdateError": "Error updating provisioning key",
|
||||
"provisioningKeysUpdated": "Provisioning key updated",
|
||||
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
|
||||
"provisioningKeysBannerTitle": "Site Provisioning Keys",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerButtonText": "Learn More",
|
||||
"pendingSitesBannerTitle": "Pending Sites",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.",
|
||||
"pendingSitesBannerButtonText": "Learn More",
|
||||
"apiKeysSettings": "{apiKeyName} Settings",
|
||||
"userTitle": "Manage All Users",
|
||||
"userDescription": "View and manage all users in the system",
|
||||
|
||||
@@ -391,7 +391,8 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", {
|
||||
lastUsed: varchar("lastUsed", { length: 255 }),
|
||||
maxBatchSize: integer("maxBatchSize"), // null = no limit
|
||||
numUsed: integer("numUsed").notNull().default(0),
|
||||
validUntil: varchar("validUntil", { length: 255 })
|
||||
validUntil: varchar("validUntil", { length: 255 }),
|
||||
approveNewSites: boolean("approveNewSites").notNull().default(true)
|
||||
});
|
||||
|
||||
export const siteProvisioningKeyOrg = pgTable(
|
||||
|
||||
@@ -100,7 +100,8 @@ export const sites = pgTable("sites", {
|
||||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
status: varchar("status").$type<"pending" | "approved">()
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
|
||||
@@ -375,7 +375,10 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
|
||||
lastUsed: text("lastUsed"),
|
||||
maxBatchSize: integer("maxBatchSize"), // null = no limit
|
||||
numUsed: integer("numUsed").notNull().default(0),
|
||||
validUntil: text("validUntil")
|
||||
validUntil: text("validUntil"),
|
||||
approveNewSites: integer("approveNewSites", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
});
|
||||
|
||||
export const siteProvisioningKeyOrg = sqliteTable(
|
||||
|
||||
@@ -110,7 +110,8 @@ export const sites = sqliteTable("sites", {
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
.default(true),
|
||||
status: text("status").$type<"pending" | "approved">()
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
|
||||
@@ -38,7 +38,8 @@ const bodySchema = z
|
||||
z.null(),
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
]),
|
||||
validUntil: z.string().max(255).optional()
|
||||
validUntil: z.string().max(255).optional(),
|
||||
approveNewSites: z.boolean().optional().default(true)
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const v = data.validUntil;
|
||||
@@ -82,7 +83,7 @@ export async function createSiteProvisioningKey(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { name, maxBatchSize } = parsedBody.data;
|
||||
const { name, maxBatchSize, approveNewSites } = parsedBody.data;
|
||||
const vuRaw = parsedBody.data.validUntil;
|
||||
const validUntil =
|
||||
vuRaw == null || vuRaw.trim() === ""
|
||||
@@ -106,7 +107,8 @@ export async function createSiteProvisioningKey(
|
||||
lastUsed: null,
|
||||
maxBatchSize,
|
||||
numUsed: 0,
|
||||
validUntil
|
||||
validUntil,
|
||||
approveNewSites
|
||||
});
|
||||
|
||||
await trx.insert(siteProvisioningKeyOrg).values({
|
||||
@@ -127,7 +129,8 @@ export async function createSiteProvisioningKey(
|
||||
lastUsed: null,
|
||||
maxBatchSize,
|
||||
numUsed: 0,
|
||||
validUntil
|
||||
validUntil,
|
||||
approveNewSites
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -57,7 +57,8 @@ function querySiteProvisioningKeys(orgId: string) {
|
||||
lastUsed: siteProvisioningKeys.lastUsed,
|
||||
maxBatchSize: siteProvisioningKeys.maxBatchSize,
|
||||
numUsed: siteProvisioningKeys.numUsed,
|
||||
validUntil: siteProvisioningKeys.validUntil
|
||||
validUntil: siteProvisioningKeys.validUntil,
|
||||
approveNewSites: siteProvisioningKeys.approveNewSites
|
||||
})
|
||||
.from(siteProvisioningKeyOrg)
|
||||
.innerJoin(
|
||||
|
||||
@@ -39,16 +39,18 @@ const bodySchema = z
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
])
|
||||
.optional(),
|
||||
validUntil: z.string().max(255).optional()
|
||||
validUntil: z.string().max(255).optional(),
|
||||
approveNewSites: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
data.maxBatchSize === undefined &&
|
||||
data.validUntil === undefined
|
||||
data.validUntil === undefined &&
|
||||
data.approveNewSites === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Provide maxBatchSize and/or validUntil",
|
||||
message: "Provide maxBatchSize and/or validUntil and/or approveNewSites",
|
||||
path: ["maxBatchSize"]
|
||||
});
|
||||
}
|
||||
@@ -129,6 +131,7 @@ export async function updateSiteProvisioningKey(
|
||||
const setValues: {
|
||||
maxBatchSize?: number | null;
|
||||
validUntil?: string | null;
|
||||
approveNewSites?: boolean;
|
||||
} = {};
|
||||
if (body.maxBatchSize !== undefined) {
|
||||
setValues.maxBatchSize = body.maxBatchSize;
|
||||
@@ -139,6 +142,9 @@ export async function updateSiteProvisioningKey(
|
||||
? null
|
||||
: new Date(Date.parse(body.validUntil)).toISOString();
|
||||
}
|
||||
if (body.approveNewSites !== undefined) {
|
||||
setValues.approveNewSites = body.approveNewSites;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(siteProvisioningKeys)
|
||||
@@ -160,7 +166,8 @@ export async function updateSiteProvisioningKey(
|
||||
lastUsed: siteProvisioningKeys.lastUsed,
|
||||
maxBatchSize: siteProvisioningKeys.maxBatchSize,
|
||||
numUsed: siteProvisioningKeys.numUsed,
|
||||
validUntil: siteProvisioningKeys.validUntil
|
||||
validUntil: siteProvisioningKeys.validUntil,
|
||||
approveNewSites: siteProvisioningKeys.approveNewSites
|
||||
})
|
||||
.from(siteProvisioningKeys)
|
||||
.where(
|
||||
|
||||
@@ -82,7 +82,8 @@ export async function registerNewt(
|
||||
orgId: siteProvisioningKeyOrg.orgId,
|
||||
maxBatchSize: siteProvisioningKeys.maxBatchSize,
|
||||
numUsed: siteProvisioningKeys.numUsed,
|
||||
validUntil: siteProvisioningKeys.validUntil
|
||||
validUntil: siteProvisioningKeys.validUntil,
|
||||
approveNewSites: siteProvisioningKeys.approveNewSites,
|
||||
})
|
||||
.from(siteProvisioningKeys)
|
||||
.innerJoin(
|
||||
@@ -196,7 +197,8 @@ export async function registerNewt(
|
||||
name: niceId,
|
||||
niceId,
|
||||
type: "newt",
|
||||
dockerSocketEnabled: true
|
||||
dockerSocketEnabled: true,
|
||||
status: keyRecord.approveNewSites ? "approved" : "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -298,7 +298,8 @@ export async function createSite(
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
type,
|
||||
dockerSocketEnabled: true
|
||||
dockerSocketEnabled: true,
|
||||
status: "approved"
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "wireguard") {
|
||||
@@ -355,7 +356,8 @@ export async function createSite(
|
||||
niceId,
|
||||
subnet,
|
||||
type,
|
||||
pubKey: pubKey || null
|
||||
pubKey: pubKey || null,
|
||||
status: "approved"
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "local") {
|
||||
@@ -370,7 +372,8 @@ export async function createSite(
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
subnet: "0.0.0.0/32"
|
||||
subnet: "0.0.0.0/32",
|
||||
status: "approved"
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
|
||||
@@ -135,6 +135,15 @@ const listSitesSchema = z.object({
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
}),
|
||||
status: z
|
||||
.enum(["pending", "approved"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site status"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -156,7 +165,8 @@ function querySitesBase() {
|
||||
exitNodeId: sites.exitNodeId,
|
||||
exitNodeName: exitNodes.name,
|
||||
exitNodeEndpoint: exitNodes.endpoint,
|
||||
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
||||
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
@@ -245,7 +255,7 @@ export async function listSites(
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const { pageSize, page, query, sort_by, order, online } =
|
||||
const { pageSize, page, query, sort_by, order, online, status } =
|
||||
parsedQuery.data;
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
@@ -273,6 +283,9 @@ export async function listSites(
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
}
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(sites.status, status));
|
||||
}
|
||||
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ const updateSiteBodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional(),
|
||||
dockerSocketEnabled: z.boolean().optional()
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
status: z.enum(["pending", "approved"]).optional(),
|
||||
// remoteSubnets: z.string().optional()
|
||||
// subdomain: z
|
||||
// .string()
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SiteProvisioningKeyListItem = {
|
||||
maxBatchSize: number | null;
|
||||
numUsed: number;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
export type ListSiteProvisioningKeysResponse = {
|
||||
@@ -26,6 +27,7 @@ export type CreateSiteProvisioningKeyResponse = {
|
||||
maxBatchSize: number | null;
|
||||
numUsed: number;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
export type UpdateSiteProvisioningKeyResponse = {
|
||||
@@ -38,4 +40,5 @@ export type UpdateSiteProvisioningKeyResponse = {
|
||||
maxBatchSize: number | null;
|
||||
numUsed: number;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
83
src/app/[orgId]/settings/provisioning/keys/page.tsx
Normal file
83
src/app/[orgId]/settings/provisioning/keys/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/app/[orgId]/settings/provisioning/layout.tsx
Normal file
38
src/app/[orgId]/settings/provisioning/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
110
src/app/[orgId]/settings/provisioning/pending/page.tsx
Normal file
110
src/app/[orgId]/settings/provisioning/pending/page.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -79,7 +79,8 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
.max(1_000_000, {
|
||||
message: t("provisioningKeysMaxBatchSizeInvalid")
|
||||
}),
|
||||
validUntil: z.string().optional()
|
||||
validUntil: z.string().optional(),
|
||||
approveNewSites: z.boolean()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const v = data.validUntil;
|
||||
@@ -103,7 +104,8 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
name: "",
|
||||
unlimitedBatchSize: false,
|
||||
maxBatchSize: 100,
|
||||
validUntil: ""
|
||||
validUntil: "",
|
||||
approveNewSites: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,7 +116,8 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
name: "",
|
||||
unlimitedBatchSize: false,
|
||||
maxBatchSize: 100,
|
||||
validUntil: ""
|
||||
validUntil: "",
|
||||
approveNewSites: true
|
||||
});
|
||||
}
|
||||
}, [open, form]);
|
||||
@@ -123,18 +126,21 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<CreateSiteProvisioningKeyResponse>
|
||||
>(`/org/${orgId}/site-provisioning-key`, {
|
||||
name: data.name,
|
||||
maxBatchSize: data.unlimitedBatchSize
|
||||
? null
|
||||
: data.maxBatchSize,
|
||||
validUntil:
|
||||
data.validUntil == null || data.validUntil.trim() === ""
|
||||
? undefined
|
||||
: data.validUntil
|
||||
})
|
||||
.put<AxiosResponse<CreateSiteProvisioningKeyResponse>>(
|
||||
`/org/${orgId}/site-provisioning-key`,
|
||||
{
|
||||
name: data.name,
|
||||
maxBatchSize: data.unlimitedBatchSize
|
||||
? null
|
||||
: data.maxBatchSize,
|
||||
validUntil:
|
||||
data.validUntil == null ||
|
||||
data.validUntil.trim() === ""
|
||||
? undefined
|
||||
: data.validUntil,
|
||||
approveNewSites: data.approveNewSites
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -152,9 +158,7 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
}
|
||||
}
|
||||
|
||||
const credential =
|
||||
created &&
|
||||
created.siteProvisioningKey;
|
||||
const credential = created && created.siteProvisioningKey;
|
||||
|
||||
const unlimitedBatchSize = form.watch("unlimitedBatchSize");
|
||||
|
||||
@@ -213,15 +217,12 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
min={1}
|
||||
max={1_000_000}
|
||||
autoComplete="off"
|
||||
disabled={
|
||||
unlimitedBatchSize
|
||||
}
|
||||
disabled={unlimitedBatchSize}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
e.target.value;
|
||||
const v = e.target.value;
|
||||
field.onChange(
|
||||
v === ""
|
||||
? 100
|
||||
@@ -269,9 +270,7 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
const dateTimeValue: DateTimeValue =
|
||||
(() => {
|
||||
if (!field.value) return {};
|
||||
const d = new Date(
|
||||
field.value
|
||||
);
|
||||
const d = new Date(field.value);
|
||||
if (isNaN(d.getTime()))
|
||||
return {};
|
||||
const hours = d
|
||||
@@ -313,11 +312,7 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
value.date
|
||||
);
|
||||
if (value.time) {
|
||||
const [
|
||||
h,
|
||||
m,
|
||||
s
|
||||
] =
|
||||
const [h, m, s] =
|
||||
value.time.split(
|
||||
":"
|
||||
);
|
||||
@@ -352,6 +347,40 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="approveNewSites"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="provisioning-approve-new-sites"
|
||||
checked={field.value}
|
||||
onCheckedChange={(c) =>
|
||||
field.onChange(
|
||||
c === true
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FormLabel
|
||||
htmlFor="provisioning-approve-new-sites"
|
||||
className="cursor-pointer font-normal !mt-0"
|
||||
>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSites"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSitesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
@@ -395,4 +424,4 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export type EditableSiteProvisioningKey = {
|
||||
name: string;
|
||||
maxBatchSize: number | null;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
type EditSiteProvisioningKeyCredenzaProps = {
|
||||
@@ -76,7 +77,8 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
.max(1_000_000, {
|
||||
message: t("provisioningKeysMaxBatchSizeInvalid")
|
||||
}),
|
||||
validUntil: z.string().optional()
|
||||
validUntil: z.string().optional(),
|
||||
approveNewSites: z.boolean()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const v = data.validUntil;
|
||||
@@ -100,7 +102,8 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
name: "",
|
||||
unlimitedBatchSize: false,
|
||||
maxBatchSize: 100,
|
||||
validUntil: ""
|
||||
validUntil: "",
|
||||
approveNewSites: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,7 +115,8 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
name: provisioningKey.name,
|
||||
unlimitedBatchSize: provisioningKey.maxBatchSize == null,
|
||||
maxBatchSize: provisioningKey.maxBatchSize ?? 100,
|
||||
validUntil: provisioningKey.validUntil ?? ""
|
||||
validUntil: provisioningKey.validUntil ?? "",
|
||||
approveNewSites: provisioningKey.approveNewSites
|
||||
});
|
||||
}, [open, provisioningKey, form]);
|
||||
|
||||
@@ -135,7 +139,8 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
data.validUntil == null ||
|
||||
data.validUntil.trim() === ""
|
||||
? ""
|
||||
: data.validUntil
|
||||
: data.validUntil,
|
||||
approveNewSites: data.approveNewSites
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -255,6 +260,38 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="approveNewSites"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="provisioning-edit-approve-new-sites"
|
||||
checked={field.value}
|
||||
onCheckedChange={(c) =>
|
||||
field.onChange(c === true)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FormLabel
|
||||
htmlFor="provisioning-edit-approve-new-sites"
|
||||
className="cursor-pointer font-normal !mt-0"
|
||||
>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSites"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSitesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validUntil"
|
||||
|
||||
465
src/components/PendingSitesTable.tsx
Normal file
465
src/components/PendingSitesTable.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "./ui/controlled-data-table";
|
||||
import { SiteRow } from "./SitesTable";
|
||||
|
||||
type PendingSitesTableProps = {
|
||||
sites: SiteRow[];
|
||||
pagination: PaginationState;
|
||||
orgId: string;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function PendingSitesTable({
|
||||
sites,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: PendingSitesTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null
|
||||
) {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
sp.delete(column);
|
||||
sp.delete("page");
|
||||
|
||||
if (value) {
|
||||
sp.set(column, value);
|
||||
}
|
||||
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function approveSite(siteId: number) {
|
||||
setApprovingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.post(`/site/${siteId}`, { status: "accepted" });
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteApproveSuccess"),
|
||||
variant: "default"
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteApproveError"),
|
||||
description: formatAxiosError(e, t("siteApproveError"))
|
||||
});
|
||||
} finally {
|
||||
setApprovingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(siteId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ExtendedColumnDef<SiteRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: () => {
|
||||
return <span className="p-3">{t("identifier")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.nice || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
header: () => {
|
||||
return (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("online") },
|
||||
{ value: "false", label: t("offline") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("online")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("online", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("online")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (
|
||||
originalRow.type == "newt" ||
|
||||
originalRow.type == "wireguard"
|
||||
) {
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return <span>-</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "mbIn",
|
||||
// friendlyName: t("dataIn"),
|
||||
// header: () => {
|
||||
// const dataInOrder = getSortDirection(
|
||||
// "megabytesIn",
|
||||
// searchParams
|
||||
// );
|
||||
// const Icon =
|
||||
// dataInOrder === "asc"
|
||||
// ? ArrowDown01Icon
|
||||
// : dataInOrder === "desc"
|
||||
// ? ArrowUp10Icon
|
||||
// : ChevronsUpDownIcon;
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() => toggleSort("megabytesIn")}
|
||||
// >
|
||||
// {t("dataIn")}
|
||||
// <Icon className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "mbOut",
|
||||
// friendlyName: t("dataOut"),
|
||||
// header: () => {
|
||||
// const dataOutOrder = getSortDirection(
|
||||
// "megabytesOut",
|
||||
// searchParams
|
||||
// );
|
||||
// const Icon =
|
||||
// dataOutOrder === "asc"
|
||||
// ? ArrowDown01Icon
|
||||
// : dataOutOrder === "desc"
|
||||
// ? ArrowUp10Icon
|
||||
// : ChevronsUpDownIcon;
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() => toggleSort("megabytesOut")}
|
||||
// >
|
||||
// {t("dataOut")}
|
||||
// <Icon className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("type"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("type")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Newt</span>
|
||||
{originalRow.newtVersion && (
|
||||
<span>v{originalRow.newtVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">WireGuard</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">Local</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
friendlyName: t("exitNode"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("exitNode")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (!originalRow.exitNodeName) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const isCloudNode =
|
||||
build == "saas" &&
|
||||
originalRow.exitNodeName &&
|
||||
[
|
||||
"mercury",
|
||||
"venus",
|
||||
"earth",
|
||||
"mars",
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
const capitalizedName =
|
||||
originalRow.exitNodeName.charAt(0).toUpperCase() +
|
||||
originalRow.exitNodeName.slice(1).toLowerCase();
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
Pangolin {capitalizedName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.remoteExitNodeId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{originalRow.exitNodeName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{originalRow.exitNodeName}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
header: () => {
|
||||
return <span className="p-3">{t("address")}</span>;
|
||||
},
|
||||
cell: ({ row }: { row: any }) => {
|
||||
const originalRow = row.original;
|
||||
return originalRow.address ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{originalRow.address}</span>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
const isApproving = approvingIds.has(siteRow.id);
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isApproving}
|
||||
onClick={() => approveSite(siteRow.id)}
|
||||
>
|
||||
<Check className="mr-2 w-4 h-4" />
|
||||
{t("approve")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={sites}
|
||||
tableId="pending-sites-table"
|
||||
searchPlaceholder={t("searchSitesProgress")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
nice: false,
|
||||
exitNode: false,
|
||||
address: false
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user