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

@@ -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}