mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-07 04:47:08 +00:00
Merge branch 'dev' of github.com:fosrl/pangolin into dev
This commit is contained in:
@@ -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,12 +29,14 @@ 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 { useMemo, useState, useTransition } from "react";
|
||||
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
@@ -40,13 +48,13 @@ 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 {
|
||||
ResourceSitesStatusCell,
|
||||
type ResourceSiteRow
|
||||
} from "@app/components/ResourceSitesStatusCell";
|
||||
|
||||
export type InternalResourceSiteRow = {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
online: boolean;
|
||||
};
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
@@ -111,121 +119,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 +154,26 @@ export default function ClientResourcesTable({
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
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 {
|
||||
@@ -294,9 +218,7 @@ export default function ClientResourcesTable({
|
||||
|
||||
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 +243,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 +310,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}
|
||||
/>
|
||||
@@ -576,6 +543,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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -376,7 +377,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 +446,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">
|
||||
|
||||
@@ -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:gap-16 gap-2 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}
|
||||
|
||||
@@ -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,9 +16,17 @@ 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";
|
||||
@@ -29,6 +42,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
Clock,
|
||||
Funnel,
|
||||
MoreHorizontal,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
@@ -39,6 +53,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -84,6 +99,7 @@ export type ResourceRow = {
|
||||
targetPort?: number;
|
||||
targets?: TargetHealth[];
|
||||
health?: "online" | "degraded" | "unhealthy" | "unknown";
|
||||
sites: ResourceSiteRow[];
|
||||
};
|
||||
|
||||
function StatusIcon({
|
||||
@@ -114,13 +130,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 {
|
||||
@@ -140,13 +158,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);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [router]);
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
@@ -351,6 +386,67 @@ 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"),
|
||||
@@ -596,6 +692,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);
|
||||
|
||||
|
||||
123
src/components/ResourceSitesStatusCell.tsx
Normal file
123
src/components/ResourceSitesStatusCell.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"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;
|
||||
};
|
||||
|
||||
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
||||
|
||||
function aggregateSitesStatus(
|
||||
resourceSites: ResourceSiteRow[]
|
||||
): 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";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -239,9 +239,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} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -437,6 +435,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);
|
||||
|
||||
@@ -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