mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 |
No files matched your search
Generated
+10
-10
@@ -3255,13 +3255,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3665,9 +3665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -6973,9 +6973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ProxyEventsPage() {
|
||||
() => ({
|
||||
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
sort_by: "timestamp",
|
||||
sort_order: "desc",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
@@ -35,6 +34,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
@@ -49,6 +49,7 @@ import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
|
||||
|
||||
export default function NetworkDetailPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -65,7 +66,7 @@ export default function NetworkDetailPage() {
|
||||
<NetworkOverview network={network} />
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
<SkeletonNetwork />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,103 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkProvider network={network}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className=" pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
>(({ className, children }, ref) => {
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = wrapperRef.current?.closest("[data-state]");
|
||||
if (!el) return;
|
||||
|
||||
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isOpen ? "auto" : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className={cn("overflow-hidden text-sm", className)}
|
||||
>
|
||||
<div className="pt-0">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -1,29 +1,68 @@
|
||||
import * as React from "react";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { TooltipVariants } from "@components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
interactive?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
alignOffset?: number;
|
||||
sideOffset?: number;
|
||||
iconSize?: number;
|
||||
delayDuration?: number;
|
||||
} & TooltipVariants;
|
||||
export const HelpTooltip = ({
|
||||
content,
|
||||
children,
|
||||
interactive = true,
|
||||
interactive = false,
|
||||
className,
|
||||
variant = "default",
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
side = "top",
|
||||
alignOffset = 0,
|
||||
sideOffset,
|
||||
iconSize = 12,
|
||||
delayDuration = 300,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
interactive={interactive}
|
||||
side={"top"}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
side={side}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
delayDuration={delayDuration}
|
||||
variant={variant}
|
||||
className={
|
||||
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
|
||||
}
|
||||
content={content}
|
||||
content={
|
||||
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<HelpCircle
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</FullTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface NotifyProps<T> {
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
showOnlyError?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ export default function Notification<T>({
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
showOnlyError = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
@@ -49,10 +51,13 @@ export default function Notification<T>({
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
}, Math.max(0, remainingRef.current));
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
Math.max(0, remainingRef.current),
|
||||
);
|
||||
}, [toastId]);
|
||||
|
||||
const pauseTimer = useCallback(() => {
|
||||
@@ -88,7 +93,10 @@ export default function Notification<T>({
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
|
||||
observer.observe(toastEl, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-expanded"],
|
||||
});
|
||||
|
||||
// Start immediately if not expanded
|
||||
const expanded = toastEl.getAttribute("data-expanded") === "true";
|
||||
@@ -106,7 +114,7 @@ export default function Notification<T>({
|
||||
promise
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
if (preventSuccessToast) {
|
||||
if (showOnlyError || preventSuccessToast) {
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
setReadyToDismiss(true);
|
||||
@@ -136,6 +144,9 @@ export default function Notification<T>({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hideUntilError = showOnlyError && loading && !error;
|
||||
if (hideUntilError) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={notificationRef}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -40,7 +41,7 @@ import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
@@ -71,6 +72,7 @@ interface MultiSelectProps {
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
closeOnSelect?: boolean;
|
||||
@@ -83,6 +85,8 @@ interface MultiSelectProps {
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
resourceIds?: string[];
|
||||
additionalResources?: NetworkResource[];
|
||||
policies?: Policy[];
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -101,6 +105,7 @@ export function PeerGroupSelector({
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showPeerCounter = true,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
closeOnSelect = false,
|
||||
@@ -113,11 +118,21 @@ export function PeerGroupSelector({
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const resources = useMemo(() => {
|
||||
if (!additionalResources?.length) return fetchedResources;
|
||||
const additional = additionalResources.filter(
|
||||
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
|
||||
);
|
||||
return [...(fetchedResources || []), ...additional];
|
||||
}, [fetchedResources, additionalResources]);
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
@@ -329,7 +344,7 @@ export function PeerGroupSelector({
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
"disabled:pointer-events-none disabled:opacity-60 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
@@ -343,7 +358,14 @@ export function PeerGroupSelector({
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
resource={
|
||||
resources?.find((r) => r.id === resource.id) ??
|
||||
({
|
||||
id: resource.id,
|
||||
name: resource.id,
|
||||
type: resource.type,
|
||||
} as NetworkResource)
|
||||
}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -569,12 +591,21 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
{policies && (
|
||||
<PolicyCounter
|
||||
group={option}
|
||||
policies={policies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
showPeerCounter && (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -790,6 +821,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PolicyCounter = ({
|
||||
group,
|
||||
policies,
|
||||
}: {
|
||||
group: Group;
|
||||
policies: Policy[];
|
||||
}) => {
|
||||
const count = useMemo(() => {
|
||||
if (!group.id) return 0;
|
||||
return policies.filter((policy) => {
|
||||
const destinations = policy.rules?.[0]?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return destinations?.some((d) =>
|
||||
typeof d === "string" ? d === group.id : d.id === group.id,
|
||||
);
|
||||
}).length;
|
||||
}, [group.id, policies]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={14} className={"shrink-0"} />
|
||||
{count} {count === 1 ? "Policy" : "Policies"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
|
||||
@@ -75,8 +75,10 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, extra, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -92,6 +94,7 @@ const SelectItem = React.forwardRef<
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{extra}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
|
||||
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
|
||||
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
|
||||
const [show, setShow] = useState(delay === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay === 0) return;
|
||||
const timer = setTimeout(() => setShow(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className={"p-default py-6 w-full"}>
|
||||
<Skeleton height={24} width={240} className={"mb-4"} />
|
||||
<div className={"mb-8 flex items-center gap-4"}>
|
||||
<Skeleton height={48} width={48} />
|
||||
<Skeleton height={20} width={200} />
|
||||
</div>
|
||||
<div className={"mb-4"}>
|
||||
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-4 mb-8"}>
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
|
||||
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
return (
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Skeleton height={24} width={200} className={"mb-6"} />
|
||||
<Skeleton height={32} width={110} className={"mb-10"} />
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -296,6 +296,7 @@ export function DataTable<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualSorting: serverSidePagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import type { Column } from "@tanstack/table-core";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||
|
||||
type Props = {
|
||||
column: Column<any>;
|
||||
@@ -13,6 +14,7 @@ type Props = {
|
||||
center?: boolean;
|
||||
className?: string;
|
||||
sorting?: boolean;
|
||||
name?: string;
|
||||
};
|
||||
export default function DataTableHeader({
|
||||
children,
|
||||
@@ -21,15 +23,22 @@ export default function DataTableHeader({
|
||||
center,
|
||||
className,
|
||||
sorting = true,
|
||||
name,
|
||||
}: Props) {
|
||||
const serverPagination = useOptionalServerPagination();
|
||||
|
||||
const handleSort = () => {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
if (name && serverPagination?.setSort) {
|
||||
serverPagination.setSort(name, direction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullTooltip content={tooltip} disabled={!tooltip}>
|
||||
<div
|
||||
onClick={
|
||||
sorting
|
||||
? () => column.toggleSorting(column.getIsSorted() === "asc")
|
||||
: undefined
|
||||
}
|
||||
onClick={sorting ? handleSort : undefined}
|
||||
className={cn(
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
|
||||
sorting &&
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -37,6 +38,7 @@ export default function MultipleGroups({
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
disableRedirect = false,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -64,6 +66,7 @@ export default function MultipleGroups({
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
showNewBadge={true}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
@@ -101,7 +104,7 @@ export default function MultipleGroups({
|
||||
return (
|
||||
group && (
|
||||
<div
|
||||
key={group.id}
|
||||
key={group?.id || group?.name}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
@@ -110,16 +113,23 @@ export default function MultipleGroups({
|
||||
group={group}
|
||||
className={"py-0"}
|
||||
textClassName={"py-1.5"}
|
||||
redirectToGroupPage={true}
|
||||
showNewBadge={true}
|
||||
redirectToGroupPage={!disableRedirect}
|
||||
redirectGroupTab={redirectGroupTab}
|
||||
></GroupBadge>
|
||||
<ArrowRightIcon size={14} />
|
||||
{showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
) : showUsers ? (
|
||||
<UserCountStack group={group} />
|
||||
) : (
|
||||
<PeerCountBadge group={group} />
|
||||
<PeerCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions, groups } = useGroups();
|
||||
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const canRedirect = !!group?.id && group?.name !== "All";
|
||||
const canRedirect =
|
||||
!!group?.id && group?.name !== "All" && !disableRedirect;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
|
||||
const showResources = resourcesCount > 0 && peerCount === 0;
|
||||
|
||||
return showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
|
||||
) : (
|
||||
<Badge
|
||||
variant={variant}
|
||||
|
||||
@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function ResourceCountBadge({ group }: Props) {
|
||||
export default function ResourceCountBadge({
|
||||
group,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const hasId = !!group?.id;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disableRedirect) return;
|
||||
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ type DialogOptions = {
|
||||
type?: "default" | "warning" | "danger" | "center";
|
||||
children?: React.ReactNode;
|
||||
maxWidthClass?: string;
|
||||
hideIcon?: boolean;
|
||||
center?: boolean;
|
||||
};
|
||||
|
||||
export default function DialogProvider({ children }: Props) {
|
||||
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
center={dialogOptions.center ?? dialogOptions.type == "center"}
|
||||
title={dialogOptions.title || "Confirmation"}
|
||||
margin={"mt-1"}
|
||||
description={
|
||||
dialogOptions.description ||
|
||||
"Are you sure you want to continue? This action cannot be undone."
|
||||
}
|
||||
icon={dialogTypes[dialogOptions.type || "default"]}
|
||||
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
|
||||
color={
|
||||
dialogOptions.type == "default"
|
||||
? "blue"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
@@ -18,18 +23,115 @@ const PoliciesContext = React.createContext(
|
||||
message?: string,
|
||||
) => void;
|
||||
createPolicy: (policy: Policy) => Promise<Policy>;
|
||||
createPoliciesForResource: (
|
||||
policies: Policy[],
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => Promise<void>;
|
||||
openEditPolicyModal: (policy: Policy, tab?: string) => void;
|
||||
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
|
||||
serializeRules: (
|
||||
rules: Policy["rules"],
|
||||
enabled?: boolean,
|
||||
) => Policy["rules"];
|
||||
},
|
||||
);
|
||||
|
||||
export default function PoliciesProvider({ children }: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const request = useApiCall<Policy>("/policies");
|
||||
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const [initialPolicyTab, setInitialPolicyTab] = useState("");
|
||||
|
||||
const createPolicy = async (policy: Policy) => request.post(policy);
|
||||
|
||||
const createPolicyForResource = async (
|
||||
policy: Policy,
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => {
|
||||
const rule = policy.rules[0];
|
||||
|
||||
const allGroups = [...(knownGroups || []), ...(groups || [])];
|
||||
const resolveGroup = async (g: Group | string): Promise<string> => {
|
||||
if (typeof g === "string") return g;
|
||||
if (g.id) return g.id;
|
||||
const existing = allGroups.find((eg) => eg.name === g.name);
|
||||
if (existing?.id) return existing.id;
|
||||
const created = await createOrUpdateGroup(g);
|
||||
return created.id!;
|
||||
};
|
||||
|
||||
const sources = await Promise.all(
|
||||
(rule.sources ?? []).map(resolveGroup),
|
||||
).then((ids) => ids.filter(Boolean) as string[]);
|
||||
|
||||
const destinations = rule.destinationResource
|
||||
? undefined
|
||||
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
|
||||
(ids) => ids.filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
const destinationResource = rule.destinationResource
|
||||
? { id: resource.id, type: resource.type }
|
||||
: undefined;
|
||||
|
||||
return createPolicy({
|
||||
...policy,
|
||||
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
|
||||
typeof c === "string" ? c : c.id,
|
||||
),
|
||||
rules: [
|
||||
{
|
||||
...rule,
|
||||
sources,
|
||||
destinations,
|
||||
destinationResource,
|
||||
},
|
||||
],
|
||||
} as Policy);
|
||||
};
|
||||
|
||||
const createPoliciesForResource = async (
|
||||
newPolicies: Policy[],
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => {
|
||||
const policiesToCreate = newPolicies.filter((p) => !p.id);
|
||||
if (policiesToCreate.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
policiesToCreate.map((p) =>
|
||||
createPolicyForResource(p, resource, knownGroups),
|
||||
),
|
||||
);
|
||||
await mutate("/policies");
|
||||
};
|
||||
|
||||
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
|
||||
rules = cloneDeep(rules);
|
||||
rules.forEach((rule) => {
|
||||
if (enabled !== undefined) rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((s) => {
|
||||
const group = s as Group;
|
||||
return group.id ?? s;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((d) => {
|
||||
const group = d as Group;
|
||||
return group.id ?? d;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) rule.destinations = null;
|
||||
if (rule.sourceResource) rule.sources = null;
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
const updatePolicy = async (
|
||||
policy: Policy,
|
||||
toUpdate: Partial<Policy>,
|
||||
@@ -62,6 +164,20 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
|
||||
const promise = request.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
onSuccess?.();
|
||||
});
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully deleted.",
|
||||
promise,
|
||||
loadingMessage: "Deleting policy...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy, tab?: string) => {
|
||||
setCurrentPolicy(policy);
|
||||
tab && setInitialPolicyTab(tab);
|
||||
@@ -70,7 +186,14 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
|
||||
return (
|
||||
<PoliciesContext.Provider
|
||||
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
|
||||
value={{
|
||||
updatePolicy,
|
||||
createPolicy,
|
||||
createPoliciesForResource,
|
||||
openEditPolicyModal,
|
||||
deletePolicy,
|
||||
serializeRules,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
|
||||
@@ -31,6 +31,7 @@ type ServerPaginationContextValue<T = unknown> = {
|
||||
onGlobalFilterChange: (value: string) => void;
|
||||
setFilter: (key: string, value: string | undefined) => void;
|
||||
getFilter: (key: string) => string | undefined;
|
||||
setSort: (name: string, direction: "asc" | "desc") => void;
|
||||
hasActiveFilters: boolean;
|
||||
resetFilters: () => void;
|
||||
onFilterReset: () => void;
|
||||
@@ -146,6 +147,15 @@ export default function ServerPaginationProvider({
|
||||
|
||||
const getFilter = useCallback((key: string) => filters[key], [filters]);
|
||||
|
||||
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort_by: name,
|
||||
sort_order: direction,
|
||||
}));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
search !== "" ||
|
||||
Object.entries(filters).some(
|
||||
@@ -170,6 +180,7 @@ export default function ServerPaginationProvider({
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
pagination: { pageIndex: page - 1, pageSize },
|
||||
@@ -193,6 +204,7 @@ export default function ServerPaginationProvider({
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
page,
|
||||
@@ -220,3 +232,8 @@ export function useServerPagination<T>() {
|
||||
}
|
||||
return context as ServerPaginationContextValue<T>;
|
||||
}
|
||||
|
||||
export function useOptionalServerPagination<T>() {
|
||||
const context = useContext(ServerPaginationContext);
|
||||
return context as ServerPaginationContextValue<T> | null;
|
||||
}
|
||||
+23
-12
@@ -1,26 +1,37 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function useUrlTab(
|
||||
validTabs: string[],
|
||||
defaultTab: string,
|
||||
): [string, (value: string) => void] {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const tab = useMemo(() => {
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
}, [searchParams, validTabs, defaultTab]);
|
||||
const getTab = useCallback(
|
||||
(params: URLSearchParams) => {
|
||||
const tabParam = params.get("tab");
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
},
|
||||
[validTabs, defaultTab],
|
||||
);
|
||||
|
||||
const [tab, setTabState] = useState(() => getTab(searchParams));
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = getTab(searchParams);
|
||||
setTabState(newTab);
|
||||
}, [searchParams, getTab]);
|
||||
|
||||
const setTab = useCallback(
|
||||
(value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
const nextTab = validTabs.includes(value) ? value : defaultTab;
|
||||
setTabState(nextTab);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("tab", nextTab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
},
|
||||
[searchParams, router],
|
||||
[validTabs, defaultTab],
|
||||
);
|
||||
|
||||
return [tab, setTab];
|
||||
|
||||
Loaded 20 of 48 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user