mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 22:46:40 +00:00
Provisioning room basics done
This commit is contained in:
@@ -331,6 +331,11 @@
|
|||||||
"provisioningKeysTitle": "Provisioning Key",
|
"provisioningKeysTitle": "Provisioning Key",
|
||||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
"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",
|
"provisioningKeys": "Provisioning Keys",
|
||||||
"searchProvisioningKeys": "Search provisioning keys...",
|
"searchProvisioningKeys": "Search provisioning keys...",
|
||||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export const sites = pgTable("sites", {
|
|||||||
publicKey: varchar("publicKey"),
|
publicKey: varchar("publicKey"),
|
||||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||||
|
status: varchar("status").$type<"pending" | "accepted">()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = pgTable("resources", {
|
export const resources = pgTable("resources", {
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export const sites = sqliteTable("sites", {
|
|||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true)
|
.default(true),
|
||||||
|
status: text("status").$type<"pending" | "accepted">()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable("resources", {
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ export async function registerNewt(
|
|||||||
name: niceId,
|
name: niceId,
|
||||||
niceId,
|
niceId,
|
||||||
type: "newt",
|
type: "newt",
|
||||||
dockerSocketEnabled: true
|
dockerSocketEnabled: true,
|
||||||
|
status: "pending"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,8 @@ export async function createSite(
|
|||||||
niceId,
|
niceId,
|
||||||
address: updatedAddress || null,
|
address: updatedAddress || null,
|
||||||
type,
|
type,
|
||||||
dockerSocketEnabled: true
|
dockerSocketEnabled: true,
|
||||||
|
status: "accepted"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
} else if (type == "wireguard") {
|
} else if (type == "wireguard") {
|
||||||
@@ -355,7 +356,8 @@ export async function createSite(
|
|||||||
niceId,
|
niceId,
|
||||||
subnet,
|
subnet,
|
||||||
type,
|
type,
|
||||||
pubKey: pubKey || null
|
pubKey: pubKey || null,
|
||||||
|
status: "accepted"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
} else if (type == "local") {
|
} else if (type == "local") {
|
||||||
@@ -370,7 +372,8 @@ export async function createSite(
|
|||||||
type,
|
type,
|
||||||
dockerSocketEnabled: false,
|
dockerSocketEnabled: false,
|
||||||
online: true,
|
online: true,
|
||||||
subnet: "0.0.0.0/32"
|
subnet: "0.0.0.0/32",
|
||||||
|
status: "accepted"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ const listSitesSchema = z.object({
|
|||||||
.openapi({
|
.openapi({
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "Filter by online status"
|
description: "Filter by online status"
|
||||||
|
}),
|
||||||
|
status: z
|
||||||
|
.enum(["pending", "accepted"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["pending", "accepted"],
|
||||||
|
description: "Filter by site status"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +165,8 @@ function querySitesBase() {
|
|||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
exitNodeName: exitNodes.name,
|
exitNodeName: exitNodes.name,
|
||||||
exitNodeEndpoint: exitNodes.endpoint,
|
exitNodeEndpoint: exitNodes.endpoint,
|
||||||
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
|
||||||
|
status: sites.status
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
@@ -245,7 +255,7 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order, online } =
|
const { pageSize, page, query, sort_by, order, online, status } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
@@ -273,6 +283,9 @@ export async function listSites(
|
|||||||
if (typeof online !== "undefined") {
|
if (typeof online !== "undefined") {
|
||||||
conditions.push(eq(sites.online, online));
|
conditions.push(eq(sites.online, online));
|
||||||
}
|
}
|
||||||
|
if (typeof status !== "undefined") {
|
||||||
|
conditions.push(eq(sites.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = querySitesBase().where(and(...conditions));
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const updateSiteBodySchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
niceId: 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", "accepted"]).optional(),
|
||||||
// remoteSubnets: z.string().optional()
|
// remoteSubnets: z.string().optional()
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
|
|||||||
56
src/app/[orgId]/settings/provisioning/keys/page.tsx
Normal file
56
src/app/[orgId]/settings/provisioning/keys/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<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 { redirect } from "next/navigation";
|
||||||
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";
|
|
||||||
|
|
||||||
type ProvisioningPageProps = {
|
type ProvisioningPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function ProvisioningPage(props: ProvisioningPageProps) {
|
export default async function ProvisioningPage(props: ProvisioningPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
redirect(`/${params.orgId}/settings/provisioning/keys`);
|
||||||
|
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
82
src/app/[orgId]/settings/provisioning/pending/page.tsx
Normal file
82
src/app/[orgId]/settings/provisioning/pending/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 params = await props.params;
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
searchParams.set("status", "accepted");
|
||||||
|
|
||||||
let sites: ListSitesResponse["sites"] = [];
|
let sites: ListSitesResponse["sites"] = [];
|
||||||
let pagination: ListSitesResponse["pagination"] = {
|
let pagination: ListSitesResponse["pagination"] = {
|
||||||
|
|||||||
440
src/components/PendingSitesTable.tsx
Normal file
440
src/components/PendingSitesTable.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
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
|
||||||
|
} 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">
|
||||||
|
<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