mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-09 01:39:52 +00:00
Merge branch 'dev' into feat/roles-and-user-multi-selectors
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
37
src/components/AdminUsersDataTable.tsx
Normal file
37
src/components/AdminUsersDataTable.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
179
src/components/ResourceAccessCertIndicator.tsx
Normal file
179
src/components/ResourceAccessCertIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
141
src/components/ResourceSitesStatusCell.tsx
Normal file
141
src/components/ResourceSitesStatusCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/RolesDataTable.tsx
Normal file
41
src/components/RolesDataTable.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function SettingsSectionTitle({
|
||||
<h2
|
||||
className={`text-${
|
||||
size ? size : "2xl"
|
||||
} font-bold tracking-tight`}
|
||||
} font-semibold tracking-tight`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -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
|
||||
|
||||
513
src/components/SiteResourcesOverview.tsx
Normal file
513
src/components/SiteResourcesOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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] : [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
|
||||
41
src/components/UsersDataTable.tsx
Normal file
41
src/components/UsersDataTable.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user