Merge branch 'dev' into feat/roles-and-user-multi-selectors

This commit is contained in:
Fred KISSIE
2026-04-30 16:55:25 +02:00
189 changed files with 6765 additions and 1954 deletions

View File

@@ -836,7 +836,14 @@ export default function BillingPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Plan Cards Grid */}
<div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
<div
className={cn(
"grid grid-cols-1 gap-4",
visiblePlanOptions.length === 5
? "md:grid-cols-5"
: "md:grid-cols-4"
)}
>
{visiblePlanOptions.map((plan) => {
const isCurrentPlan = plan.id === currentPlanId;
const planAction = getPlanAction(plan);
@@ -967,7 +974,7 @@ export default function BillingPage() {
{t("billingCurrentUsage") || "Current Usage"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
<span className="text-3xl font-semibold">
{getUserCount()}
</span>
<span className="text-lg">
@@ -1298,7 +1305,7 @@ export default function BillingPage() {
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">

View File

@@ -151,6 +151,7 @@ export default async function AlertingHealthChecksPage(
fullDomain: string | null;
niceId: string;
ssl: boolean;
wildcard: boolean;
} | null = null;
if (resourceIdParam) {
try {
@@ -165,7 +166,8 @@ export default async function AlertingHealthChecksPage(
resourceId: r.resourceId,
fullDomain: r.fullDomain,
niceId: r.niceId,
ssl: r.ssl
ssl: r.ssl,
wildcard: r.wildcard
};
}
} catch {

View File

@@ -96,6 +96,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
userId: client.userId,
username: client.username,
userEmail: client.userEmail,
userType: client.userType ?? null,
idpName: client.idpName ?? null,
idpVariant: client.idpVariant ?? null,
niceId: client.niceId,
agent: client.agent,
archived: Boolean(client.archived),

View File

@@ -5,7 +5,7 @@ export default async function NotFound() {
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h1 className="text-6xl font-semibold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>

View File

@@ -69,6 +69,7 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
resourceCount: Number(site.resourceCount ?? 0),
orgId: params.orgId,
type: site.type as any,
online: site.online,

View File

@@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { ListResourcesResponse } from "@server/routers/resource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
@@ -22,6 +24,13 @@ export interface ClientResourcesPageProps {
searchParams: Promise<Record<string, string>>;
}
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ClientResourcesPage(
props: ClientResourcesPageProps
) {
@@ -47,6 +56,32 @@ export default async function ClientResourcesPage(
pagination = responseData.pagination;
} catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null;
try {
const res = await getCachedOrg(params.orgId);
@@ -114,6 +149,7 @@ export default async function ClientResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
/>
</OrgProvider>
</>

View File

@@ -29,6 +29,7 @@ import { Label } from "@app/components/ui/label";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { UpdateResourceResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import { AlertCircle } from "lucide-react";
@@ -506,7 +507,7 @@ export default function GeneralForm() {
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain
? toASCII(data.subdomain)
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
@@ -670,6 +671,7 @@ export default function GeneralForm() {
<div className="space-y-4">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
key={resource.resourceId}
orgId={orgId as string}
cols={2}

View File

@@ -62,6 +62,7 @@ import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site";
@@ -953,6 +954,18 @@ function ProxyResourceTargetsForm({
</Button>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
</p>
)}
</SettingsSectionBody>
<form className="self-end mt-4" action={formAction}>

View File

@@ -488,7 +488,7 @@ export default function Page() {
const httpData = httpForm.getValues();
sanitizedSubdomain = httpData.subdomain
? finalizeSubdomainSanitize(httpData.subdomain)
? finalizeSubdomainSanitize(httpData.subdomain, true)
: undefined;
Object.assign(payload, {
@@ -694,19 +694,6 @@ export default function Page() {
header: () => <span className="p-3">{t("healthCheck")}</span>,
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "green";
case "unhealthy":
return "red";
case "unknown":
default:
return "secondary";
}
};
const getStatusText = (status: string) => {
switch (status) {
@@ -720,19 +707,7 @@ export default function Page() {
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CircleCheck className="w-3 h-3" />;
case "unhealthy":
return <CircleX className="w-3 h-3" />;
case "unknown":
default:
return null;
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -742,12 +717,16 @@ export default function Page() {
openHealthCheckDialog(row.original)
}
>
<Settings className="h-4 w-4" />
<div className="flex items-center gap-1">
{getStatusIcon(status)}
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -1132,6 +1111,7 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm>
<DomainPicker
allowWildcard={true}
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
@@ -1439,6 +1419,18 @@ export default function Page() {
</Button>
</div>
)}
{build === "enterprise" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<InfoIcon className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
</p>
)}
</SettingsSectionBody>
</SettingsSection>

View File

@@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
@@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps {
searchParams: Promise<Record<string, string>>;
}
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ProxyResourcesPage(
props: ProxyResourcesPageProps
) {
@@ -47,13 +55,31 @@ export default async function ProxyResourcesPage(
pagination = responseData.pagination;
} catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null;
try {
@@ -94,6 +120,7 @@ export default async function ProxyResourcesPage(
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
@@ -102,7 +129,9 @@ export default async function ProxyResourcesPage(
enabled: target.enabled,
healthStatus: target.healthStatus,
siteName: target.siteName
}))
})),
sites: resource.sites ?? [],
health: (resource.health as ResourceRow["health"]) ?? undefined
};
});
return (
@@ -123,6 +152,7 @@ export default async function ProxyResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
/>
</OrgProvider>
</>

View File

@@ -42,6 +42,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
title: t("general"),
href: `/${params.orgId}/settings/sites/${params.niceId}/general`
},
{
title: t("siteResourcesTab"),
href: `/${params.orgId}/settings/sites/${params.niceId}/resources`
},
...(site.type !== "local"
? [
{

View File

@@ -0,0 +1,64 @@
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { GetSiteResponse } from "@server/routers/site";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
type SiteResourcesPageProps = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function SiteResourcesPage(props: SiteResourcesPageProps) {
const { orgId, niceId } = await props.params;
const siteRes = await internal.get<AxiosResponse<GetSiteResponse>>(
`/org/${orgId}/site/${niceId}`,
await authCookieHeader()
);
const site = siteRes.data.data;
const baseSearch = new URLSearchParams({
page: "1",
pageSize: "5",
siteId: String(site.siteId)
});
let initialPublicData: ListResourcesResponse | null = null;
let initialPrivateData: ListAllSiteResourcesByOrgResponse | null = null;
let initialPublicForbidden = false;
let initialPrivateForbidden = false;
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${orgId}/resources?${baseSearch.toString()}`,
await authCookieHeader()
);
initialPublicData = res.data.data;
} catch (e: any) {
initialPublicForbidden = e?.response?.status === 403;
}
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(
`/org/${orgId}/site-resources?${baseSearch.toString()}`,
await authCookieHeader()
);
initialPrivateData = res.data.data;
} catch (e: any) {
initialPrivateForbidden = e?.response?.status === 403;
}
return (
<SiteResourcesOverview
siteId={site.siteId}
initialPublicData={initialPublicData}
initialPrivateData={initialPrivateData}
initialPublicForbidden={initialPublicForbidden}
initialPrivateForbidden={initialPrivateForbidden}
/>
);
}

View File

@@ -64,6 +64,7 @@ export default async function SitesPage(props: SitesPageProps) {
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
resourceCount: Number(site.resourceCount ?? 0),
orgId: params.orgId,
type: site.type as any,
online: site.online,

View File

@@ -0,0 +1,264 @@
"use client";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
export type GlobalUserRow = {
id: string;
name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string;
twoFactorEnabled: boolean | null;
twoFactorSetupRequested: boolean | null;
};
type Props = {
users: GlobalUserRow[];
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(users);
const api = createApiClient(useEnvContext());
const deleteUser = (id: string) => {
api.delete(`/user/${id}`)
.catch((e) => {
console.error(t("userErrorDelete"), e);
toast({
variant: "destructive",
title: t("userErrorDelete"),
description: formatAxiosError(e, t("userErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== id);
setRows(newRows);
});
};
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "username",
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center gap-2">
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<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">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
title={t("userDeleteServer")}
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View File

@@ -92,7 +92,7 @@ export default function InitialSetupPage() {
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
<h1 className="text-2xl font-semibold mt-1">
{t("initialSetupTitle")}
</h1>
<CardDescription>

View File

@@ -23,8 +23,10 @@ export default function DeviceAuthSuccessPage() {
useEffect(() => {
// Detect if we're on iOS or Android
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera;
const isIOS =
/iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
const isAndroid = /android/i.test(userAgent);
if (isAndroid) {
@@ -32,7 +34,8 @@ export default function DeviceAuthSuccessPage() {
// This explicitly tells Chrome to send an intent to the app, which will bring
// SignInCodeActivity back to the foreground (it has launchMode="singleTop")
setTimeout(() => {
window.location.href = "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end";
window.location.href =
"intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end";
}, 500);
} else if (isIOS) {
// Wait 500ms then attempt to open the app
@@ -41,7 +44,8 @@ export default function DeviceAuthSuccessPage() {
window.location.href = "pangolin://";
setTimeout(() => {
window.location.href = "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS";
window.location.href =
"https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS";
}, 2000);
}, 500);
}
@@ -64,7 +68,7 @@ export default function DeviceAuthSuccessPage() {
<div className="flex flex-col items-center space-y-4">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div className="space-y-2">
<h3 className="text-xl font-bold text-center">
<h3 className="text-xl font-semibold text-center">
{t("deviceConnected")}
</h3>
<p className="text-center text-sm text-muted-foreground">

View File

@@ -135,7 +135,7 @@ export default async function Page(props: {
<div className="border rounded-md p-3 mb-4 bg-card">
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
<h2 className="text-2xl font-semibold mb-2 text-center">
{t("inviteAlready")}
</h2>
<p className="text-center">

View File

@@ -106,10 +106,22 @@ export default async function ResourceAuthPage(props: {
const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
if (!wildcardDomain.startsWith("*.")) return false;
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
return host.endsWith(suffix) && host.length > suffix.length;
};
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (serverResourceHostWithPort === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (
authInfo.wildcard &&
authInfo.fullDomain &&
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
) {
redirectUrl = searchParams.redirect;
}
} catch (e) {}
}

View File

@@ -65,7 +65,7 @@ export default async function Page(props: {
<div className="border rounded-md p-3 mb-4 bg-card">
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
<h2 className="text-2xl font-semibold mb-2 text-center">
{t("inviteAlready")}
</h2>
<p className="text-center">

View File

@@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
import { Inter, Mona_Sans } from "next/font/google";
import localFont from "next/font/local";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -32,12 +32,30 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic";
const inter = Inter({
subsets: ["latin"]
});
const monaSans = Mona_Sans({
subsets: ["latin"]
const monaSans = localFont({
src: [
{
path: "../fonts/mona-sans/MonaSans-Regular.woff2",
weight: "400",
style: "normal"
},
{
path: "../fonts/mona-sans/MonaSans-Medium.woff2",
weight: "500",
style: "normal"
},
{
path: "../fonts/mona-sans/MonaSans-SemiBold.woff2",
weight: "600",
style: "normal"
},
{
path: "../fonts/mona-sans/MonaSans-Bold.woff2",
weight: "700",
style: "normal"
}
],
display: "swap"
});
const fontClassName = monaSans.className;

View File

@@ -5,7 +5,7 @@ export default async function NotFound() {
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h1 className="text-6xl font-semibold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>

View File

@@ -143,7 +143,7 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) {
) : (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
<CardTitle className="text-center text-2xl font-semibold">
{renderTitle()}
</CardTitle>
</CardHeader>

View File

@@ -58,12 +58,12 @@ export default function AccessTokenSection({
<TabsContent value="token" className="space-y-4">
<div className="space-y-1">
<div className="font-bold">{t("tokenId")}</div>
<div className="font-semibold">{t("tokenId")}</div>
<CopyToClipboard text={tokenId} isLink={false} />
</div>
<div className="space-y-1">
<div className="font-bold">{t("token")}</div>
<div className="font-semibold">{t("token")}</div>
<CopyToClipboard text={token} isLink={false} />
</div>
</TabsContent>

View File

@@ -0,0 +1,37 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="userServer-table"
title={t("userServer")}
searchPlaceholder={t("userSearch")}
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="username"
stickyRightColumn="actions"
/>
);
}

View File

@@ -118,6 +118,8 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) {
return t("alertingTriggerResourceHealthy");
case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy");
case "resource_degraded":
return t("alertingTriggerResourceDegraded");
case "resource_toggle":
return t("alertingTriggerResourceToggle");
default:

View File

@@ -399,11 +399,10 @@ function AuthPageSettings({
</div>
)}
{env.flags.usePangolinDns &&
(build === "enterprise" ||
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
{build !== "oss" && (build === "enterprise" ||
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (

View File

@@ -1,43 +1,38 @@
"use client";
import { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react";
import { FileBadge, RotateCw } from "lucide-react";
import { useCertificate } from "@app/hooks/useCertificate";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import { useTranslations } from "next-intl";
type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
export type CertificateStatusContentProps = {
cert: GetCertificateResponse | null;
certLoading: boolean;
certError: string | null;
refreshing: boolean;
refreshCert: () => Promise<void>;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
};
export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
/** Presentation-only certificate row (shared hook state possible via props). */
export function CertificateStatusContent({
cert,
certLoading,
certError,
refreshing,
refreshCert,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
}: CertificateStatusProps) {
onRefresh
}: CertificateStatusContentProps) {
const t = useTranslations();
const { cert, certLoading, certError, refreshing, refreshCert } =
useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
const labelClass =
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-none";
const valueClass = "inline-flex items-center gap-2 text-sm leading-none";
const handleRefresh = async () => {
await refreshCert();
@@ -74,11 +69,15 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
<span className={labelClass}>
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
<span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 animate-pulse text-muted-foreground"
aria-hidden
/>
{t("loading")}
</span>
</div>
@@ -89,11 +88,17 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
<span className={labelClass}>
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-red-500">{certError}</span>
<span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 text-red-500"
aria-hidden
/>
{certError}
</span>
</div>
);
}
@@ -102,32 +107,64 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
<span className={labelClass}>
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
<span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
{t("none", { defaultValue: "None" })}
</span>
</div>
);
}
const isPending = cert.status === "pending";
const disableRestartButton = cert.domainType === "wildcard";
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
<span className={labelClass}>{t("certificateStatus")}:</span>
)}
<span className={`text-sm ${getStatusColor(cert.status)}`}>
<span className="inline-flex items-center">
{isPending && !disableRestartButton ? (
<Button
variant="ghost"
className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-none inline-flex items-center self-center"
onClick={handleRefresh}
disabled={refreshing}
title={t("restartCertificate", {
defaultValue: "Restart Certificate"
})}
>
<span className="inline-flex items-center gap-2 leading-none">
<FileBadge
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
aria-hidden
/>
{cert.status.charAt(0).toUpperCase() +
cert.status.slice(1)}
<RotateCw
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/>
</span>
</Button>
) : (
<span className={valueClass}>
<FileBadge
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
aria-hidden
/>
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
{shouldShowRefreshButton(cert.status, cert.updatedAt) && (
{shouldShowRefreshButton(cert.status, cert.updatedAt) &&
!disableRestartButton ? (
<Button
size="icon"
variant="ghost"
className="ml-2 p-0 h-auto align-middle"
className="inline-flex h-auto min-h-0 w-3 shrink-0 items-center justify-center self-center p-0"
onClick={handleRefresh}
disabled={refreshing}
title={t("restartCertificate", {
@@ -135,12 +172,58 @@ export default function CertificateStatus({
})}
>
<RotateCw
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
)}
) : null}
</span>
</span>
)}
</div>
);
}
type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
};
export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
}: CertificateStatusProps) {
const hook = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
return (
<CertificateStatusContent
cert={hook.cert}
certLoading={hook.certLoading}
certError={hook.certError}
refreshing={hook.refreshing}
refreshCert={hook.refreshCert}
showLabel={showLabel}
className={className}
onRefresh={onRefresh}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useTranslations } from "next-intl";
@@ -36,7 +37,24 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
{userDisplayName ? t("user") : t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{userDisplayName || client.niceId}
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName || client.niceId}</span>
{userDisplayName &&
(client.userType ?? "internal") !==
"internal" && (
<IdpTypeBadge
type={client.userType ?? "oidc"}
name={
client.idpName?.trim()
? client.idpName
: t("idpNameInternal")
}
variant={
client.idpVariant ?? undefined
}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
<InfoSection>

View File

@@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
@@ -12,6 +13,11 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -23,30 +29,32 @@ import {
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useEffect, useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type InternalResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
};
export type InternalResourceSiteRow = ResourceSiteRow;
export type InternalResourceRow = {
id: number;
@@ -78,28 +86,13 @@ export type InternalResourceRow = {
fullDomain?: string | null;
};
function resolveHttpHttpsDisplayPort(
mode: "http",
httpHttpsPort: number | null
): number {
if (httpHttpsPort != null) {
return httpHttpsPort;
}
return 80;
}
function formatDestinationDisplay(row: InternalResourceRow): string {
const { mode, destination, httpHttpsPort, scheme } = row;
if (mode !== "http") {
return destination;
}
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
const downstreamScheme = scheme ?? "http";
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
return formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.httpHttpsPort,
scheme: row.scheme
});
}
function isSafeUrlForLink(href: string): boolean {
@@ -111,121 +104,20 @@ function isSafeUrlForLink(href: string): boolean {
}
}
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
function aggregateSitesStatus(
resourceSites: InternalResourceSiteRow[]
): AggregateSitesStatus {
if (resourceSites.length === 0) {
return "allOffline";
}
const onlineCount = resourceSites.filter((rs) => rs.online).length;
if (onlineCount === resourceSites.length) return "allOnline";
if (onlineCount > 0) return "partial";
return "allOffline";
}
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
switch (status) {
case "allOnline":
return "bg-green-500";
case "partial":
return "bg-yellow-500";
case "allOffline":
default:
return "bg-neutral-500";
}
}
function ClientResourceSitesStatusCell({
orgId,
resourceSites
}: {
orgId: string;
resourceSites: InternalResourceSiteRow[];
}) {
const t = useTranslations();
if (resourceSites.length === 0) {
return <span>-</span>;
}
const aggregate = aggregateSitesStatus(resourceSites);
const countLabel = t("multiSitesSelectorSitesCount", {
count: resourceSites.length
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
aggregateStatusDotClass(aggregate)
)}
/>
<span className="text-sm tabular-nums">{countLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
{resourceSites.map((site) => {
const isOnline = site.online;
return (
<DropdownMenuItem key={site.siteId} asChild>
<Link
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
className="flex cursor-pointer items-center justify-between gap-4"
>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isOnline
? "bg-green-500"
: "bg-neutral-500"
)}
/>
<span className="truncate">
{site.siteName}
</span>
</div>
<span
className={cn(
"shrink-0 capitalize",
isOnline
? "text-green-600"
: "text-muted-foreground"
)}
>
{isOnline ? t("online") : t("offline")}
</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
initialFilterSite?: Selectedsite | null;
};
export default function ClientResourcesTable({
internalResources,
orgId,
pagination,
rowCount
rowCount,
initialFilterSite = null
}: ClientResourcesTableProps) {
const router = useRouter();
const {
@@ -247,9 +139,33 @@ export default function ClientResourcesTable({
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => {
startTransition(() => {
try {
@@ -289,14 +205,16 @@ export default function ClientResourcesTable({
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return <span>-</span>;
return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
}
if (siteNames.length === 1) {
return (
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
>
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
<Button variant="outline">
{siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" />
@@ -321,10 +239,7 @@ export default function ClientResourcesTable({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => (
<DropdownMenuItem
key={siteNiceIds[idx]}
asChild
>
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer"
@@ -391,11 +306,59 @@ export default function ClientResourcesTable({
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ClientResourceSitesStatusCell
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
@@ -479,13 +442,34 @@ export default function ClientResourcesTable({
);
}
if (resourceRow.mode === "http") {
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
@@ -576,6 +560,16 @@ export default function ClientResourcesTable({
});
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
@@ -632,6 +626,7 @@ export default function ClientResourcesTable({
rows={internalResources}
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query") ?? ""}
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
onSearch={handleSearchChange}

View File

@@ -15,6 +15,7 @@ import {
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
interface FilterOption {
@@ -74,7 +75,10 @@ export function ColumnFilter({
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start">
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>

View File

@@ -15,6 +15,7 @@ import {
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
interface FilterOption {
@@ -75,7 +76,10 @@ export function ColumnFilterButton({
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start">
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>

View File

@@ -18,6 +18,7 @@ import {
} from "@app/components/ui/command";
import { CheckIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
type FilterOption = {
@@ -101,7 +102,10 @@ export function ColumnMultiFilterButton({
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start">
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>

View File

@@ -92,7 +92,7 @@ export default function ConfirmDeleteDialog({
<CredenzaBody>
<div className="mb-4 break-all overflow-hidden">
{dialog}
<div className="mt-2 mb-6 font-bold text-destructive">
<div className="mt-2 mb-6 font-semibold text-destructive">
{warningText || t("cannotbeUndone")}
</div>
@@ -142,7 +142,9 @@ export default function ConfirmDeleteDialog({
form="confirm-delete-form"
loading={loading}
disabled={loading || !isConfirmed}
className={!isConfirmed && !loading ? "opacity-50" : ""}
className={
!isConfirmed && !loading ? "opacity-50" : ""
}
>
{buttonText}
</Button>

View File

@@ -47,15 +47,7 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon } from "@radix-ui/react-icons";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
@@ -275,10 +267,11 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
orgId={
org.org
.orgId
}
excludeWildcard
orgId={
org.org
.orgId
}
selectedResource={
selectedResource
}

View File

@@ -107,7 +107,7 @@ export function DNSRecordsDataTable<TData, TValue>({
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2 justify-between">
<div className="relative w-full sm:max-w-sm flex flex-row gap-4 items-center">
<h1 className="font-bold">{t("dnsRecord")}</h1>
<h1 className="font-semibold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge>
</div>
<Link

View File

@@ -252,7 +252,7 @@ export default function DeleteAccountConfirmDialog({
</>
)}
</div>
<p className="text-sm font-bold text-destructive">
<p className="text-sm font-semibold text-destructive">
{t("cannotbeUndone")}
</p>
</>

View File

@@ -27,6 +27,7 @@ import { cn } from "@/lib/cn";
import {
finalizeSubdomainSanitize,
isValidSubdomainStructure,
isWildcardSubdomain,
sanitizeInputRaw,
validateByDomainType
} from "@/lib/subdomain-utils";
@@ -41,10 +42,12 @@ import {
Check,
CheckCircle2,
ChevronsUpDown,
ExternalLink,
KeyRound,
Zap
} from "lucide-react";
import { useTranslations } from "next-intl";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@/hooks/usePaidStatus";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { toUnicode } from "punycode";
@@ -77,6 +80,7 @@ interface DomainPickerProps {
subdomain?: string;
fullDomain: string;
baseDomain: string;
wildcard?: boolean;
} | null
) => void;
cols?: number;
@@ -85,6 +89,7 @@ interface DomainPickerProps {
defaultSubdomain?: string | null;
defaultDomainId?: string | null;
warnOnProvidedDomain?: boolean;
allowWildcard?: boolean;
}
export default function DomainPicker({
@@ -95,23 +100,30 @@ export default function DomainPicker({
defaultSubdomain,
defaultFullDomain,
defaultDomainId,
warnOnProvidedDomain = false
warnOnProvidedDomain = false,
allowWildcard = false
}: DomainPickerProps) {
const { env } = useEnvContext();
const { user } = useUserContext();
const api = createApiClient({ env });
const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus();
const { hasSaasSubscription, isPaidUser } = usePaidStatus();
const requiresPaywall =
build === "saas" &&
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
new Date(user.dateCreated) > new Date("2026-04-13");
const wildcardAllowed =
allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]);
const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId })
);
// Wildcard mode is derived from the input itself — if the user types a
// wildcard subdomain (e.g. *.foo) and allowWildcard is enabled, it's active.
if (!env.flags.usePangolinDns) {
hideFreeDomain = true;
}
@@ -180,13 +192,16 @@ export default function DomainPicker({
firstOrExistingDomain.type !== "cname"
? defaultSubdomain?.trim() || undefined
: undefined;
const isWc =
allowWildcard && !!sub && isWildcardSubdomain(sub);
onDomainChange?.({
domainId: firstOrExistingDomain.domainId,
type: "organization",
subdomain: sub,
fullDomain: sub ? `${sub}.${base}` : base,
baseDomain: base
baseDomain: base,
wildcard: isWc
});
}
}
@@ -285,7 +300,8 @@ export default function DomainPicker({
}, [userInput, debouncedCheckAvailability, selectedBaseDomain]);
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
const sanitized = finalizeSubdomainSanitize(sub);
const wildcardMode = wildcardAllowed && isWildcardSubdomain(sub);
const sanitized = finalizeSubdomainSanitize(sub, wildcardMode);
if (!sanitized) {
toast({
@@ -301,7 +317,8 @@ export default function DomainPicker({
base.type === "provided-search"
? "provided-search"
: "organization",
domainType: base.domainType
domainType: base.domainType,
allowWildcard: wildcardMode
});
if (!ok) {
@@ -330,7 +347,7 @@ export default function DomainPicker({
};
const handleSubdomainChange = (value: string) => {
const raw = sanitizeInputRaw(value);
const raw = sanitizeInputRaw(value, allowWildcard);
setSubdomainInput(raw);
setSelectedProvidedDomain(null);
@@ -338,13 +355,15 @@ export default function DomainPicker({
const fullDomain = raw
? `${raw}.${selectedBaseDomain.domain}`
: selectedBaseDomain.domain;
const isWc = wildcardAllowed && isWildcardSubdomain(raw);
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: raw || undefined,
fullDomain,
baseDomain: selectedBaseDomain.domain
baseDomain: selectedBaseDomain.domain,
wildcard: isWc
});
}
};
@@ -366,6 +385,17 @@ export default function DomainPicker({
const handleBaseDomainSelect = (option: DomainOption) => {
let sub = subdomainInput;
// If the selected domain doesn't support wildcards, strip any wildcard prefix.
const supportsWildcard =
wildcardAllowed &&
option.type === "organization" &&
option.domainType !== "cname";
if (!supportsWildcard && isWildcardSubdomain(sub)) {
sub = sub.replace(/^\*\./, "");
setSubdomainInput(sub);
}
if (sub && sub.trim() !== "") {
sub = finalizeSubdomain(sub, option) || "";
setSubdomainInput(sub);
@@ -389,6 +419,7 @@ export default function DomainPicker({
}
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
const isWc = wildcardAllowed && !!sub && isWildcardSubdomain(sub);
if (option.type === "provided-search") {
onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain`
@@ -402,7 +433,8 @@ export default function DomainPicker({
? sub || undefined
: undefined,
fullDomain,
baseDomain: option.domain
baseDomain: option.domain,
wildcard: isWc
});
}
};
@@ -431,7 +463,9 @@ export default function DomainPicker({
selectedBaseDomain.type === "provided-search"
? "provided-search"
: "organization",
domainType: selectedBaseDomain.domainType
domainType: selectedBaseDomain.domainType,
allowWildcard:
wildcardAllowed && isWildcardSubdomain(subdomainInput)
})
: true;
@@ -439,6 +473,7 @@ export default function DomainPicker({
selectedBaseDomain &&
selectedBaseDomain.type === "organization" &&
selectedBaseDomain.domainType !== "cname";
const showProvidedDomainSearch =
selectedBaseDomain?.type === "provided-search";
@@ -463,9 +498,11 @@ export default function DomainPicker({
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="subdomain-input">
{t("domainPickerSubdomainLabel")}
</Label>
<div className="flex items-center justify-between">
<Label htmlFor="subdomain-input">
{t("domainPickerSubdomainLabel")}
</Label>
</div>
<Input
id="subdomain-input"
value={
@@ -477,7 +514,9 @@ export default function DomainPicker({
showProvidedDomainSearch
? ""
: showSubdomainInput
? ""
? wildcardAllowed
? "* or subdomain"
: ""
: t("domainPickerNotAvailableForCname")
}
disabled={
@@ -498,11 +537,35 @@ export default function DomainPicker({
/>
{showSubdomainInput &&
subdomainInput &&
!isValidSubdomainStructure(subdomainInput) && (
!isValidSubdomainStructure(
subdomainInput,
wildcardAllowed &&
isWildcardSubdomain(subdomainInput)
) && (
<p className="text-sm text-red-500">
{t("domainPickerInvalidSubdomainStructure")}
</p>
)}
{allowWildcard &&
!wildcardAllowed &&
showSubdomainInput &&
isWildcardSubdomain(subdomainInput) && (
<>
<p className="text-sm text-red-500">
{t(
"domainPickerWildcardSubdomainNotAllowed"
)}
</p>
<PaidFeaturesAlert
showBookADemo={false}
tiers={
tierMatrix[
TierFeature.WildcardSubdomain
]
}
/>
</>
)}
</div>
<div className="space-y-2">
@@ -592,23 +655,23 @@ export default function DomainPicker({
</span>
<span className="text-xs text-muted-foreground">
{orgDomain.type ===
"wildcard"
? t(
"domainPickerManual"
)
: (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</>
)}
"wildcard" ? (
t(
"domainPickerManual"
)
) : (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</>
)}
</span>
</div>
<Check
@@ -708,17 +771,17 @@ export default function DomainPicker({
</div>
{requiresPaywall && !hideFreeDomain && (
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<KeyRound className="size-4 shrink-0 text-black-500" />
<span>
{t("domainPickerFreeDomainsPaidFeature")}
</span>
</div>
</CardContent>
</Card>
)}
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<KeyRound className="size-4 shrink-0 text-black-500" />
<span>
{t("domainPickerFreeDomainsPaidFeature")}
</span>
</div>
</CardContent>
</Card>
)}
{/*showProvidedDomainSearch && build === "saas" && (
<Alert>
@@ -845,6 +908,22 @@ export default function DomainPicker({
)}
</div>
)}
{selectedBaseDomain?.domainType === "wildcard" &&
isWildcardSubdomain(subdomainInput) && (
<p className="text-sm text-muted-foreground">
{t("domainPickerWildcardCertWarning")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/wildcard-resources#requirements-for-wildcard-resources"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("domainPickerWildcardCertWarningLink")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</div>
);
}

View File

@@ -46,6 +46,7 @@ import { SitesSelector } from "@app/components/site-selector";
import type { Selectedsite } from "@app/components/site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import { SwitchInput } from "@app/components/SwitchInput";
export type HealthCheckConfig = {
hcEnabled: boolean;
@@ -118,7 +119,7 @@ const DEFAULT_VALUES = {
name: "",
hcEnabled: true,
hcMode: "http",
hcScheme: "https",
hcScheme: "http",
hcMethod: "GET",
hcHostname: "",
hcPort: "",
@@ -270,7 +271,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
name: initialValues.name,
hcEnabled: initialValues.hcEnabled,
hcMode: initialValues.hcMode ?? "http",
hcScheme: initialValues.hcScheme ?? "https",
hcScheme: initialValues.hcScheme ?? "http",
hcMethod: initialValues.hcMethod ?? "GET",
hcHostname: initialValues.hcHostname ?? "",
hcPort: initialValues.hcPort
@@ -407,7 +408,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
})
: t("standaloneHcDescription");
const showFields = mode === "submit" || watchedEnabled;
const disableTabInputs = mode === "autoSave" && !watchedEnabled;
const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp";
const isTcp = watchedMode === "tcp";
@@ -484,6 +485,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
onSelectSite={(site) => {
setSelectedSite(site);
}}
filterTypes={["newt"]}
/>
</PopoverContent>
</Popover>
@@ -491,6 +493,40 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
</div>
)}
{mode === "autoSave" && (
<div className="mt-5">
<FormField
control={form.control}
name="hcEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="hcEnabled"
label={t(
"enableHealthChecks"
)}
description={t(
"healthCheckDisabledStateDescription"
)}
checked={field.value}
onCheckedChange={(
value
) =>
handleChange(
"hcEnabled",
value,
field.onChange
)
}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
<div className="mt-5">
<HorizontalTabs
clientSide
@@ -513,121 +549,86 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
]}
>
{/* ── Strategy tab ──────────────────────── */}
<div className="space-y-4 mt-4 p-1">
{/* Enable toggle (autoSave mode only) */}
{mode === "autoSave" && (
<FormField
control={form.control}
name="hcEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div>
<FormLabel>
{t(
"enableHealthChecks"
)}
</FormLabel>
</div>
<FormControl>
<Switch
checked={
field.value
}
onCheckedChange={(
value
) =>
handleChange(
"hcEnabled",
value,
field.onChange
)
}
/>
</FormControl>
</FormItem>
)}
/>
)}
<div className="mt-4 p-1">
<fieldset
disabled={disableTabInputs}
className={cn(
"space-y-4",
disableTabInputs &&
"pointer-events-none opacity-60"
)}
>
{/* Strategy picker */}
{showFields && (
<FormField
control={form.control}
name="hcMode"
render={({ field }) => (
<FormItem>
<FormControl>
<StrategySelect
cols={2}
options={[
{
id: "http",
title: "HTTP",
description:
t(
"healthCheckStrategyHttp"
)
},
{
id: "tcp",
title: "TCP",
description:
t(
"healthCheckStrategyTcp"
)
},
{
id: "snmp",
title: "SNMP",
description:
t(
"healthCheckStrategySnmp"
)
},
{
id: "icmp",
title: "Ping (ICMP)",
description:
t(
"healthCheckStrategyIcmp"
)
}
]}
value={
field.value
}
onChange={(
value
) =>
handleChange(
"hcMode",
value,
field.onChange
<FormField
control={form.control}
name="hcMode"
render={({ field }) => (
<FormItem>
<FormControl>
<StrategySelect
cols={2}
options={[
{
id: "http",
title: "HTTP",
description: t(
"healthCheckStrategyHttp"
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
},
{
id: "tcp",
title: "TCP",
description: t(
"healthCheckStrategyTcp"
)
},
// lets hide these for now until they are implemented
// {
// id: "snmp",
// title: "SNMP",
// description: t(
// "healthCheckStrategySnmp"
// )
// },
// {
// id: "icmp",
// title: "Ping (ICMP)",
// description: t(
// "healthCheckStrategyIcmp"
// )
// }
]}
value={field.value}
onChange={(value) =>
handleChange(
"hcMode",
value,
field.onChange
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
{/* ── Connection tab ────────────────────── */}
<div className="space-y-4 mt-4 p-1">
{!showFields && (
<p className="text-sm text-muted-foreground">
{t("enableHealthChecks")}
</p>
)}
<div className="mt-4 p-1">
<fieldset
disabled={disableTabInputs}
className={cn(
"space-y-4",
disableTabInputs &&
"pointer-events-none opacity-60"
)}
>
{/* Contact-sales banner for SNMP / ICMP */}
{showFields && isSnmpOrIcmp && (
<ContactSalesBanner />
)}
{isSnmpOrIcmp && <ContactSalesBanner />}
{showFields && !isSnmpOrIcmp && (
{!isSnmpOrIcmp && (
<>
{/* Scheme / Hostname / Port */}
{isTcp ? (
@@ -1021,22 +1022,23 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
)}
</>
)}
</fieldset>
</div>
{/* ── Advanced tab ──────────────────────── */}
<div className="space-y-4 mt-4 p-1">
{!showFields && (
<p className="text-sm text-muted-foreground">
{t("enableHealthChecks")}
</p>
)}
<div className="mt-4 p-1">
<fieldset
disabled={disableTabInputs}
className={cn(
"space-y-4",
disableTabInputs &&
"pointer-events-none opacity-60"
)}
>
{/* Contact-sales banner for SNMP / ICMP */}
{showFields && isSnmpOrIcmp && (
<ContactSalesBanner />
)}
{isSnmpOrIcmp && <ContactSalesBanner />}
{showFields && !isSnmpOrIcmp && (
{!isSnmpOrIcmp && (
<>
{/* Healthy interval + threshold */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -1350,6 +1352,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
)}
</>
)}
</fieldset>
</div>
</HorizontalTabs>
</div>

View File

@@ -50,6 +50,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
type StandaloneHealthChecksTableProps = {
orgId: string;
@@ -150,7 +151,8 @@ export default function HealthChecksTable({
resourceId: resourceIdNum,
fullDomain: null,
niceId: "",
ssl: false
ssl: false,
wildcard: false
};
}, [initialFilterResource, resourceIdQ, resourceIdNum, t]);
@@ -165,7 +167,7 @@ export default function HealthChecksTable({
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
}, 30_000);
return () => clearInterval(interval);
}, [router]);
@@ -376,7 +378,7 @@ export default function HealthChecksTable({
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
@@ -445,7 +447,7 @@ export default function HealthChecksTable({
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
@@ -584,7 +586,7 @@ export default function HealthChecksTable({
<Switch
checked={r.hcEnabled}
disabled={
!isPaid || togglingId === r.targetHealthCheckId
!isPaid || togglingId === r.targetHealthCheckId || !!r.resourceId
}
onCheckedChange={(v) => handleToggleEnabled(r, v)}
/>

View File

@@ -4,18 +4,29 @@ import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols
cols,
columnSizing = "content"
}: {
children: React.ReactNode;
cols?: number;
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
columnSizing?: "fill" | "content";
}) {
const n = cols || 1;
const track =
columnSizing === "fill" ? "minmax(0, 1fr)" : "minmax(0, max-content)";
return (
<div
className={`grid grid-cols-2 md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start`}
className={cn(
"grid grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
columnSizing === "content" &&
"md:justify-items-start md:justify-start"
)}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value
"--columns": `repeat(${cols || 1}, minmax(0, 1fr))`
"--columns": `repeat(${n}, ${track})`
}}
>
{children}

View File

@@ -62,6 +62,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus";
import { UsersSelector } from "./users-selector";
import { RolesSelector } from "./roles-selector";
import { build } from "@server/build";
// --- Helpers (shared) ---
@@ -754,108 +755,139 @@ export function InternalResourceForm({
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 items-start mb-4">
<div className="min-w-0 col-span-1">
<FormField
control={form.control}
name="siteIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("sites")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length ===
0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<MultiSitesSelector
orgId={orgId}
selectedSites={
selectedSites
}
filterTypes={[
"newt"
]}
onSelectionChange={(
sites
) => {
setSelectedSites(
sites
);
field.onChange(
sites.map(
(s) =>
s.siteId
)
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="min-w-0 col-span-2">
<FormField
control={form.control}
name="mode"
render={({ field }) => {
const modeOptions: OptionSelectOption<InternalResourceMode>[] =
[
{
value: "host",
label: t(modeHostKey)
},
{
value: "cidr",
label: t(modeCidrKey)
},
{
value: "http",
label: t(modeHttpKey)
}
];
return (
<FormItem>
<div className="space-y-2 mb-4">
<div className="grid grid-cols-3 gap-4 items-start">
<div className="min-w-0 col-span-1">
<FormField
control={form.control}
name="siteIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t(modeLabelKey)}
{t("sites")}
</FormLabel>
<OptionSelect<InternalResourceMode>
options={modeOptions}
value={field.value}
onChange={
field.onChange
}
cols={3}
/>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length ===
0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<MultiSitesSelector
orgId={orgId}
selectedSites={
selectedSites
}
filterTypes={[
"newt"
]}
onSelectionChange={(
sites
) => {
setSelectedSites(
sites
);
field.onChange(
sites.map(
(
s
) =>
s.siteId
)
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
)}
/>
</div>
<div className="min-w-0 col-span-2">
<FormField
control={form.control}
name="mode"
render={({ field }) => {
const modeOptions: OptionSelectOption<InternalResourceMode>[] =
[
{
value: "host",
label: t(
modeHostKey
)
},
{
value: "cidr",
label: t(
modeCidrKey
)
},
{
value: "http",
label: t(
modeHttpKey
)
}
];
return (
<FormItem>
<FormLabel>
{t(modeLabelKey)}
</FormLabel>
<OptionSelect<InternalResourceMode>
options={
modeOptions
}
value={field.value}
onChange={
field.onChange
}
cols={3}
/>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</div>
{selectedSites.length > 1 && (
<p className="text-sm text-muted-foreground">
{t(
"internalResourceFormMultiSiteRoutingHelp"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t(
"internalResourceFormMultiSiteRoutingHelpLearnMore"
)}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</div>
<div
className={cn(
@@ -1147,9 +1179,14 @@ export function InternalResourceForm({
{variant === "edit" &&
resource?.domainId &&
httpConfigFullDomain &&
httpConfigDomainId ===
resource.domainId &&
httpConfigFullDomain ===
resource.fullDomain &&
build != "oss" &&
form.watch("ssl") && (
<div className="flex items-center gap-1 pt-1">
<span className="text-sm font-medium text-muted-foreground">
<div className="flex items-center gap-2 pt-1">
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
<CertificateStatus

View File

@@ -204,7 +204,7 @@ export default function InviteStatusCard({
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
<CardTitle className="text-center text-2xl font-semibold">
{loading ? t("checkingInvite") : t("inviteNotAccepted")}
</CardTitle>
</CardHeader>

View File

@@ -67,7 +67,7 @@ type SiteResource = {
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
type: 'site';
type: "site";
};
type MemberResourcesPortalProps = {
@@ -130,7 +130,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
resource.whitelist;
const hasAnyInfo =
Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled;
Boolean(resource.siteName) ||
Boolean(hasAuthMethods) ||
!resource.enabled;
if (!hasAnyInfo) return null;
@@ -353,7 +355,9 @@ export default function MemberResourcesPortal({
const [resources, setResources] = useState<Resource[]>([]);
const [siteResources, setSiteResources] = useState<SiteResource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [filteredSiteResources, setFilteredSiteResources] = useState<SiteResource[]>([]);
const [filteredSiteResources, setFilteredSiteResources] = useState<
SiteResource[]
>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -381,7 +385,9 @@ export default function MemberResourcesPortal({
setResources(response.data.data.resources);
setSiteResources(response.data.data.siteResources || []);
setFilteredResources(response.data.data.resources);
setFilteredSiteResources(response.data.data.siteResources || []);
setFilteredSiteResources(
response.data.data.siteResources || []
);
} else {
setError("Failed to load resources");
}
@@ -459,9 +465,10 @@ export default function MemberResourcesPortal({
case "domain-asc":
case "domain-desc":
// Sort by destination for site resources
const destCompare = sortBy === "domain-asc"
? a.destination.localeCompare(b.destination)
: b.destination.localeCompare(a.destination);
const destCompare =
sortBy === "domain-asc"
? a.destination.localeCompare(b.destination)
: b.destination.localeCompare(a.destination);
return destCompare;
case "status-enabled":
return b.enabled ? 1 : -1;
@@ -487,12 +494,14 @@ export default function MemberResourcesPortal({
startIndex + itemsPerPage
);
const remainingSlots = itemsPerPage - paginatedResources.length;
const paginatedSiteResources = remainingSlots > 0
? filteredSiteResources.slice(
Math.max(0, startIndex - filteredResources.length),
Math.max(0, startIndex - filteredResources.length) + remainingSlots
)
: [];
const paginatedSiteResources =
remainingSlots > 0
? filteredSiteResources.slice(
Math.max(0, startIndex - filteredResources.length),
Math.max(0, startIndex - filteredResources.length) +
remainingSlots
)
: [];
const handleOpenResource = (resource: Resource) => {
// Open the resource in a new tab
@@ -640,7 +649,8 @@ export default function MemberResourcesPortal({
</div>
{/* Resources Content */}
{filteredResources.length === 0 && filteredSiteResources.length === 0 ? (
{filteredResources.length === 0 &&
filteredSiteResources.length === 0 ? (
/* Enhanced Empty State */
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
@@ -697,87 +707,96 @@ export default function MemberResourcesPortal({
Public Resources
</h3>
<p className="text-sm text-muted-foreground mt-1">
Web applications and services accessible via browser
Web applications and services accessible via
browser
</p>
</div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
{paginatedResources.map((resource) => (
<Card key={resource.resourceId}>
<div className="p-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{resource.name}
</CardTitle>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{resource.name}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Card key={resource.resourceId}>
<div className="p-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-semibold text-foreground truncate group-hover:text-primary transition-colors">
{
resource.name
}
</CardTitle>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{
resource.name
}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<ResourceInfo
resource={resource}
/>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() =>
handleOpenResource(
resource
)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled}
>
{resource.domain.replace(
/^https?:\/\//,
""
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-shrink-0">
<ResourceInfo resource={resource} />
<div className="p-6 pt-0 mt-auto">
<Button
onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() =>
handleOpenResource(resource)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled}
>
{resource.domain.replace(
/^https?:\/\//,
""
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<Button
onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div>
</Card>
))}
</div>
</Card>
))}
</div>
</>
)}
@@ -790,7 +809,8 @@ export default function MemberResourcesPortal({
Private Resources
</h3>
<p className="text-sm text-muted-foreground mt-1">
Internal network resources accessible via client
Internal network resources accessible via
client
</p>
</div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -802,13 +822,17 @@ export default function MemberResourcesPortal({
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{siteResource.name}
<CardTitle className="text-lg font-semibold text-foreground truncate group-hover:text-primary transition-colors">
{
siteResource.name
}
</CardTitle>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{siteResource.name}
{
siteResource.name
}
</p>
</TooltipContent>
</Tooltip>
@@ -818,39 +842,63 @@ export default function MemberResourcesPortal({
<div className="flex-shrink-0">
<InfoPopup>
<div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5">Resource Details</div>
<div className="text-xs font-medium mb-1.5">
Resource Details
</div>
<div>
<span className="font-medium">Mode:</span>
<span className="font-medium">
Mode:
</span>
<span className="ml-2 text-muted-foreground capitalize">
{siteResource.mode}
{
siteResource.mode
}
</span>
</div>
{siteResource.protocol && (
<div>
<span className="font-medium">Protocol:</span>
<span className="font-medium">
Protocol:
</span>
<span className="ml-2 text-muted-foreground uppercase">
{siteResource.protocol}
{
siteResource.protocol
}
</span>
</div>
)}
<div>
<span className="font-medium">Destination:</span>
<span className="font-medium">
Destination:
</span>
<span className="ml-2 text-muted-foreground">
{siteResource.destination}
{
siteResource.destination
}
</span>
</div>
{siteResource.alias && (
<div>
<span className="font-medium">Alias:</span>
<span className="font-medium">
Alias:
</span>
<span className="ml-2 text-muted-foreground">
{siteResource.alias}
{
siteResource.alias
}
</span>
</div>
)}
<div>
<span className="font-medium">Status:</span>
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
{siteResource.enabled ? 'Enabled' : 'Disabled'}
<span className="font-medium">
Status:
</span>
<span
className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`}
>
{siteResource.enabled
? "Enabled"
: "Disabled"}
</span>
</div>
</div>
@@ -864,7 +912,9 @@ export default function MemberResourcesPortal({
{/* Alias as primary */}
<div className="flex items-center gap-2 mb-1">
<div className="text-base font-semibold text-foreground text-left truncate flex-1">
{siteResource.alias}
{
siteResource.alias
}
</div>
<Button
variant="ghost"
@@ -887,14 +937,18 @@ export default function MemberResourcesPortal({
</div>
{/* Destination as secondary */}
<div className="text-xs text-muted-foreground truncate">
{siteResource.destination}
{
siteResource.destination
}
</div>
</>
) : (
/* Destination as primary when no alias */
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
{siteResource.destination}
{
siteResource.destination
}
</div>
<Button
variant="ghost"

View File

@@ -32,9 +32,7 @@ export function OptionSelect<TValue extends string>({
}: OptionSelectProps<TValue>) {
return (
<div className={className}>
{label && (
<p className="font-bold mb-3">{label}</p>
)}
{label && <p className="font-semibold mb-3">{label}</p>}
<div
className={cn(
"grid gap-2",
@@ -51,7 +49,11 @@ export function OptionSelect<TValue extends string>({
<Button
key={option.value}
type="button"
variant={isSelected ? "squareOutlinePrimary" : "squareOutline"}
variant={
isSelected
? "squareOutlinePrimary"
: "squareOutline"
}
className={cn(
"flex-1 min-w-30 shadow-none",
isSelected && "bg-primary/10"

View File

@@ -86,7 +86,7 @@ export function OrgSelector({
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center min-w-0 flex-1">
<div className="flex flex-col items-start min-w-0 flex-1 gap-1">
<span className="font-bold">
<span className="font-semibold">
{t("org")}
</span>
<span className="text-sm text-muted-foreground truncate w-full text-left">

View File

@@ -70,7 +70,7 @@ export default function OrganizationLandingCard(
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center text-3xl font-bold">
<CardTitle className="flex items-center text-3xl font-semibold">
{orgData.overview.orgName}
</CardTitle>
</CardHeader>
@@ -82,7 +82,7 @@ export default function OrganizationLandingCard(
className="flex flex-col items-center p-4 bg-secondary rounded-lg"
>
{stat.icon}
<span className="mt-2 text-2xl font-bold">
<span className="mt-2 text-2xl font-semibold">
{stat.value}
</span>
<span className="text-sm text-muted-foreground">

View File

@@ -114,9 +114,10 @@ function getDocsLinkRenderer(href: string) {
type Props = {
tiers: Tier[];
showBookADemo?: boolean;
};
export function PaidFeaturesAlert({ tiers }: Props) {
export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
const t = useTranslations();
const params = useParams();
const orgId = params?.orgId as string | undefined;
@@ -134,7 +135,9 @@ export function PaidFeaturesAlert({ tiers }: Props) {
const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
const bookADemoLinkRenderer = getBookADemoLinkRenderer();
const bookADemoLinkRenderer = showBookADemo
? getBookADemoLinkRenderer()
: () => null;
if (env.flags.disableEnterpriseFeatures) {
return null;

View File

@@ -2,6 +2,11 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
@@ -11,15 +16,22 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
@@ -29,6 +41,7 @@ import {
ChevronDown,
ChevronsUpDownIcon,
Clock,
Funnel,
MoreHorizontal,
ShieldCheck,
ShieldOff,
@@ -39,6 +52,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
@@ -49,14 +63,9 @@ import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import type { StatusHistoryResponse } from "@server/lib/statusHistory";
import UptimeMiniBar from "./UptimeMiniBar";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type TargetHealth = {
targetId: number;
@@ -79,58 +88,31 @@ export type ResourceRow = {
proxyPort: number | null;
enabled: boolean;
domainId?: string;
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
fullDomain?: string | null;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
sites: ResourceSiteRow[];
};
function getOverallHealthStatus(
targets?: TargetHealth[]
): "online" | "degraded" | "offline" | "unknown" {
if (!targets || targets.length === 0) {
return "unknown";
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
if (monitoredTargets.length === 0) {
return "unknown";
}
const healthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "healthy"
).length;
const unhealthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "unhealthy"
).length;
if (healthyCount === monitoredTargets.length) {
return "online";
} else if (unhealthyCount === monitoredTargets.length) {
return "offline";
} else {
return "degraded";
}
}
function StatusIcon({
status,
className = ""
}: {
status: "online" | "degraded" | "offline" | "unknown";
status: string | undefined | null;
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "online":
case "healthy":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
case "unhealthy":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
@@ -144,13 +126,15 @@ type ProxyResourcesTableProps = {
orgId: string;
pagination: PaginationState;
rowCount: number;
initialFilterSite?: Selectedsite | null;
};
export default function ProxyResourcesTable({
resources,
orgId,
pagination,
rowCount
rowCount,
initialFilterSite = null
}: ProxyResourcesTableProps) {
const router = useRouter();
const {
@@ -170,13 +154,30 @@ export default function ProxyResourcesTable({
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
}, 30_000);
return () => clearInterval(interval);
}, []);
}, [router]);
const refreshData = () => {
startTransition(() => {
@@ -231,12 +232,18 @@ export default function ProxyResourcesTable({
}
}
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
const overallStatus = getOverallHealthStatus(targets);
function TargetStatusCell({
targets,
healthStatus
}: {
targets?: TargetHealth[];
healthStatus?: string;
}) {
const overallStatus = healthStatus;
if (!targets || targets.length === 0) {
return (
<div id="LOOK_FOR_ME" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{t("resourcesTableNoTargets")}
@@ -262,12 +269,12 @@ export default function ProxyResourcesTable({
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "online" &&
{overallStatus === "healthy" &&
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "offline" &&
t("resourcesTableOffline")}
{overallStatus === "unhealthy" &&
t("resourcesTableUnhealthy")}
{overallStatus === "unknown" &&
t("resourcesTableUnknown")}
</span>
@@ -375,6 +382,66 @@ export default function ProxyResourcesTable({
return <span>{row.original.nice || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => (
<ResourceSitesStatusCell
orgId={row.original.orgId}
resourceSites={row.original.sites}
/>
)
},
{
accessorKey: "protocol",
friendlyName: t("protocol"),
@@ -396,7 +463,7 @@ export default function ProxyResourcesTable({
{
id: "status",
accessorKey: "status",
friendlyName: t("status"),
friendlyName: t("health"),
header: () => (
<ColumnFilterButton
options={[
@@ -405,10 +472,9 @@ export default function ProxyResourcesTable({
value: "degraded",
label: t("resourcesTableDegraded")
},
{ value: "offline", label: t("resourcesTableOffline") },
{
value: "no_targets",
label: t("resourcesTableNoTargets")
value: "unhealthy",
label: t("resourcesTableUnhealthy")
},
{ value: "unknown", label: t("resourcesTableUnknown") }
]}
@@ -420,21 +486,29 @@ export default function ProxyResourcesTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("status")}
label={t("health")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />;
return (
<TargetStatusCell
targets={resourceRow.targets}
healthStatus={resourceRow.health}
/>
);
},
sortingFn: (rowA, rowB) => {
const statusA = getOverallHealthStatus(rowA.original.targets);
const statusB = getOverallHealthStatus(rowB.original.targets);
const statusA = rowA.original.health;
const statusB = rowB.original.health;
if (!statusA && !statusB) return 0;
if (!statusA) return 1;
if (!statusB) return -1;
const statusOrder = {
online: 3,
healthy: 3,
degraded: 2,
offline: 1,
unhealthy: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
@@ -446,9 +520,7 @@ export default function ProxyResourcesTable({
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
);
return <UptimeMiniBar resourceId={resourceRow.id} days={30} />;
}
},
{
@@ -457,24 +529,52 @@ export default function ProxyResourcesTable({
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
if (!resourceRow.http) {
return (
<div className="flex items-center gap-2 min-w-0">
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
) : !resourceRow.domainId ? (
</div>
);
}
if (!resourceRow.domainId) {
return (
<div className="flex items-center gap-2 min-w-0">
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
</div>
);
}
const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl &&
certHostname != null &&
certHostname !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{showHttpsCertIndicator ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={certHostname}
/>
) : null}
<div className="">
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
</div>
);
}
@@ -620,6 +720,16 @@ export default function ProxyResourcesTable({
});
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);

View File

@@ -0,0 +1,179 @@
"use client";
import { CertificateStatusContent } from "@app/components/CertificateStatus";
import {
Popover,
PopoverAnchor,
PopoverContent
} from "@app/components/ui/popover";
import { useCertificate } from "@app/hooks/useCertificate";
import { cn } from "@app/lib/cn";
import { FileBadge } from "lucide-react";
import { useTranslations } from "next-intl";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode
} from "react";
type ResourceAccessCertIndicatorProps = {
orgId: string;
domainId: string;
fullDomain: string;
};
function getStatusColor(status: string) {
switch (status) {
case "valid":
return "text-green-500";
case "pending":
case "requested":
return "text-yellow-500";
case "expired":
case "failed":
return "text-red-500";
default:
return "text-muted-foreground";
}
}
/** Compact cert icon + hover popover with full certificate status (shared by proxy and client resource tables). */
export function ResourceAccessCertIndicator({
orgId,
domainId,
fullDomain
}: ResourceAccessCertIndicatorProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const certificate = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch: true,
polling: open,
pollingInterval: 5000
});
const { cert, certLoading, certError, refreshing, fetchCert } = certificate;
useEffect(() => {
if (!open) return;
void fetchCert(false);
}, [open, fetchCert]);
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current != null) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = setTimeout(() => setOpen(false), 280);
}, [clearCloseTimer]);
const handleEnterOpen = useCallback(() => {
clearCloseTimer();
setOpen(true);
}, [clearCloseTimer]);
useEffect(() => {
return () => clearCloseTimer();
}, [clearCloseTimer]);
let triggerBody: ReactNode;
if (certLoading) {
triggerBody = (
<div
className={cn(
"h-4 w-4 shrink-0 rounded-[2px] animate-pulse",
"bg-neutral-200 dark:bg-neutral-700"
)}
aria-busy="true"
aria-label={t("loading")}
/>
);
} else if (refreshing) {
triggerBody = (
<FileBadge
className={cn(
"h-4 w-4 shrink-0 animate-spin",
cert ? getStatusColor(cert.status) : "text-muted-foreground"
)}
aria-hidden
/>
);
} else if (certError) {
triggerBody = (
<FileBadge className="h-4 w-4 shrink-0 text-red-500" aria-hidden />
);
} else if (cert) {
triggerBody = (
<FileBadge
className={cn("h-4 w-4", getStatusColor(cert.status))}
aria-hidden
/>
);
} else {
triggerBody = (
<FileBadge
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center shrink-0 rounded-[2px] outline-offset-2",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring",
certError && "text-red-500"
)}
onMouseEnter={handleEnterOpen}
onMouseLeave={scheduleClose}
onClick={(e) => {
e.preventDefault();
setOpen((v) => !v);
}}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={t("certificateStatus")}
>
{triggerBody}
</button>
</PopoverAnchor>
<PopoverContent
className="w-72 p-4"
align="start"
side="bottom"
sideOffset={6}
onMouseEnter={clearCloseTimer}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-3">
<CertificateStatusContent
cert={certificate.cert}
certLoading={certificate.certLoading}
certError={certificate.certError}
refreshing={certificate.refreshing}
refreshCert={certificate.refreshCert}
showLabel
/>
<p className="text-sm text-muted-foreground">
{t("certificateStatusAutoRefreshHint")}
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -17,7 +17,7 @@ export default function ResourceAccessDenied() {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
<CardTitle className="text-center text-2xl font-semibold">
{t("accessDenied")}
</CardTitle>
</CardHeader>

View File

@@ -1,7 +1,15 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ShieldCheck, ShieldOff, Eye, EyeOff } from "lucide-react";
import {
ShieldCheck,
ShieldOff,
Eye,
EyeOff,
CheckCircle2,
XCircle,
Clock
} from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
@@ -13,13 +21,12 @@ import {
import { useTranslations } from "next-intl";
import CertificateStatus from "@app/components/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { build } from "@server/build";
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const { resource, authInfo, updateResource } = useResourceContext();
const { env } = useEnvContext();
const { resource, authInfo } = useResourceContext();
const t = useTranslations();
@@ -29,9 +36,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections
cols={resource.http ? 5 : 4}
>
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
@@ -43,10 +48,14 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
{resource.wildcard ? (
<span>{fullUrl}</span>
) : (
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -60,12 +69,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ||
authInfo.headerAuth ? (
<div className="flex items-start space-x-2">
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4 flex-shrink-0" />
<div className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("notProtected")}</span>
</div>
)}
@@ -136,7 +145,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* Certificate Status Column */}
{resource.http &&
resource.domainId &&
resource.fullDomain && (
resource.fullDomain &&
build != "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
@@ -155,6 +165,36 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
{resource.health === "healthy" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("resourcesTableHealthy")}</span>
</div>
)}
{resource.health === "degraded" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span>
</div>
)}
{resource.health === "unhealthy" && (
<div className="flex items-center space-x-2">
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
<span>{t("resourcesTableUnhealthy")}</span>
</div>
)}
{(!resource.health ||
resource.health === "unknown") && (
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 flex-shrink-0" />
<span>{t("resourcesTableUnknown")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -15,7 +15,7 @@ export default async function ResourceNotFound() {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
<CardTitle className="text-center text-2xl font-semibold">
{t("resourceNotFound")}
</CardTitle>
</CardHeader>

View File

@@ -0,0 +1,141 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { cn } from "@app/lib/cn";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
export type ResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online?: boolean | null;
};
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline" | "unknown";
function aggregateSitesStatus(
resourceSites: ResourceSiteRow[]
): AggregateSitesStatus {
if (resourceSites.length === 0) {
return "allOffline";
}
const knownStatuses = resourceSites
.map((rs) => rs.online)
.filter((status): status is boolean => typeof status === "boolean");
if (knownStatuses.length === 0) {
return "unknown";
}
const onlineCount = knownStatuses.filter(Boolean).length;
if (onlineCount === knownStatuses.length) return "allOnline";
if (onlineCount > 0) return "partial";
return "allOffline";
}
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
switch (status) {
case "allOnline":
return "bg-green-500";
case "partial":
return "bg-yellow-500";
case "allOffline":
return "bg-neutral-500";
case "unknown":
default:
return "border border-muted-foreground/50 bg-transparent";
}
}
export function ResourceSitesStatusCell({
orgId,
resourceSites
}: {
orgId: string;
resourceSites: ResourceSiteRow[];
}) {
const t = useTranslations();
if (resourceSites.length === 0) {
return <span>-</span>;
}
const aggregate = aggregateSitesStatus(resourceSites);
const countLabel = t("multiSitesSelectorSitesCount", {
count: resourceSites.length
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
aggregateStatusDotClass(aggregate)
)}
/>
<span className="text-sm tabular-nums">{countLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
{resourceSites.map((site) => {
const isOnline = site.online;
const hasKnownStatus = typeof isOnline === "boolean";
return (
<DropdownMenuItem key={site.siteId} asChild>
<Link
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
className="flex cursor-pointer items-center justify-between gap-4"
>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
!hasKnownStatus
? "border border-muted-foreground/50 bg-transparent"
: isOnline
? "bg-green-500"
: "bg-neutral-500"
)}
/>
<span className="truncate">
{site.siteName}
</span>
</div>
<span
className={cn(
"shrink-0 capitalize",
hasKnownStatus && isOnline
? "text-green-600"
: "text-muted-foreground"
)}
>
{!hasKnownStatus
? t("resourcesTableUnknown")
: isOnline
? t("online")
: t("offline")}
</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createRole?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function RolesDataTable<TData, TValue>({
columns,
data,
createRole,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="roles-table"
title={t("roles")}
searchPlaceholder={t("accessRolesSearch")}
searchColumn="name"
onAdd={createRole}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessRolesAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -38,7 +38,7 @@ export function SettingsSectionTitle({
children: React.ReactNode;
}) {
return (
<h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{children}
</h2>
);

View File

@@ -16,7 +16,7 @@ export default function SettingsSectionTitle({
<h2
className={`text-${
size ? size : "2xl"
} font-bold tracking-tight`}
} font-semibold tracking-tight`}
>
{title}
</h2>

View File

@@ -14,7 +14,7 @@ import {
} from "@app/components/ui/tooltip";
import { useTranslations } from "next-intl";
const TRIAL_DURATION_DAYS = 14;
const TRIAL_DURATION_DAYS = 10;
export default function ShowTrialCard({
isCollapsed

View File

@@ -0,0 +1,513 @@
"use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Button } from "@app/components/ui/button";
import { InfoPopup } from "@app/components/ui/info-popup";
import { SettingsContainer } from "@app/components/Settings";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { ListResourcesResponse } from "@server/routers/resource";
import type ResponseT from "@server/types/Response";
import { useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams } from "next/navigation";
import { toUnicode } from "punycode";
import { useMemo, useState, type ReactNode } from "react";
const INITIAL_PAGE_SIZE = 5;
const LOAD_MORE_INCREMENT = 20;
type SiteResourceRow =
ListAllSiteResourcesByOrgResponse["siteResources"][number];
type PublicResourceRow = ListResourcesResponse["resources"][number];
function isForbidden(e: unknown): boolean {
return isAxiosError(e) && e.response?.status === 403;
}
function isSafeUrlForLink(href: string): boolean {
try {
void new URL(href);
return true;
} catch {
return false;
}
}
/** Meta text inside the left column (width comes from the column wrapper). */
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
function publicProtocolLabel(r: PublicResourceRow): string {
if (r.http) {
return r.ssl ? "HTTPS" : "HTTP";
}
const p = (r.protocol || "").toLowerCase();
if (p === "tcp") return "TCP";
if (p === "udp") return "UDP";
return (r.protocol || "—").toUpperCase();
}
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
return (
<div className={OVERVIEW_META_CLASS}>
<div className="truncate font-medium text-foreground">
{publicProtocolLabel(r)}
</div>
</div>
);
}
function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
const t = useTranslations();
const modeLabel: Record<SiteResourceRow["mode"], string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp")
};
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.destinationPort ?? null,
scheme: row.scheme
});
return (
<div
className={OVERVIEW_META_CLASS}
title={`${modeLabel[row.mode]}\n${dest}`}
>
<div className="truncate font-medium text-foreground">
{modeLabel[row.mode]}
</div>
</div>
);
}
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
const t = useTranslations();
if (!r.http) {
return (
<CopyToClipboard
text={r.proxyPort?.toString() ?? ""}
isLink={false}
/>
);
}
if (!r.domainId) {
return (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
);
}
const fullUrl = `${r.ssl ? "https" : "http"}://${toUnicode(r.fullDomain || "")}`;
return (
<CopyToClipboard
text={fullUrl}
isLink={isSafeUrlForLink(fullUrl)}
displayText={fullUrl}
/>
);
}
function PrivateAccessMethod({ row }: { row: SiteResourceRow }) {
if (row.mode === "http" && row.fullDomain) {
const url = `${row.ssl ? "https" : "http"}://${toUnicode(row.fullDomain)}`;
return (
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
);
}
if (row.mode === "host" && row.alias) {
return (
<CopyToClipboard
text={row.alias}
isLink={false}
displayText={row.alias}
/>
);
}
const fromAlias = row.alias?.trim();
if (fromAlias) {
return (
<CopyToClipboard
text={fromAlias}
isLink={false}
displayText={fromAlias}
/>
);
}
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.destinationPort,
scheme: row.scheme
});
return (
<CopyToClipboard
text={dest}
isLink={isSafeUrlForLink(dest)}
displayText={dest}
/>
);
}
type OverviewRow = {
key: number;
meta: ReactNode;
name: string;
access: ReactNode;
editHref: string;
};
type OverviewColumnProps = {
title: string;
description: string;
viewAllHref: string;
viewAllLabel: string;
emptyLabel: string;
isForbidden: boolean;
isFetching: boolean;
/** When there are no rows and the first fetch (no SSR initial data) is in flight. */
isLoading: boolean;
rows: OverviewRow[];
canShowMore: boolean;
onShowMore: () => void;
};
function OverviewColumn({
title,
description,
viewAllHref,
viewAllLabel,
emptyLabel,
isForbidden,
isFetching,
isLoading,
rows,
canShowMore,
onShowMore
}: OverviewColumnProps) {
const t = useTranslations();
const header = (
<div className="border-b px-5 py-5">
<div className="flex items-start justify-between gap-4">
<div className="text-lg space-y-0.5 pb-6">
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{title}
</h2>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
<Link
href={viewAllHref}
className="shrink-0 text-muted-foreground text-sm hover:underline"
>
{viewAllLabel}
</Link>
</div>
</div>
);
if (isForbidden) {
return (
<div className="min-w-0 overflow-hidden rounded-lg border h-full flex flex-col">
{header}
<p className="px-5 py-3 text-sm text-muted-foreground">
{t("siteResourcesPermissionDenied")}
</p>
</div>
);
}
return (
<div className="min-w-0 overflow-hidden rounded-lg border h-full flex flex-col">
{header}
{rows.length === 0 ? (
<div className="flex flex-1 items-center justify-center px-5 py-3 min-h-24">
{isLoading ? (
<div
className="flex flex-col items-center justify-center gap-2"
role="status"
>
<Loader2
className="h-6 w-6 animate-spin text-muted-foreground"
aria-hidden
/>
<span className="sr-only">{t("loading")}</span>
</div>
) : (
<p className="text-center text-sm text-muted-foreground">
{emptyLabel}
</p>
)}
</div>
) : (
<>
<div className="relative flex-1">
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-25 border-l border-border"
/>
<ul className="relative divide-y">
{rows.map((row) => (
<li key={row.key} className="flex">
<div className="w-25 min-w-0 shrink-0 px-5 py-3">
{row.meta}
</div>
<div className="min-w-0 min-h-0 flex-1 px-5 py-3">
<div className="truncate text-sm font-medium">
{row.name}
</div>
<div className="mt-1 min-w-0 break-words text-sm text-muted-foreground">
{row.access}
</div>
</div>
<div className="flex shrink-0 items-center px-5 py-3">
<Button
asChild
type="button"
variant="outline"
>
<Link href={row.editHref}>
{t("edit")}
</Link>
</Button>
</div>
</li>
))}
</ul>
</div>
{canShowMore ? (
<div className="border-t px-5 py-3 text-center">
<button
type="button"
onClick={onShowMore}
disabled={isFetching}
className="text-sm hover:underline text-muted-foreground cursor-pointer"
>
{isFetching
? t("loading")
: t("siteResourcesShowMore")}
</button>
</div>
) : null}
</>
)}
</div>
);
}
type SiteResourcesOverviewProps = {
siteId: number;
initialPublicData: ListResourcesResponse | null;
initialPrivateData: ListAllSiteResourcesByOrgResponse | null;
initialPublicForbidden: boolean;
initialPrivateForbidden: boolean;
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
orgIdOverride?: string;
};
export default function SiteResourcesOverview({
siteId,
initialPublicData,
initialPrivateData,
initialPublicForbidden,
initialPrivateForbidden,
orgIdOverride
}: SiteResourcesOverviewProps) {
const t = useTranslations();
const params = useParams<{ orgId: string }>();
const orgId = orgIdOverride ?? params.orgId;
const { env } = useEnvContext();
const api = useMemo(() => createApiClient({ env }), [env]);
const enabled = Boolean(orgId && siteId);
const [publicPageSize, setPublicPageSize] = useState(INITIAL_PAGE_SIZE);
const [privatePageSize, setPrivatePageSize] = useState(INITIAL_PAGE_SIZE);
const publicQuery = useQuery({
queryKey: [
"siteResourcesOverview",
"public",
orgId,
siteId,
publicPageSize
] as const,
enabled: enabled && !initialPublicForbidden,
initialData: initialPublicData ?? undefined,
queryFn: async (): Promise<ListResourcesResponse> => {
const sp = new URLSearchParams({
page: "1",
pageSize: String(publicPageSize),
siteId: String(siteId)
});
const res = await api.get(
`/org/${orgId}/resources?${sp.toString()}`
);
const envelope = res.data as ResponseT<ListResourcesResponse>;
const payload = envelope.data;
if (!payload) {
throw new Error("No data");
}
return payload;
}
});
const privateQuery = useQuery({
queryKey: [
"siteResourcesOverview",
"private",
orgId,
siteId,
privatePageSize
] as const,
enabled: enabled && !initialPrivateForbidden,
initialData: initialPrivateData ?? undefined,
queryFn: async (): Promise<ListAllSiteResourcesByOrgResponse> => {
const sp = new URLSearchParams({
page: "1",
pageSize: String(privatePageSize),
siteId: String(siteId)
});
const res = await api.get(
`/org/${orgId}/site-resources?${sp.toString()}`
);
const envelope =
res.data as ResponseT<ListAllSiteResourcesByOrgResponse>;
const payload = envelope.data;
if (!payload) {
throw new Error("No data");
}
return payload;
}
});
const publicList = publicQuery.data?.resources ?? [];
const publicTotal = publicQuery.data?.pagination.total ?? 0;
const privateList = privateQuery.data?.siteResources ?? [];
const privateTotal = privateQuery.data?.pagination.total ?? 0;
const publicForbidden =
initialPublicForbidden ||
(publicQuery.isError && isForbidden(publicQuery.error));
const privateForbidden =
initialPrivateForbidden ||
(privateQuery.isError && isForbidden(privateQuery.error));
const waitingOnPublicList =
enabled && !publicForbidden && publicQuery.isPending;
const waitingOnPrivateList =
enabled && !privateForbidden && privateQuery.isPending;
const showEmptyPlaceholder =
!waitingOnPublicList &&
!waitingOnPrivateList &&
!publicForbidden &&
!privateForbidden &&
publicList.length === 0 &&
privateList.length === 0;
const publicViewAllHref = `/${orgId}/settings/resources/proxy?siteId=${siteId}`;
const privateViewAllHref = `/${orgId}/settings/resources/client?siteId=${siteId}`;
const publicRows = publicList.map((r) => ({
key: r.resourceId,
meta: <PublicResourceMeta resource={r} />,
name: r.name,
access: <PublicAccessMethod resource={r} />,
editHref: `/${orgId}/settings/resources/proxy/${r.niceId}`
}));
const privateRows = privateList.map((row) => {
const qs = new URLSearchParams({
siteId: String(siteId),
query: row.niceId
});
return {
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: `/${orgId}/settings/resources/client?${qs.toString()}`
};
});
if (showEmptyPlaceholder) {
return (
<SettingsContainer>
<p className="pt-2 text-sm text-muted-foreground">
{t("siteResourcesNoneOnSite")}
</p>
</SettingsContainer>
);
}
const publicEmptyLoading =
enabled &&
!publicForbidden &&
publicRows.length === 0 &&
publicQuery.isPending;
const privateEmptyLoading =
enabled &&
!privateForbidden &&
privateRows.length === 0 &&
privateQuery.isPending;
const publicColumn = (
<OverviewColumn
key="public"
title={t("siteResourcesSectionPublic")}
description={t("siteResourcesSectionPublicDescription")}
viewAllHref={publicViewAllHref}
viewAllLabel={t("siteResourcesViewAllPublic")}
emptyLabel={t("siteResourcesEmptyPublic")}
isForbidden={publicForbidden}
isFetching={publicQuery.isFetching}
isLoading={publicEmptyLoading}
rows={publicRows}
canShowMore={publicList.length < publicTotal}
onShowMore={() => setPublicPageSize((n) => n + LOAD_MORE_INCREMENT)}
/>
);
const privateColumn = (
<OverviewColumn
key="private"
title={t("siteResourcesSectionPrivate")}
description={t("siteResourcesSectionPrivateDescription")}
viewAllHref={privateViewAllHref}
viewAllLabel={t("siteResourcesViewAllPrivate")}
emptyLabel={t("siteResourcesEmptyPrivate")}
isForbidden={privateForbidden}
isFetching={privateQuery.isFetching}
isLoading={privateEmptyLoading}
rows={privateRows}
canShowMore={privateList.length < privateTotal}
onShowMore={() =>
setPrivatePageSize((n) => n + LOAD_MORE_INCREMENT)
}
/>
);
return (
<SettingsContainer>
<div className="grid gap-6 md:grid-cols-2">
{publicColumn}
{privateColumn}
</div>
</SettingsContainer>
);
}

View File

@@ -24,6 +24,7 @@ import {
ArrowRight,
ArrowUp10Icon,
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
@@ -34,6 +35,16 @@ import { useState, useTransition, useEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
ControlledDataTable,
type ExtendedColumnDef
@@ -49,11 +60,12 @@ export type SiteRow = {
type: "newt" | "wireguard" | "local";
newtVersion?: string;
newtUpdateAvailable?: boolean;
online: boolean;
online?: boolean | null;
address?: string;
exitNodeName?: string;
exitNodeEndpoint?: string;
remoteExitNodeId?: string;
resourceCount: number;
};
type SitesTableProps = {
@@ -79,6 +91,8 @@ export default function SitesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
@@ -88,7 +102,7 @@ export default function SitesTable({
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
}, 30_000);
return () => clearInterval(interval);
}, []);
@@ -239,9 +253,7 @@ export default function SitesTable({
if (originalRow.type == "local") {
return <span>-</span>;
}
return (
<UptimeMiniBar siteId={originalRow.id} days={30} />
);
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
}
},
{
@@ -341,6 +353,29 @@ export default function SitesTable({
}
}
},
{
id: "resources",
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
);
}
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
@@ -437,6 +472,22 @@ export default function SitesTable({
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
>
<DropdownMenuItem>
{t("sitesTableViewPublicResources")}
</DropdownMenuItem>
</Link>
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
>
<DropdownMenuItem>
{t("sitesTableViewPrivateResources")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
@@ -489,6 +540,43 @@ export default function SitesTable({
return (
<>
<Credenza
open={Boolean(resourcesDialogSite)}
onOpenChange={(open) => {
if (!open) setResourcesDialogSite(null);
}}
>
<CredenzaContent className="md:max-w-7xl">
<CredenzaHeader>
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
<CredenzaDescription>
{t("siteResourcesDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{resourcesDialogSite != null && (
<SiteResourcesOverview
orgIdOverride={orgId}
siteId={resourcesDialogSite.id}
initialPublicData={null}
initialPrivateData={null}
initialPublicForbidden={false}
initialPrivateForbidden={false}
/>
)}
</CredenzaBody>
<CredenzaFooter>
<Button
type="button"
variant="outline"
onClick={() => setResourcesDialogSite(null)}
>
{t("close")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedSite && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}

View File

@@ -98,7 +98,7 @@ export default function UptimeAlertSection({
name,
eventType: siteId ? "site_toggle" : "resource_toggle",
enabled: true,
cooldownSeconds: 300,
cooldownSeconds: 0, // default to 0 here because we dont want the extra confusion
siteIds: siteId ? [siteId] : [],
healthCheckIds: [],
resourceIds: resourceId ? [resourceId] : [],

View File

@@ -42,7 +42,8 @@ const barColorClass: Record<string, string> = {
good: "bg-green-500",
degraded: "bg-yellow-500",
bad: "bg-red-500",
no_data: "bg-neutral-200 dark:bg-neutral-700"
no_data: "bg-neutral-200 dark:bg-neutral-700",
unknown: "bg-neutral-200 dark:bg-neutral-700"
};
type UptimeBarProps = {
@@ -188,7 +189,7 @@ export default function UptimeBar({
<div className="font-semibold text-xs">
{formatDate(day.date)}
</div>
{day.status !== "no_data" && (
{day.status !== "no_data" && day.status !== "unknown" && (
<div className="text-xs text-primary-foreground/80">
{t("uptimeTooltipUptimeLabel")}:{" "}
<span className="font-medium text-primary-foreground">
@@ -224,7 +225,7 @@ export default function UptimeBar({
))}
</div>
)}
{day.status === "no_data" && (
{(day.status === "no_data" || day.status === "unknown") && (
<div className="text-xs text-primary-foreground/60">
{t("uptimeNoMonitoringData")}
</div>

View File

@@ -34,7 +34,8 @@ const barColorClass: Record<string, string> = {
good: "bg-green-500",
degraded: "bg-yellow-500",
bad: "bg-red-500",
no_data: "bg-neutral-200 dark:bg-neutral-700"
no_data: "bg-neutral-200 dark:bg-neutral-700",
unknown: "bg-neutral-200 dark:bg-neutral-700"
};
type UptimeMiniBarProps = {
@@ -137,7 +138,7 @@ export default function UptimeMiniBar({
{formatDate(day.date)}
</div>
<div className="text-xs text-primary-foreground/80">
{day.status === "no_data"
{day.status === "no_data" || day.status === "unknown"
? t("uptimeNoData")
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
</div>

View File

@@ -35,6 +35,7 @@ import { useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import ClientDownloadBanner from "./ClientDownloadBanner";
import { ColumnFilterButton } from "./ColumnFilterButton";
import IdpTypeBadge from "./IdpTypeBadge";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
@@ -52,6 +53,9 @@ export type ClientRow = {
userId: string | null;
username: string | null;
userEmail: string | null;
userType: string | null;
idpName: string | null;
idpVariant: string | null;
niceId: string;
agent: string | null;
approvalState: "approved" | "pending" | "denied" | null;
@@ -370,17 +374,30 @@ export default function UserDevicesTable({
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
<div className="flex items-center gap-2">
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
{(r.userType ?? "internal") !== "internal" && (
<IdpTypeBadge
type={r.userType ?? "oidc"}
name={
r.idpName?.trim()
? r.idpName
: t("idpNameInternal")
}
variant={r.idpVariant ?? undefined}
/>
)}
</div>
) : (
"-"
);

View File

@@ -0,0 +1,41 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
inviteUser?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
inviteUser,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="users-table"
title={t("users")}
searchPlaceholder={t("accessUsersSearch")}
searchColumn="email"
onAdd={inviteUser}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessUserCreate")}
enableColumnVisibility={true}
stickyLeftColumn="displayUsername"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1110,6 +1110,7 @@ export function AlertRuleSourceFields({
if (
curTrigger !== "resource_healthy" &&
curTrigger !== "resource_unhealthy" &&
curTrigger !== "resource_degraded" &&
curTrigger !== "resource_toggle"
) {
setValue("trigger", "resource_toggle", {
@@ -1330,6 +1331,9 @@ export function AlertRuleTriggerFields({
<SelectItem value="resource_unhealthy">
{t("alertingTriggerResourceUnhealthy")}
</SelectItem>
<SelectItem value="resource_degraded">
{t("alertingTriggerResourceDegraded")}
</SelectItem>
</>
) : (
<>

View File

@@ -111,11 +111,13 @@ export function MultiSitesSelector({
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
</CommandItem>
))}

View File

@@ -77,8 +77,12 @@ sudo install -d -m 0755 /etc/newt
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
NEWT_ID=${id}
NEWT_SECRET=${secret}
PANGOLIN_ENDPOINT=${endpoint}${!acceptClients ? `
DISABLE_CLIENTS=true` : ""}
PANGOLIN_ENDPOINT=${endpoint}${
!acceptClients
? `
DISABLE_CLIENTS=true`
: ""
}
EOF
sudo chmod 600 /etc/newt/newt.env`
},
@@ -232,9 +236,7 @@ WantedBy=default.target`
<OptionSelect<string>
label={
platform === "windows"
? t("architecture")
: t("method")
platform === "windows" ? t("architecture") : t("method")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
@@ -247,7 +249,9 @@ WantedBy=default.target`
/>
<div className="pt-4">
<p className="font-bold mb-3">{t("siteConfiguration")}</p>
<p className="font-semibold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
@@ -269,7 +273,7 @@ WantedBy=default.target`
</div>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
<p className="font-semibold mb-3">{t("commands")}</p>
{platform === "kubernetes" && (
<p className="text-sm text-muted-foreground mb-3">
For more and up to date Kubernetes installation

View File

@@ -122,9 +122,7 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
<OptionSelect<string>
label={
platform === "docker"
? t("method")
: t("architecture")
platform === "docker" ? t("method") : t("architecture")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
@@ -137,33 +135,31 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
/>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string"
? item
: item.command;
const title =
typeof item === "string"
? undefined
: item.title;
<p className="font-semibold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string" ? item : item.command;
const title =
typeof item === "string"
? undefined
: item.title;
return (
<div key={index}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
return (
<div key={index}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
</div>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -17,19 +17,21 @@ import { useDebounce } from "use-debounce";
export type SelectedResource = Pick<
ListResourcesResponse["resources"][number],
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl"
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard"
>;
export type ResourceSelectorProps = {
orgId: string;
selectedResource?: SelectedResource | null;
onSelectResource: (resource: SelectedResource) => void;
excludeWildcard?: boolean;
};
export function ResourceSelector({
orgId,
selectedResource,
onSelectResource
onSelectResource,
excludeWildcard = false
}: ResourceSelectorProps) {
const t = useTranslations();
const [resourceSearchQuery, setResourceSearchQuery] = useState("");
@@ -46,10 +48,13 @@ export function ResourceSelector({
// always include the selected resource in the list of resources shown
const resourcesShown = useMemo(() => {
const allResources: Array<SelectedResource> = [...resources];
const allResources: Array<SelectedResource> = excludeWildcard
? resources.filter((r) => !r.wildcard)
: [...resources];
if (
debouncedSearchQuery.trim().length === 0 &&
selectedResource &&
!(excludeWildcard && selectedResource.wildcard) &&
!allResources.find(
(resource) =>
resource.resourceId === selectedResource?.resourceId
@@ -58,7 +63,7 @@ export function ResourceSelector({
allResources.unshift(selectedResource);
}
return allResources;
}, [debouncedSearchQuery, resources, selectedResource]);
}, [debouncedSearchQuery, resources, selectedResource, excludeWildcard]);
return (
<Command shouldFilter={false}>

View File

@@ -104,7 +104,7 @@ export function ResourceTargetAddressItem({
role="combobox"
className={cn(
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
"rounded-l-md rounded-r-xs",
"",
!proxyTarget.siteId && "text-muted-foreground"
)}
>
@@ -142,7 +142,7 @@ export function ResourceTargetAddressItem({
})
}
>
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs">
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
{proxyTarget.method || "http"}
</SelectTrigger>
<SelectContent>

View File

@@ -124,11 +124,13 @@ export function SitesSelector({
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
</CommandItem>
))}

View File

@@ -57,7 +57,7 @@ export const tagVariants = cva(
},
textStyle: {
normal: "font-normal",
bold: "font-bold",
bold: "font-semibold",
italic: "italic",
underline: "underline",
lineThrough: "line-through"

View File

@@ -20,7 +20,7 @@ const checkboxVariants = cva(
outlinePrimarySquare:
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
outlineSquare:
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground"
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-muted data-[state=checked]:text-foreground"
}
},
defaultVariants: {
@@ -44,7 +44,7 @@ const Checkbox = React.forwardRef<
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-4 w-4 text-white" />
<Check className="h-4 w-4 text-current" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View File

@@ -33,6 +33,7 @@ import {
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover";
import {
ChevronDown,
@@ -345,7 +346,9 @@ export function ControlledDataTable<TData, TValue>({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-48"
className={
dataTableFilterDropdownContentClassName
}
>
<DropdownMenuLabel>
{filter.label}

View File

@@ -34,6 +34,7 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover";
import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import {
Card,
@@ -603,7 +604,9 @@ export function DataTable<TData, TValue>({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-48"
className={
dataTableFilterDropdownContentClassName
}
>
<DropdownMenuLabel>
{filter.label}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -20,7 +20,7 @@ type UseCertificateReturn = {
certLoading: boolean;
certError: string | null;
refreshing: boolean;
fetchCert: () => Promise<void>;
fetchCert: (showLoading?: boolean) => Promise<void>;
refreshCert: () => Promise<void>;
clearCert: () => void;
};
@@ -47,18 +47,18 @@ export function useCertificate({
if (showLoading) {
setCertLoading(true);
}
setCertError(null);
try {
const res = await api.get<
AxiosResponse<GetCertificateResponse>
>(`/org/${orgId}/certificate/${domainId}/${fullDomain}`);
const certData = res.data.data;
if (certData) {
setCertError(null);
setCert(certData);
}
} catch (error: any) {
console.error("Failed to fetch certificate:", error);
setCertError("Failed to fetch certificate");
setCertError("Failed");
} finally {
if (showLoading) {
setCertLoading(false);
@@ -84,7 +84,7 @@ export function useCertificate({
}, 500);
} catch (error: any) {
console.error("Failed to restart certificate:", error);
setCertError("Failed to restart certificate");
setCertError("Failed to restart");
} finally {
setRefreshing(false);
}
@@ -102,15 +102,33 @@ export function useCertificate({
}
}, [autoFetch, orgId, domainId, fullDomain, fetchCert]);
// Polling effect
useEffect(() => {
if (!polling || !orgId || !domainId || !fullDomain) return;
const interval = setInterval(() => {
fetchCert(false); // Don't show loading for polling
}, pollingInterval);
const POLL_JITTER_MS = 1000;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout>;
return () => clearInterval(interval);
const scheduleNext = () => {
const jitter = (Math.random() * 2 - 1) * POLL_JITTER_MS;
const delayMs = Math.max(
1000,
Math.round(pollingInterval + jitter)
);
timeoutId = setTimeout(() => {
if (cancelled) return;
void fetchCert(false);
scheduleNext();
}, delayMs);
};
scheduleNext();
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]);
return {

View File

@@ -8,9 +8,7 @@ export function useSubscriptionStatusContext() {
}
const context = useContext(SubscriptionStatusContext);
if (context === undefined) {
throw new Error(
"useSubscriptionStatusContext must be used within an SubscriptionStatusProvider"
);
return null;
}
return context;
}

View File

@@ -25,6 +25,7 @@ export type AlertTrigger =
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
export type AlertRuleFormAction =
@@ -77,6 +78,7 @@ export type AlertRuleApiPayload = {
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
enabled: boolean;
allSites: boolean;
@@ -160,6 +162,7 @@ export function buildFormSchema(t: (k: string) => string) {
"health_check_toggle",
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
]),
actions: z.array(
@@ -243,6 +246,7 @@ export function buildFormSchema(t: (k: string) => string) {
const resourceTriggers: AlertTrigger[] = [
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
];
if (
@@ -344,7 +348,9 @@ export function alertRuleAllResourcesSelected(
eventType: string,
resourceIds: number[] | undefined
): boolean {
return eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0;
return (
eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0
);
}
export function alertRuleAllHealthChecksSelected(

View File

@@ -0,0 +1,129 @@
import { z } from "zod";
const STORAGE_PREFIX = "pangolin:alert-rules:";
export const webhookHeaderEntrySchema = z.object({
key: z.string(),
value: z.string()
});
export const alertActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
roleIds: z.array(z.number()),
emails: z.array(z.string())
}),
z.object({
type: z.literal("webhook"),
url: z.string().url(),
method: z.string().min(1),
headers: z.array(webhookHeaderEntrySchema),
secret: z.string().optional()
})
]);
export const alertSourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("site"),
siteIds: z.array(z.number())
}),
z.object({
type: z.literal("health_check"),
targetIds: z.array(z.number())
})
]);
export const alertTriggerSchema = z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_unhealthy"
]);
export const alertRuleSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
enabled: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
source: alertSourceSchema,
trigger: alertTriggerSchema,
actions: z.array(alertActionSchema).min(1)
});
export type AlertRule = z.infer<typeof alertRuleSchema>;
export type AlertAction = z.infer<typeof alertActionSchema>;
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
function storageKey(orgId: string) {
return `${STORAGE_PREFIX}${orgId}`;
}
export function getRule(orgId: string, ruleId: string): AlertRule | undefined {
return loadRules(orgId).find((r) => r.id === ruleId);
}
export function loadRules(orgId: string): AlertRule[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(storageKey(orgId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const out: AlertRule[] = [];
for (const item of parsed) {
const r = alertRuleSchema.safeParse(item);
if (r.success) {
out.push(r.data);
}
}
return out;
} catch {
return [];
}
}
export function saveRules(orgId: string, rules: AlertRule[]) {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(storageKey(orgId), JSON.stringify(rules));
}
export function upsertRule(orgId: string, rule: AlertRule) {
const rules = loadRules(orgId);
const i = rules.findIndex((r) => r.id === rule.id);
if (i >= 0) {
rules[i] = rule;
} else {
rules.push(rule);
}
saveRules(orgId, rules);
}
export function deleteRule(orgId: string, ruleId: string) {
const rules = loadRules(orgId).filter((r) => r.id !== ruleId);
saveRules(orgId, rules);
}
export function newRuleId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function isoNow() {
return new Date().toISOString();
}

View File

@@ -0,0 +1,5 @@
export const dataTableFilterPopoverContentClassName =
"w-[min(16rem,calc(100vw-2rem))] p-0";
export const dataTableFilterDropdownContentClassName =
"w-[min(16rem,calc(100vw-2rem))]";

View File

@@ -0,0 +1,32 @@
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http";
destination: string;
httpHttpsPort: number | null;
scheme: "http" | "https" | null;
};
export function resolveHttpHttpsDisplayPort(
mode: "http",
httpHttpsPort: number | null
): number {
if (httpHttpsPort != null) {
return httpHttpsPort;
}
return 80;
}
export function formatSiteResourceDestinationDisplay(
row: SiteResourceDestinationInput
): string {
const { mode, destination, httpHttpsPort, scheme } = row;
if (mode !== "http") {
return destination;
}
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
const downstreamScheme = scheme ?? "http";
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
}

View File

@@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild
export const SINGLE_LABEL_STRICT_RE =
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
export function sanitizeInputRaw(input: string): string {
/**
* A wildcard subdomain is either bare "*" or "*.label1.label2…" where every
* label after the dot is a valid hostname label. This mirrors the shape that
* the server's `wildcardSubdomainSchema` accepts.
*/
export const WILDCARD_SUBDOMAIN_RE =
/^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
export function isWildcardSubdomain(input: string): boolean {
return WILDCARD_SUBDOMAIN_RE.test(input);
}
export function sanitizeInputRaw(input: string, allowWildcard = false): string {
if (!input) return "";
// When wildcard mode is active, preserve a leading "* " / "*." prefix and
// only sanitize the remainder so the user can type "*.level1" naturally.
if (allowWildcard && input.startsWith("*")) {
const rest = input.slice(1);
const sanitizedRest = rest
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "");
return "*" + sanitizedRest;
}
return input
.toLowerCase()
.normalize("NFC") // normalize Unicode
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
}
export function finalizeSubdomainSanitize(input: string): string {
export function finalizeSubdomainSanitize(
input: string,
allowWildcard = false
): string {
if (!input) return "";
// If the input is a valid wildcard and the caller permits it, keep it as-is
// (just lowercase the non-wildcard labels).
if (allowWildcard && input.startsWith("*")) {
const rest = input.slice(1); // everything after the leading "*"
const sanitizedRest = rest
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "")
.replace(/\.{2,}/g, ".")
.replace(/^-+|-+$/g, "")
.replace(/(\.-)|(-\.)/g, ".");
const candidate = "*" + sanitizedRest;
// Return only if it still forms a valid wildcard after sanitizing
return isWildcardSubdomain(candidate) ? candidate : "";
}
return input
.toLowerCase()
.normalize("NFC")
@@ -30,6 +72,7 @@ export function validateByDomainType(
domainType: {
type: "provided-search" | "organization";
domainType?: "ns" | "cname" | "wildcard";
allowWildcard?: boolean;
}
): boolean {
if (!domainType) return false;
@@ -46,6 +89,12 @@ export function validateByDomainType(
domainType.domainType === "wildcard"
) {
if (subdomain === "") return true;
// Wildcard subdomain validation (only when caller opts in)
if (domainType.allowWildcard && subdomain.startsWith("*")) {
return isWildcardSubdomain(subdomain);
}
if (!MULTI_LABEL_RE.test(subdomain)) return false;
const labels = subdomain.split(".");
return labels.every(
@@ -57,10 +106,19 @@ export function validateByDomainType(
return false;
}
export const isValidSubdomainStructure = (input: string): boolean => {
export const isValidSubdomainStructure = (
input: string,
allowWildcard = false
): boolean => {
if (!input) return false;
// A valid wildcard subdomain is structurally valid when the caller allows it
if (allowWildcard && input.startsWith("*")) {
return isWildcardSubdomain(input);
}
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
if (!input) return false;
if (input.includes("..")) return false;
return input.split(".").every((label) => regex.test(label));