Add resource search, unidirectional policies for all/icmp and dns0 template (#479)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Some checks failed
build and push / build_n_push (push) Has been cancelled
This commit is contained in:
@@ -105,7 +105,7 @@ function PeersBlockedView() {
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} />
|
||||
|
||||
@@ -67,7 +67,7 @@ p {
|
||||
}
|
||||
|
||||
.stepper-bg-variant .step-circle {
|
||||
@apply !border-[#1d2024];
|
||||
@apply !border-nb-gray-940;
|
||||
}
|
||||
|
||||
.webkit-scroll{
|
||||
@@ -117,4 +117,43 @@ p {
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-slow-ping {
|
||||
animation: ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slow-pulse {
|
||||
animation: pulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
60% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-scroll {
|
||||
0% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bg-scroll {
|
||||
animation: bg-scroll 4s linear infinite;
|
||||
}
|
||||
.animate-bg-scroll-faster {
|
||||
animation: bg-scroll 1.8s linear infinite;
|
||||
}
|
||||
|
||||
12
src/assets/nameservers/dns0-zero.svg
Normal file
12
src/assets/nameservers/dns0-zero.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
12
src/assets/nameservers/dns0.svg
Normal file
12
src/assets/nameservers/dns0.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -61,9 +61,9 @@ export default function Badge({
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
className,
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, VariantProps } from "class-variance-authority";
|
||||
import classNames from "classnames";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -28,7 +28,7 @@ export const buttonVariants = cva(
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-920 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
@@ -49,7 +49,7 @@ export const buttonVariants = cva(
|
||||
dropdown: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-800 dark:hover:bg-nb-gray-900/50",
|
||||
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
|
||||
@@ -34,6 +34,10 @@ const defaultRanges = {
|
||||
from: dayjs().subtract(2, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
last7Days: {
|
||||
from: dayjs().subtract(7, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
lastMonth: {
|
||||
from: dayjs().subtract(1, "month").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
@@ -64,6 +68,7 @@ export function DatePickerWithRange({
|
||||
yesterday: isEqualDateRange(value, defaultRanges.yesterday),
|
||||
last14Days: isEqualDateRange(value, defaultRanges.last14Days),
|
||||
last2Days: isEqualDateRange(value, defaultRanges.last2Days),
|
||||
last7Days: isEqualDateRange(value, defaultRanges.last7Days),
|
||||
lastMonth: isEqualDateRange(value, defaultRanges.lastMonth),
|
||||
allTime: isEqualDateRange(value, defaultRanges.allTime),
|
||||
};
|
||||
@@ -76,6 +81,7 @@ export function DatePickerWithRange({
|
||||
if (isActive.lastMonth) return "Last Month";
|
||||
if (isActive.last14Days) return "Last 14 Days";
|
||||
if (isActive.last2Days) return "Last 2 Days";
|
||||
if (isActive.last7Days) return "Last 7 Days";
|
||||
if (isActive.yesterday) return "Yesterday";
|
||||
if (isActive.today) return "Today";
|
||||
|
||||
@@ -88,12 +94,11 @@ export function DatePickerWithRange({
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
|
||||
const updateRangeAndClose = (range: DateRange) => {
|
||||
setCalendarOpen(false);
|
||||
onChange?.(range);
|
||||
};
|
||||
|
||||
const debouncedOnChange = useMemo(() => {
|
||||
return onChange ? debounce(onChange, 300) : undefined;
|
||||
return onChange ? debounce(onChange, 500) : undefined;
|
||||
}, [onChange]);
|
||||
|
||||
const handleOnSelect = (range?: DateRange) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TooltipVariants,
|
||||
} from "@components/Tooltip";
|
||||
import { TooltipProps } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -24,7 +25,9 @@ type Props = {
|
||||
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
} & TooltipProps;
|
||||
} & TooltipProps &
|
||||
TooltipVariants;
|
||||
|
||||
export default function FullTooltip({
|
||||
children,
|
||||
content,
|
||||
@@ -41,6 +44,7 @@ export default function FullTooltip({
|
||||
customOnOpenChange,
|
||||
delayDuration = 1,
|
||||
skipDelayDuration = 300,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(!!keepOpen);
|
||||
|
||||
@@ -66,7 +70,7 @@ export default function FullTooltip({
|
||||
<div
|
||||
className={cn(
|
||||
isAction ? "cursor-pointer" : "cursor-default",
|
||||
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
|
||||
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -82,6 +86,7 @@ export default function FullTooltip({
|
||||
alignOffset={20}
|
||||
forceMount={true}
|
||||
className={contentClassName}
|
||||
variant={variant}
|
||||
align={align}
|
||||
side={side}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ const variants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["bg-nb-gray-800 border-nb-gray-700 text-nb-gray-300 "],
|
||||
darker: ["bg-nb-gray-930 border-nb-gray-900 text-nb-gray-250 "],
|
||||
netbird: ["bg-netbird-100 text-netbird border-netbird "],
|
||||
},
|
||||
size: {
|
||||
@@ -30,7 +31,7 @@ export default function Kbd({
|
||||
size = "default",
|
||||
disabled = false,
|
||||
className,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
42
src/components/NetBirdLogo.tsx
Normal file
42
src/components/NetBirdLogo.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import Image from "next/image";
|
||||
import * as React from "react";
|
||||
import NetBirdLogoMark from "@/assets/netbird.svg";
|
||||
import NetBirdLogoFull from "@/assets/netbird-full.svg";
|
||||
|
||||
type Props = {
|
||||
size?: "default" | "large";
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: {
|
||||
desktop: 22,
|
||||
mobile: 30,
|
||||
},
|
||||
large: {
|
||||
desktop: 24,
|
||||
mobile: 40,
|
||||
},
|
||||
};
|
||||
|
||||
export const NetBirdLogo = ({ size = "default", mobile = true }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={NetBirdLogoFull}
|
||||
height={sizes[size].desktop}
|
||||
alt={"NetBird Logo"}
|
||||
className={cn(mobile && "hidden md:block")}
|
||||
/>
|
||||
{mobile && (
|
||||
<Image
|
||||
src={NetBirdLogoMark}
|
||||
width={sizes[size].mobile}
|
||||
alt={"NetBird Logo"}
|
||||
className={cn(mobile && "md:hidden ml-4")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -172,6 +172,7 @@ export function PeerSelector({
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
estimatedItemHeight={37}
|
||||
onSelect={(item) => {
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
item.version,
|
||||
|
||||
67
src/components/RadioCard.tsx
Normal file
67
src/components/RadioCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { ReactNode } from "react"; // or replace with clsx or similar
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-nb-gray-300 text-[0.8rem] text-left">
|
||||
{description}
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type RadioCardGroupProps = {
|
||||
value: string;
|
||||
onValueChange: (val: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
export const RadioCardGroup = ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
"aria-label": ariaLabel = "Options",
|
||||
}: RadioCardGroupProps) => {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,8 @@ type StepProps = {
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Step = ({
|
||||
@@ -32,6 +34,8 @@ const Step = ({
|
||||
line = true,
|
||||
center = false,
|
||||
horizontal,
|
||||
disabled = false,
|
||||
className,
|
||||
}: StepProps) => {
|
||||
return (
|
||||
<div
|
||||
@@ -39,6 +43,8 @@ const Step = ({
|
||||
"flex gap-4 items-start justify-start relative pb-6 -mx-1.5 group px-[2px]",
|
||||
center && "items-center",
|
||||
horizontal ? "flex-col items-center" : "min-w-full",
|
||||
disabled && "opacity-40 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{line && (
|
||||
@@ -57,6 +63,7 @@ const Step = ({
|
||||
"h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
|
||||
"[.stepper-bg-variant]:border-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
@@ -10,25 +11,58 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const tooltipClasses =
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50";
|
||||
export type TooltipVariants = VariantProps<typeof tooltipVariants>;
|
||||
|
||||
export const tooltipVariants = cva(
|
||||
[
|
||||
"z-[9999] overflow-hidden rounded-md border text-sm shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white dark:bg-nb-gray-940",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-930",
|
||||
],
|
||||
lighter: [
|
||||
"bg-white dark:bg-nb-gray-920",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-900",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className = "px-4 py-2.5", sideOffset = 7, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipClasses, className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> &
|
||||
TooltipVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className = "px-4 py-2.5",
|
||||
sideOffset = 7,
|
||||
variant = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
@@ -11,12 +11,16 @@ type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
renderItem?: (item: T, selected?: boolean) => React.ReactNode;
|
||||
renderHeading?: (item: T) => React.ReactNode;
|
||||
renderBeforeItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
itemWrapperClassName?: string;
|
||||
scrollAreaClassName?: string;
|
||||
maxHeight?: number;
|
||||
estimatedItemHeight?: number;
|
||||
estimatedHeadingHeight?: number;
|
||||
heightAdjustment?: number;
|
||||
groupKey?: (item: T) => string | undefined;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
@@ -24,13 +28,20 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
onSelect,
|
||||
renderItem,
|
||||
renderBeforeItem,
|
||||
renderHeading,
|
||||
itemClassName,
|
||||
itemWrapperClassName,
|
||||
scrollAreaClassName,
|
||||
maxHeight,
|
||||
estimatedItemHeight = 36,
|
||||
estimatedHeadingHeight = 16,
|
||||
heightAdjustment = 8,
|
||||
groupKey,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
|
||||
"mouse",
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,6 +58,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
|
||||
const navigation = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
setLastInputMethod("keyboard");
|
||||
if (items.length === 0) return;
|
||||
const length = items.length - 1;
|
||||
if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
|
||||
@@ -69,20 +81,54 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouse = () => setLastInputMethod("mouse");
|
||||
|
||||
window.addEventListener("keydown", navigation);
|
||||
window.addEventListener("mousemove", handleMouse);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", navigation);
|
||||
window.removeEventListener("mousemove", handleMouse);
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
const headingCount = useMemo(() => {
|
||||
if (!groupKey) return 0;
|
||||
|
||||
let count = 0;
|
||||
let prev: string | undefined;
|
||||
|
||||
for (const item of items) {
|
||||
const key = groupKey(item);
|
||||
if (key !== prev) {
|
||||
count++;
|
||||
prev = key;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}, [items, groupKey]);
|
||||
|
||||
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
|
||||
|
||||
const scrollAreaHeight = { maxHeight: maxHeight ?? 195 };
|
||||
|
||||
const virtuosoHeight = {
|
||||
height: Math.min(items.length * estimatedItemHeight + 8, maxHeight ?? 195),
|
||||
height: Math.min(
|
||||
items.length * estimatedItemHeight +
|
||||
headingCount * estimatedHeadingHeight +
|
||||
+(8 + heightAdjustment),
|
||||
maxHeight ?? 195,
|
||||
),
|
||||
};
|
||||
|
||||
const fixedItemHeight = useMemo(() => {
|
||||
if (!groupKey) return estimatedItemHeight;
|
||||
if (items.length === 0) return 0;
|
||||
const h = virtuosoHeight.height / items.length;
|
||||
if (isNaN(h)) return estimatedItemHeight;
|
||||
return h;
|
||||
}, [estimatedItemHeight, groupKey, items.length, virtuosoHeight.height]);
|
||||
|
||||
return (
|
||||
<MemoizedScrollArea
|
||||
withoutViewport={true}
|
||||
@@ -93,16 +139,26 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
ref={virtuosoRef}
|
||||
overscan={50}
|
||||
data={items}
|
||||
defaultItemHeight={fixedItemHeight}
|
||||
totalCount={items.length}
|
||||
fixedItemHeight={estimatedItemHeight}
|
||||
computeItemKey={(index) => items[index].id as string}
|
||||
context={{ selected, setSelected, onClick: onSelect }}
|
||||
itemContent={(index, option, { selected, setSelected, onClick }) => {
|
||||
const group = groupKey?.(option);
|
||||
const prevGroup =
|
||||
index > 0 ? groupKey?.(items[index - 1]) : undefined;
|
||||
const showHeading = group && group !== prevGroup;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showHeading && renderHeading?.(option)}
|
||||
{renderBeforeItem?.(option)}
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
onMouseEnter={() => {
|
||||
if (lastInputMethod === "mouse") {
|
||||
setSelected(index);
|
||||
}
|
||||
}}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
@@ -151,17 +207,13 @@ export const VirtualScrollListItemWrapper = memo(
|
||||
return (
|
||||
<div
|
||||
key={id ?? undefined}
|
||||
className={cn(
|
||||
"pr-3 pl-2 webkit-scroll group/list-item",
|
||||
isLast && "pb-2",
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className={cn("pr-3 pl-2 webkit-scroll", isLast && "pb-2", className)}
|
||||
onMouseOver={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md group/list-item",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
|
||||
itemClassName,
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Button from "@components/Button";
|
||||
import Button, { ButtonVariants } from "@components/Button";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
@@ -36,6 +36,7 @@ interface SelectDropdownProps {
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
isLoading?: boolean;
|
||||
variant?: ButtonVariants["variant"];
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -49,15 +50,13 @@ export function SelectDropdown({
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
isLoading = false,
|
||||
}: SelectDropdownProps) {
|
||||
variant = "input",
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
const toggle = (selectedValue: string) => {
|
||||
const isSelected = value == selectedValue;
|
||||
if (isSelected) {
|
||||
} else {
|
||||
onChange && onChange(selectedValue);
|
||||
}
|
||||
if (!isSelected) onChange?.(selectedValue);
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
@@ -66,18 +65,6 @@ export function SelectDropdown({
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
setSlice(options.length);
|
||||
}, 100);
|
||||
} else {
|
||||
setSlice(10);
|
||||
}
|
||||
}, [open, options]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -96,7 +83,6 @@ export function SelectDropdown({
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setSlice(10);
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
@@ -107,7 +93,7 @@ export function SelectDropdown({
|
||||
>
|
||||
<PopoverTrigger asChild={true} disabled={disabled || isLoading}>
|
||||
<Button
|
||||
variant={"input"}
|
||||
variant={variant}
|
||||
disabled={disabled || isLoading}
|
||||
ref={inputRef}
|
||||
className={"w-full"}
|
||||
@@ -146,7 +132,7 @@ export function SelectDropdown({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
|
||||
@@ -6,16 +6,26 @@ type Props = {
|
||||
withHeader?: boolean;
|
||||
};
|
||||
|
||||
export default function SkeletonTable({ withHeader = true }: Props) {
|
||||
export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
{withHeader && <SkeletonTableHeader />}
|
||||
<Skeleton
|
||||
height={48}
|
||||
containerClassName={"flex"}
|
||||
className={cn(withHeader && "mt-8")}
|
||||
/>
|
||||
<div>
|
||||
<div className={"mt-6"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableContentSkeleton() {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<div className={"mt-6"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
@@ -31,7 +41,7 @@ type RowProps = {
|
||||
odd?: boolean;
|
||||
};
|
||||
|
||||
export function TableSkeletonRow({ odd = false }: RowProps) {
|
||||
export function TableSkeletonRow({ odd = false }: Readonly<RowProps>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { TableContentSkeleton } from "@components/skeletons/SkeletonTable";
|
||||
import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch";
|
||||
import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal";
|
||||
import { DataTablePagination } from "@components/table/DataTablePagination";
|
||||
@@ -40,10 +40,10 @@ import {
|
||||
import { FilterFn } from "@tanstack/table-core";
|
||||
import { cn, removeAllSpaces } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { trim } from "lodash";
|
||||
import { isEqual, trim } from "lodash";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
declare module "@tanstack/table-core" {
|
||||
@@ -84,7 +84,6 @@ const isWithinRange: FilterFn<any> = (
|
||||
) => {
|
||||
const date = dayjs(row.getValue(columnId));
|
||||
const [start, end] = value;
|
||||
//If one filter defined and date is null filter it
|
||||
if ((start || end) && !date) return false;
|
||||
if (start && !end) {
|
||||
return date.isAfter(dayjs(start));
|
||||
@@ -147,6 +146,8 @@ interface DataTableProps<TData, TValue> {
|
||||
showSearchAndFilters?: boolean;
|
||||
rightSide?: (table: TanStackTable<TData>) => React.ReactNode;
|
||||
manualPagination?: boolean;
|
||||
manualFiltering?: boolean;
|
||||
manualColumnFiltering?: boolean;
|
||||
showHeader?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
setRowSelection?: React.Dispatch<React.SetStateAction<RowSelectionState>>;
|
||||
@@ -163,14 +164,23 @@ interface DataTableProps<TData, TValue> {
|
||||
initialPageSize?: number;
|
||||
uniqueKey?: string;
|
||||
resetRowSelectionOnSearch?: boolean;
|
||||
pageCount?: number;
|
||||
pagination?: { pageIndex: number; pageSize: number };
|
||||
onPaginationChange?: (pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) => void;
|
||||
totalRecords?: number;
|
||||
globalFilter?: string;
|
||||
onGlobalFilterChange?: (value: string) => void;
|
||||
columnFilters?: ColumnFiltersState;
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
|
||||
initialFilters?: ColumnFiltersState;
|
||||
initialSearch?: string;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
|
||||
if (props.isLoading) return <SkeletonTable withHeader={!props.minimal} />;
|
||||
return <DataTableContent {...props} />;
|
||||
}
|
||||
|
||||
export function DataTableContent<TData, TValue>({
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
children,
|
||||
@@ -196,6 +206,8 @@ export function DataTableContent<TData, TValue>({
|
||||
searchClassName,
|
||||
rightSide,
|
||||
manualPagination = false,
|
||||
manualFiltering = false,
|
||||
manualColumnFiltering = false,
|
||||
showHeader = true,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
@@ -212,18 +224,33 @@ export function DataTableContent<TData, TValue>({
|
||||
initialPageSize = 10,
|
||||
uniqueKey,
|
||||
resetRowSelectionOnSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
pageCount,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
totalRecords,
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters: externalColumnFilters,
|
||||
onColumnFiltersChange: externalOnColumnFiltersChange,
|
||||
initialFilters,
|
||||
initialSearch,
|
||||
onSearchClick,
|
||||
}: Readonly<DataTableProps<TData, TValue>>) {
|
||||
const path = usePathname();
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
[],
|
||||
keepStateInLocalStorage,
|
||||
);
|
||||
const [globalSearch, setGlobalSearch] = useLocalStorage(
|
||||
const [localColumnFilters, setLocalColumnFilters] =
|
||||
useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
[],
|
||||
keepStateInLocalStorage && !manualColumnFiltering,
|
||||
initialFilters,
|
||||
);
|
||||
const [localGlobalSearch, setLocalGlobalSearch] = useLocalStorage(
|
||||
`netbird-table-search${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
"",
|
||||
keepStateInLocalStorage,
|
||||
globalFilter || "",
|
||||
keepStateInLocalStorage && !manualFiltering,
|
||||
initialSearch,
|
||||
);
|
||||
|
||||
const [paginationState, setPaginationState] =
|
||||
@@ -232,10 +259,10 @@ export function DataTableContent<TData, TValue>({
|
||||
uniqueKey ? "/" + (uniqueKey as string) : path
|
||||
}`,
|
||||
{
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
},
|
||||
keepStateInLocalStorage,
|
||||
keepStateInLocalStorage && !manualPagination,
|
||||
);
|
||||
|
||||
const hasInitialData = !!(data && data.length > 0);
|
||||
@@ -253,19 +280,30 @@ export function DataTableContent<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection: rowSelection ?? {},
|
||||
columnFilters,
|
||||
columnFilters: manualColumnFiltering
|
||||
? externalColumnFilters || []
|
||||
: localColumnFilters,
|
||||
columnVisibility: columnVisibility,
|
||||
globalFilter: globalSearch,
|
||||
pagination: paginationState,
|
||||
globalFilter: manualFiltering ? globalFilter : localGlobalSearch,
|
||||
pagination: manualPagination
|
||||
? {
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
}
|
||||
: paginationState,
|
||||
},
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: initialPageSize || 10,
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
},
|
||||
columnFilters: initialFilters,
|
||||
globalFilter: initialSearch,
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
@@ -273,8 +311,36 @@ export function DataTableContent<TData, TValue>({
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPaginationState,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onPaginationChange: (updater) => {
|
||||
if (manualPagination) {
|
||||
if (isInitialRender.current) {
|
||||
isInitialRender.current = false;
|
||||
return;
|
||||
}
|
||||
if (typeof updater === "function") {
|
||||
const newState = updater(pagination!);
|
||||
onPaginationChange?.(newState);
|
||||
} else {
|
||||
onPaginationChange?.(updater);
|
||||
}
|
||||
} else {
|
||||
setPaginationState(updater);
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (filters) => {
|
||||
if (manualColumnFiltering) {
|
||||
externalOnColumnFiltersChange?.(filters as ColumnFiltersState);
|
||||
} else {
|
||||
setLocalColumnFilters(filters as ColumnFiltersState);
|
||||
}
|
||||
},
|
||||
onGlobalFilterChange: (value) => {
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.(value);
|
||||
} else {
|
||||
setLocalGlobalSearch(value);
|
||||
}
|
||||
},
|
||||
globalFilterFn: fuzzyFilter,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -298,8 +364,16 @@ export function DataTableContent<TData, TValue>({
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
table.setPageIndex(0);
|
||||
setColumnFilters([]);
|
||||
setGlobalSearch("");
|
||||
if (manualColumnFiltering) {
|
||||
externalOnColumnFiltersChange?.([]);
|
||||
} else {
|
||||
setLocalColumnFilters([]);
|
||||
}
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.("");
|
||||
} else {
|
||||
setLocalGlobalSearch("");
|
||||
}
|
||||
setRowSelection?.({});
|
||||
onFilterReset?.();
|
||||
setSearchKey((prev) => (prev === 0 ? 1 : 0));
|
||||
@@ -307,6 +381,36 @@ export function DataTableContent<TData, TValue>({
|
||||
|
||||
const [searchKey, setSearchKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualPagination && pagination) {
|
||||
const currentPagination = table.getState().pagination;
|
||||
if (isEqual(currentPagination, pagination)) return;
|
||||
|
||||
table.setPagination({
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
}, [manualPagination, pagination, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualFiltering && globalFilter !== undefined) {
|
||||
const currentGlobalFilter = table.getState().globalFilter;
|
||||
if (currentGlobalFilter !== globalFilter) {
|
||||
table.setGlobalFilter(globalFilter);
|
||||
}
|
||||
}
|
||||
}, [manualFiltering, globalFilter, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualColumnFiltering && externalColumnFilters) {
|
||||
const currentFilters = table.getState().columnFilters;
|
||||
if (!isEqual(currentFilters, externalColumnFilters)) {
|
||||
table.setColumnFilters(externalColumnFilters);
|
||||
}
|
||||
}
|
||||
}, [manualColumnFiltering, externalColumnFilters, table]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative table-fixed-scroll", className)}>
|
||||
{showSearchAndFilters && (
|
||||
@@ -318,45 +422,48 @@ export function DataTableContent<TData, TValue>({
|
||||
>
|
||||
<DataTableGlobalSearch
|
||||
className={searchClassName}
|
||||
disabled={!hasInitialData}
|
||||
disabled={false} // Never disable the search input
|
||||
key={searchKey}
|
||||
globalSearch={globalSearch}
|
||||
onClick={onSearchClick}
|
||||
isLoading={isLoading}
|
||||
globalSearch={
|
||||
manualFiltering ? globalFilter || "" : localGlobalSearch
|
||||
}
|
||||
setGlobalSearch={(val) => {
|
||||
table.setPageIndex(0);
|
||||
setGlobalSearch(val);
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.(val);
|
||||
} else {
|
||||
setLocalGlobalSearch(val);
|
||||
}
|
||||
resetRowSelectionOnSearch && setRowSelection?.({});
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
{children && children(table)}
|
||||
{children?.(table)}
|
||||
{showResetFilterButton && (
|
||||
<DataTableResetFilterButton onClick={resetFilters} table={table} />
|
||||
)}
|
||||
<div className={"flex gap-4 flex-wrap grow"}>
|
||||
<div className={"flex gap-4 flex-wrap"}></div>
|
||||
{rightSide && rightSide(table)}
|
||||
{rightSide?.(table)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aboveTable && aboveTable(table)}
|
||||
{aboveTable?.(table)}
|
||||
|
||||
{!hasInitialData && !isLoading && (
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{getStartedCard}
|
||||
</TableWrapper>
|
||||
)}
|
||||
|
||||
{hasInitialData && !isLoading && (
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{isLoading ? (
|
||||
<TableContentSkeleton />
|
||||
) : !hasInitialData ? (
|
||||
getStartedCard
|
||||
) : (
|
||||
<TableComponent
|
||||
className={cn("relative mt-8", tableClassName)}
|
||||
className={cn("relative mt-6", tableClassName)}
|
||||
minimal={minimal}
|
||||
>
|
||||
{showHeader && as == "table" && (
|
||||
@@ -398,94 +505,99 @@ export function DataTableContent<TData, TValue>({
|
||||
)}
|
||||
>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
key={row.original.id}
|
||||
>
|
||||
<>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={row.original.id}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"cursor-pointer relative group/accordion",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={
|
||||
accordion?.includes(row.original.id)
|
||||
? "opened"
|
||||
: "closed"
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (renderExpandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(row.original.id)) {
|
||||
return prev.filter(
|
||||
(item) => item !== row.original.id,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), row.original.id];
|
||||
}
|
||||
});
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
return (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
key={row.id}
|
||||
>
|
||||
<>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={row.original.id}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"relative group/accordion",
|
||||
(onRowClick || expandedRow) && "cursor-pointer",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={
|
||||
accordion?.includes(row.original.id)
|
||||
? "opened"
|
||||
: "closed"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
onClick={(e) => {
|
||||
if (expandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(row.original.id)) {
|
||||
return prev.filter(
|
||||
(item) => item !== row.original.id,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), row.original.id];
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</>
|
||||
</TableRowComponent>
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick &&
|
||||
onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</>
|
||||
</TableRowComponent>
|
||||
|
||||
{renderExpandedRow && (
|
||||
<AccordionContent asChild={true}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
key={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
{expandedRow && (
|
||||
<AccordionContent asChild={true}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
key={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{renderExpandedRow(row.original)}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
))
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
<TableCellComponent
|
||||
@@ -499,14 +611,15 @@ export function DataTableContent<TData, TValue>({
|
||||
</TableBodyComponent>
|
||||
</Accordion>
|
||||
</TableComponent>
|
||||
</TableWrapper>
|
||||
)}
|
||||
)}
|
||||
</TableWrapper>
|
||||
|
||||
<div className={paginationClassName}>
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
text={text}
|
||||
paginationPadding={paginationPaddingClassName}
|
||||
totalRecords={totalRecords}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,38 +9,66 @@ interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
setGlobalSearch: (value: string) => void;
|
||||
globalSearch?: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function DataTableGlobalSearch({
|
||||
setGlobalSearch,
|
||||
globalSearch,
|
||||
className = "min-w-[300px] max-w-[400px] grow",
|
||||
isLoading,
|
||||
onClick,
|
||||
...props
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const ref = React.useRef<HTMLInputElement>(null);
|
||||
const [inputValue, setInputValue] = useState(globalSearch || "");
|
||||
const debouncedValue = useDebounce(inputValue, 300);
|
||||
const debouncedValue = useDebounce(inputValue, 800);
|
||||
|
||||
// Call setGlobalSearch when debounced value changes
|
||||
useEffect(() => {
|
||||
setGlobalSearch(debouncedValue);
|
||||
}, [debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalSearch !== undefined && globalSearch !== inputValue) {
|
||||
setInputValue(globalSearch);
|
||||
}
|
||||
}, [globalSearch]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
useHotkeys("mod+k", () => ref.current?.focus(), []);
|
||||
useHotkeys(
|
||||
"mod+k",
|
||||
() => {
|
||||
if (onClick) {
|
||||
onClick?.();
|
||||
} else {
|
||||
ref.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
onFocus={(e) => {
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
icon={<Search size={15} />}
|
||||
value={inputValue} // Shows immediate updates
|
||||
onChange={handleChange}
|
||||
maxWidthClass={className}
|
||||
customSuffix={<Kbd>⌘ K</Kbd>}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,78 +7,83 @@ import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
text?: string;
|
||||
paginationPadding?: string;
|
||||
totalRecords?: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
text = "rows",
|
||||
paginationPadding = "px-8 py-8",
|
||||
totalRecords,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const allRows = table.getFilteredRowModel().rows.length;
|
||||
const rowsPerPage = table.getState().pagination.pageSize;
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const isLastPage = currentPage === table.getPageCount();
|
||||
const showingFrom = (currentPage - 1) * rowsPerPage + 1;
|
||||
const showingTo = isLastPage ? allRows : showingFrom + rowsPerPage - 1;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
// Reset page index if it's greater than the page count
|
||||
useEffect(() => {
|
||||
if (currentPage > pageCount) {
|
||||
table.setPageIndex(0);
|
||||
}
|
||||
}, []);
|
||||
const totalRows =
|
||||
totalRecords !== undefined
|
||||
? totalRecords
|
||||
: table.getFilteredRowModel().rows.length;
|
||||
|
||||
return pageCount > 1 ? (
|
||||
<div className={cn("flex items-center justify-between", paginationPadding)}>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{allRows}</span> {text}
|
||||
</div>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button>
|
||||
<div>
|
||||
{currentPage} of {pageCount}
|
||||
</div>
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
const showingFrom = totalRows === 0 ? 0 : (currentPage - 1) * rowsPerPage + 1;
|
||||
const showingTo = Math.min(currentPage * rowsPerPage, totalRows);
|
||||
|
||||
return (
|
||||
pageCount > 1 && (
|
||||
<div
|
||||
className={cn("flex items-center justify-between", paginationPadding)}
|
||||
>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{totalRows}</span>{" "}
|
||||
{text}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button>
|
||||
<div>
|
||||
{currentPage} of {pageCount}
|
||||
</div>
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,48 +8,54 @@ import { useState } from "react";
|
||||
interface Props<TData> {
|
||||
table: Table<TData>;
|
||||
onClick: () => void;
|
||||
hasServerSideFilters?: boolean;
|
||||
}
|
||||
|
||||
export default function DataTableResetFilterButton<TData>({
|
||||
table,
|
||||
onClick,
|
||||
hasServerSideFilters = undefined,
|
||||
}: Props<TData>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isDisabled =
|
||||
table.getState().columnFilters.length <= 0 &&
|
||||
table.getState().globalFilter === "";
|
||||
|
||||
return !isDisabled ? (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger
|
||||
asChild={true}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
const hasClientSideFilters =
|
||||
table.getState().globalFilter !== "" ||
|
||||
table.getState().columnFilters.length > 0;
|
||||
|
||||
const showButton = hasServerSideFilters ?? hasClientSideFilters;
|
||||
|
||||
return (
|
||||
showButton && (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger
|
||||
asChild={true}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<FilterX size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FilterX size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className={"px-3 py-2"}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (hovered) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className={"px-3 py-2"}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (hovered) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
export const GradientFadedBackground = () => {
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GradientFadedBackground = ({ className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none"
|
||||
}
|
||||
className={cn(
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
onError?: (error: boolean) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
showRemoveButton?: boolean;
|
||||
preventLeadingAndTrailingDots?: boolean;
|
||||
allowWildcard?: boolean;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export default function InputDomain({
|
||||
disabled,
|
||||
preventLeadingAndTrailingDots,
|
||||
allowWildcard = true,
|
||||
showRemoveButton = true,
|
||||
}: Readonly<Props>) {
|
||||
const [name, setName] = useState(value?.name || "");
|
||||
|
||||
@@ -88,14 +90,16 @@ export default function InputDomain({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { orderBy } from "lodash";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ArrowRightIcon, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
@@ -30,8 +30,17 @@ export default function MultipleGroups({
|
||||
onClick,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
if (!groups) return <EmptyRow />;
|
||||
const orderedGroups = orderBy(groups, ["peers_count", "name"], ["desc"]);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
if (!groups || groups?.length === 0) return <EmptyRow />;
|
||||
const orderedGroups = groups.sort((a, b) => {
|
||||
if (a.name === "All") return 1;
|
||||
if (b.name === "All") return -1;
|
||||
const aPeerCount = a.peers_count ?? 0;
|
||||
const bPeerCount = b.peers_count ?? 0;
|
||||
if (aPeerCount !== bPeerCount) return bPeerCount - aPeerCount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const firstGroup = orderedGroups.length > 0 ? orderedGroups[0] : undefined;
|
||||
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
|
||||
|
||||
@@ -48,12 +57,22 @@ export default function MultipleGroups({
|
||||
data-cy={"multiple-groups"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{firstGroup && <GroupBadge group={firstGroup} />}
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{otherGroups && otherGroups.length > 0 && (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={true}
|
||||
className={"px-3 gap-2 whitespace-nowrap"}
|
||||
className={cn(
|
||||
"px-3 gap-2 whitespace-nowrap",
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : "",
|
||||
)}
|
||||
>
|
||||
+ {otherGroups.length}
|
||||
</Badge>
|
||||
@@ -98,3 +117,15 @@ export default function MultipleGroups({
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const TransparentEditIconButton = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-[34px] w-[34px] !p-0 opacity-0 group-hover:opacity-100 flex items-center justify-center text-nb-gray-400 hover:text-nb-gray-100"
|
||||
}
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import React from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
type Props = {
|
||||
@@ -10,6 +12,8 @@ type Props = {
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
hasFiltersApplied?: boolean;
|
||||
onResetFilters?: () => void;
|
||||
};
|
||||
export default function NoResults({
|
||||
icon,
|
||||
@@ -17,7 +21,32 @@ export default function NoResults({
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
children,
|
||||
className,
|
||||
hasFiltersApplied = false,
|
||||
onResetFilters,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleResetClick = useCallback(() => {
|
||||
if (onResetFilters) {
|
||||
onResetFilters();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const page_size = searchParams.get("page_size");
|
||||
|
||||
params.set("page", "1");
|
||||
|
||||
if (page_size) {
|
||||
params.set("page_size", page_size);
|
||||
}
|
||||
|
||||
const newUrl = `${pathname}?${params.toString()}`;
|
||||
router.push(newUrl);
|
||||
}
|
||||
}, [onResetFilters, router, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<div
|
||||
@@ -49,6 +78,16 @@ export default function NoResults({
|
||||
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
{hasFiltersApplied && onResetFilters && (
|
||||
<Button
|
||||
onClick={handleResetClick}
|
||||
variant="secondary"
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterX size={16} />
|
||||
Reset Filters & Search
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
value: Direction;
|
||||
onChange: (value: Direction) => void;
|
||||
className?: string;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export type Direction = "bi" | "in" | "out";
|
||||
@@ -17,31 +19,8 @@ export default function PolicyDirection({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const toggleIn = () => {
|
||||
if (value == "in") {
|
||||
onChange("out");
|
||||
return;
|
||||
}
|
||||
if (value == "bi") {
|
||||
onChange("out");
|
||||
} else {
|
||||
onChange("bi");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOut = () => {
|
||||
if (value == "out") {
|
||||
onChange("in");
|
||||
return;
|
||||
}
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
} else {
|
||||
onChange("bi");
|
||||
}
|
||||
};
|
||||
|
||||
destinationResource,
|
||||
}: Readonly<Props>) {
|
||||
const toggleDirection = () => {
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
@@ -55,8 +34,34 @@ export default function PolicyDirection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
|
||||
const topBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "blueDark";
|
||||
if (value === "bi") return "green";
|
||||
if (value === "in") return "blueDark";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const topArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-sky-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
if (value === "in") return "fill-sky-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const bottomBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "gray";
|
||||
if (value === "bi") return "green";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const bottomArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-gray-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
@@ -66,39 +71,20 @@ export default function PolicyDirection({
|
||||
onClick={toggleDirection}
|
||||
data-cy={"policy-direction"}
|
||||
>
|
||||
<Badge
|
||||
variant={value == "bi" ? "green" : value == "in" ? "blueDark" : "gray"}
|
||||
className={"px-4 py-1"}
|
||||
>
|
||||
<Badge variant={topBadgeClass} className={"px-4 py-1"}>
|
||||
<LongArrowLeftIcon
|
||||
size={40}
|
||||
autoHeight={true}
|
||||
className={cn(
|
||||
value == "bi"
|
||||
? "fill-green-500"
|
||||
: value == "in"
|
||||
? "fill-sky-500"
|
||||
: "fill-gray-500",
|
||||
"rotate-180",
|
||||
)}
|
||||
className={cn(topArrowClass, "rotate-180")}
|
||||
/>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={value == "bi" ? "green" : value == "out" ? "blueDark" : "gray"}
|
||||
className={"px-4 py-1"}
|
||||
>
|
||||
<Badge variant={bottomBadgeClass} className={"px-4 py-1"}>
|
||||
<LongArrowLeftIcon
|
||||
size={40}
|
||||
autoHeight={true}
|
||||
className={cn(
|
||||
value == "bi"
|
||||
? "fill-green-500"
|
||||
: value == "out"
|
||||
? "fill-sky-500"
|
||||
: "fill-gray-500",
|
||||
)}
|
||||
className={cn(bottomArrowClass)}
|
||||
/>
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ export function useSearch<T>(
|
||||
string,
|
||||
(event: ChangeEvent<HTMLInputElement> | string) => void,
|
||||
(querty: string) => void,
|
||||
boolean,
|
||||
] {
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||
const isMounted = useRef<boolean>(false);
|
||||
const [query, setQuery] = useState<string>(initialQuery);
|
||||
const prevCollection = usePrevious(collection);
|
||||
@@ -62,6 +64,7 @@ export function useSearch<T>(
|
||||
setFilteredCollection(
|
||||
filterCollection(collection, predicate, query, filter),
|
||||
);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
debounce,
|
||||
@@ -75,8 +78,10 @@ export function useSearch<T>(
|
||||
!isEqual(predicate, prevPredicate) ||
|
||||
!isEqual(query, prevQuery) ||
|
||||
!isEqual(filter, prevFilter)
|
||||
)
|
||||
) {
|
||||
if (!isEqual(query, prevQuery)) setIsSearching(true);
|
||||
debouncedFilterCollection(collection, predicate, query, filter);
|
||||
}
|
||||
}, [collection, predicate, query, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,5 +92,5 @@ export function useSearch<T>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [filteredCollection, query, handleChange, setQuery];
|
||||
return [filteredCollection, query, handleChange, setQuery, isSearching];
|
||||
}
|
||||
|
||||
@@ -104,4 +104,50 @@ export const NameserverPresets: Record<string, NameserverGroup> = {
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0: {
|
||||
name: "DNS0.EU",
|
||||
description: "DNS0.EU DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0Zero: {
|
||||
name: "DNS0.EU Zero",
|
||||
description: "DNS0.EU Zero DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { NetBirdLogo } from "@components/NetBirdLogo";
|
||||
import { AnnouncementBanner } from "@components/ui/AnnouncementBanner";
|
||||
import UserDropdown from "@components/ui/UserDropdown";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useMemo } from "react";
|
||||
import NetBirdLogo from "@/assets/netbird.svg";
|
||||
import NetBirdLogoFull from "@/assets/netbird-full.svg";
|
||||
import React from "react";
|
||||
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -18,25 +16,6 @@ export const headerHeight = 75;
|
||||
|
||||
export default function NavbarWithDropdown() {
|
||||
const router = useRouter();
|
||||
const Logo = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={NetBirdLogoFull}
|
||||
height={22}
|
||||
alt={"NetBird Logo"}
|
||||
className={"hidden md:block"}
|
||||
/>
|
||||
<Image
|
||||
src={NetBirdLogo}
|
||||
width={30}
|
||||
alt={"NetBird Logo"}
|
||||
className={"md:hidden ml-4"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { toggleMobileNav } = useApplicationContext();
|
||||
const { bannerHeight } = useAnnouncement();
|
||||
const { isRestricted } = usePermissions();
|
||||
@@ -78,7 +57,7 @@ export default function NavbarWithDropdown() {
|
||||
"cursor-pointer hover:opacity-70 transition-all mr-auto"
|
||||
}
|
||||
>
|
||||
{Logo}
|
||||
<NetBirdLogo />
|
||||
</button>
|
||||
<ToggleCollapsableNavigationButton />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
@@ -29,6 +30,7 @@ import { Textarea } from "@components/Textarea";
|
||||
import PolicyDirection from "@components/ui/PolicyDirection";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ArrowRightLeft,
|
||||
ExternalLinkIcon,
|
||||
FolderDown,
|
||||
@@ -130,11 +132,13 @@ export function AccessControlModalContent({
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const {
|
||||
portAndDirectionDisabled,
|
||||
portDisabled,
|
||||
destinationGroups,
|
||||
direction,
|
||||
ports,
|
||||
sourceGroups,
|
||||
destinationHasResources,
|
||||
destinationOnlyResources,
|
||||
setSourceGroups,
|
||||
setDestinationGroups,
|
||||
setPorts,
|
||||
@@ -156,6 +160,7 @@ export function AccessControlModalContent({
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
setPortRanges,
|
||||
hasPortSupport,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
@@ -183,17 +188,10 @@ export function AccessControlModalContent({
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
if (p == "icmp") {
|
||||
if (!hasPortSupport(p)) {
|
||||
setPorts([]);
|
||||
setPortRanges([]);
|
||||
}
|
||||
if (p == "all") {
|
||||
setPorts([]);
|
||||
setPortRanges([]);
|
||||
}
|
||||
if (p == "tcp" || p == "udp") {
|
||||
setDirection("in");
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
@@ -301,7 +299,8 @@ export function AccessControlModalContent({
|
||||
<PolicyDirection
|
||||
value={direction}
|
||||
onChange={setDirection}
|
||||
disabled={portAndDirectionDisabled}
|
||||
disabled={destinationOnlyResources}
|
||||
destinationResource={destinationResource}
|
||||
/>
|
||||
|
||||
<div className={"w-full self-start"}>
|
||||
@@ -329,10 +328,28 @@ export function AccessControlModalContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{destinationHasResources &&
|
||||
!destinationOnlyResources &&
|
||||
direction === "bi" && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="mb-4"
|
||||
>
|
||||
Some destination groups contain resources. Resources only
|
||||
support incoming traffic and cannot initiate connections.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2",
|
||||
portAndDirectionDisabled && "opacity-30 pointer-events-none",
|
||||
portDisabled && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
@@ -352,7 +369,7 @@ export function AccessControlModalContent({
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portAndDirectionDisabled}
|
||||
disabled={portDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,20 @@ import { Policy } from "@/interfaces/Policy";
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlDirectionCell({ policy }: Props) {
|
||||
export default function AccessControlDirectionCell({
|
||||
policy,
|
||||
}: Readonly<Props>) {
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
const bidirectional = firstRule ? firstRule.bidirectional : false;
|
||||
const isSingleResource = !!firstRule?.destinationResource;
|
||||
|
||||
return (
|
||||
<div className={"flex h-full"}>
|
||||
{bidirectional ? (
|
||||
{bidirectional && !isSingleResource ? (
|
||||
<Badge variant={"green"} className={"py-2 px-4"}>
|
||||
<LongArrowBidirectionalIcon
|
||||
size={60}
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
@@ -162,6 +162,12 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
filterFn: "exactMatch",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ cell }) => <AccessControlActionCell policy={cell.row.original} />,
|
||||
@@ -176,6 +182,8 @@ export default function AccessControlTable({
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
const { permission } = usePermissions();
|
||||
const params = useSearchParams();
|
||||
const idParam = params.get("id") ?? undefined;
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
@@ -205,12 +213,25 @@ export default function AccessControlTable({
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
keepStateInLocalStorage={!idParam}
|
||||
initialSearch={idParam ? "" : undefined}
|
||||
initialFilters={
|
||||
idParam
|
||||
? [
|
||||
{
|
||||
id: "id",
|
||||
value: idParam,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
text={"Access Control Policies"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={AccessControlTableColumns}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
data={policies}
|
||||
onRowClick={(row, cell) => {
|
||||
|
||||
@@ -96,9 +96,9 @@ export const useAccessControl = ({
|
||||
firstRule ? firstRule.protocol : "all",
|
||||
);
|
||||
const [direction, setDirection] = useState<Direction>(() => {
|
||||
if (firstRule && firstRule?.bidirectional) return "bi";
|
||||
if (firstRule && firstRule?.bidirectional == false) return "in";
|
||||
return "bi";
|
||||
if (!firstRule) return "bi";
|
||||
if (firstRule.bidirectional) return "bi";
|
||||
return "in";
|
||||
});
|
||||
const [name, setName] = useState(policy?.name || initialName || "");
|
||||
const [description, setDescription] = useState(
|
||||
@@ -273,7 +273,52 @@ export const useAccessControl = ({
|
||||
}
|
||||
};
|
||||
|
||||
const portAndDirectionDisabled = protocol == "icmp" || protocol == "all";
|
||||
const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp";
|
||||
const portDisabled = !hasPortSupport(protocol);
|
||||
|
||||
const destinationHasResources = useMemo(() => {
|
||||
if (destinationResource) return true;
|
||||
|
||||
return destinationGroups.some((group) => {
|
||||
if (group.resources_count !== undefined) {
|
||||
return group.resources_count > 0;
|
||||
}
|
||||
if (group.resources && Array.isArray(group.resources)) {
|
||||
return group.resources.length > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [destinationGroups, destinationResource]);
|
||||
|
||||
const destinationOnlyResources = useMemo(() => {
|
||||
if (destinationResource) return true;
|
||||
|
||||
return (
|
||||
destinationGroups.length > 0 &&
|
||||
destinationGroups.every((group) => {
|
||||
const hasPeers =
|
||||
group.peers_count !== undefined
|
||||
? group.peers_count > 0
|
||||
: group.peers &&
|
||||
Array.isArray(group.peers) &&
|
||||
group.peers.length > 0;
|
||||
const hasResources =
|
||||
group.resources_count !== undefined
|
||||
? group.resources_count > 0
|
||||
: group.resources &&
|
||||
Array.isArray(group.resources) &&
|
||||
group.resources.length > 0;
|
||||
|
||||
return hasResources && !hasPeers;
|
||||
})
|
||||
);
|
||||
}, [destinationGroups, destinationResource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (destinationOnlyResources && direction !== "in") {
|
||||
setDirection("in");
|
||||
}
|
||||
}, [destinationOnlyResources, direction, setDirection]);
|
||||
|
||||
return {
|
||||
protocol,
|
||||
@@ -298,10 +343,13 @@ export const useAccessControl = ({
|
||||
setPostureChecks,
|
||||
submit,
|
||||
getPolicyData,
|
||||
portAndDirectionDisabled,
|
||||
portDisabled,
|
||||
isPostureChecksLoading,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
destinationHasResources,
|
||||
destinationOnlyResources,
|
||||
hasPortSupport,
|
||||
} as const;
|
||||
};
|
||||
|
||||
|
||||
@@ -121,13 +121,13 @@ export default function ActivityTable({
|
||||
return (
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
wrapperClassName={"gap-0 flex flex-col"}
|
||||
tableClassName={"px-8 mt-10"}
|
||||
paginationClassName={"max-w-[800px]"}
|
||||
as={"div"}
|
||||
text={"Audit Events"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
wrapperClassName={"gap-0 flex flex-col"}
|
||||
tableClassName={"px-8 pt-4"}
|
||||
columns={ActivityFeedColumnsTable}
|
||||
data={events}
|
||||
searchPlaceholder={"Search by audit name, user, peer, meta..."}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sortBy, trim, uniqBy } from "lodash";
|
||||
import { ChevronsUpDown, Cog, SearchIcon, UserCircle2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
|
||||
|
||||
@@ -37,7 +38,8 @@ export function UsersDropdownSelector({
|
||||
}: Props) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const search = useDebounce(searchInput, 500);
|
||||
|
||||
const toggle = (item: string | undefined) => {
|
||||
const isSelected = value == item;
|
||||
@@ -45,7 +47,7 @@ export function UsersDropdownSelector({
|
||||
onChange && onChange(undefined);
|
||||
} else {
|
||||
onChange && onChange(item);
|
||||
setSearch("");
|
||||
setSearchInput("");
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
@@ -69,7 +71,7 @@ export function UsersDropdownSelector({
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
setSearchInput("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
@@ -155,8 +157,8 @@ export function UsersDropdownSelector({
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
value={searchInput}
|
||||
onValueChange={setSearchInput}
|
||||
placeholder={"Search user..."}
|
||||
/>
|
||||
<div
|
||||
@@ -182,7 +184,6 @@ export function UsersDropdownSelector({
|
||||
className={"py-1 px-2"}
|
||||
onSelect={() => {
|
||||
toggle(undefined);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
@@ -217,7 +218,6 @@ export function UsersDropdownSelector({
|
||||
className={"py-1 px-2"}
|
||||
onSelect={() => {
|
||||
toggle(user.email);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -64,7 +67,7 @@ export default function GroupsRow({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setModal && permission.groups.update && setModal(true);
|
||||
setModal && permission.groups.update && !disabled && setModal(true);
|
||||
}}
|
||||
>
|
||||
{foundGroups?.length == 0 && showAddGroupButton ? (
|
||||
@@ -73,7 +76,15 @@ export default function GroupsRow({
|
||||
Add Groups
|
||||
</Badge>
|
||||
) : (
|
||||
<MultipleGroups groups={foundGroups} label={label} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
disabled ? "cursor-default" : "group",
|
||||
)}
|
||||
>
|
||||
<MultipleGroups groups={foundGroups} label={label} />
|
||||
{!disabled && <TransparentEditIconButton />}
|
||||
</div>
|
||||
)}
|
||||
</ModalTrigger>
|
||||
<EditGroupsModal
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalContent, ModalTrigger } from "@components/modal/Modal";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import React, { useState } from "react";
|
||||
import CloudflareLogo from "@/assets/nameservers/cloudflare.svg";
|
||||
import DNS0Logo from "@/assets/nameservers/dns0.svg";
|
||||
import DNS0ZeroLogo from "@/assets/nameservers/dns0-zero.svg";
|
||||
import GoogleLogo from "@/assets/nameservers/google.svg";
|
||||
import Quad9Logo from "@/assets/nameservers/quad9.svg";
|
||||
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
|
||||
@@ -13,7 +16,7 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function NameserverTemplateModal({ children }: Props) {
|
||||
export default function NameserverTemplateModal({ children }: Readonly<Props>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [presetModal, setPresetModal] = useState(false);
|
||||
const [preset, setPreset] = useState(NameserverPresets.Default);
|
||||
@@ -49,11 +52,11 @@ type ModalProps = {
|
||||
|
||||
export function NameserverTemplateModalContent({
|
||||
onePresetSelection,
|
||||
}: ModalProps) {
|
||||
}: Readonly<ModalProps>) {
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"} showClose={true}>
|
||||
<ModalContent maxWidthClass={"max-w-5xl"} showClose={true}>
|
||||
<div className={"px-8 py-3 flex flex-col gap-6 mt-4"}>
|
||||
<div className={"grid grid-cols-1 gap-4"}>
|
||||
<div className={"grid grid-cols-1 md:grid-cols-2 gap-4"}>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.Google)}
|
||||
src={GoogleLogo}
|
||||
@@ -61,6 +64,7 @@ export function NameserverTemplateModalContent({
|
||||
description={
|
||||
"A free, global DNS resolution service by Google that implements a number of security, performance, and compliance improvements."
|
||||
}
|
||||
href={"https://developers.google.com/speed/public-dns"}
|
||||
/>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.Cloudflare)}
|
||||
@@ -69,6 +73,26 @@ export function NameserverTemplateModalContent({
|
||||
description={
|
||||
"Enterprise-grade DNS service that offers the fastest response time, unparalleled redundancy, and advanced security with built-in DDoS mitigation and DNSSEC."
|
||||
}
|
||||
href={"https://www.cloudflare.com/learning/dns/what-is-1.1.1.1/"}
|
||||
/>
|
||||
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.DNS0)}
|
||||
src={DNS0Logo}
|
||||
title={"DNS0.EU DNS"}
|
||||
description={
|
||||
"A free, sovereign and GDPR-compliant DNS resolver with a strong focus on security to protect the citizens and organizations of the European Union."
|
||||
}
|
||||
href={"https://www.dns0.eu/"}
|
||||
/>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.DNS0Zero)}
|
||||
src={DNS0ZeroLogo}
|
||||
title={"DNS0.EU Zero DNS"}
|
||||
description={
|
||||
"Increase the catch rate for malicious domains by combining human-vetted threat intelligence with advanced heuristics that automatically identify high-risk patterns."
|
||||
}
|
||||
href={"https://www.dns0.eu/zero"}
|
||||
/>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.Quad9)}
|
||||
@@ -77,6 +101,7 @@ export function NameserverTemplateModalContent({
|
||||
description={
|
||||
"The Quad9 DNS service is operated by the Swiss-based Quad9 Foundation, whose mission is to provide a safer and more robust Internet for everyone."
|
||||
}
|
||||
href={"https://quad9.net/"}
|
||||
/>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.Default)}
|
||||
@@ -98,15 +123,19 @@ function NameserverTemplate({
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
href,
|
||||
hrefTitle,
|
||||
}: Readonly<{
|
||||
src?: StaticImageData;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
href?: string;
|
||||
hrefTitle?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={
|
||||
"bg-nb-gray-930/90 h-full hover:bg-nb-gray-900 border transition-all cursor-pointer border-nb-gray-900 hover:border-nb-gray-800 flex items-center rounded-lg overflow-hidden"
|
||||
}
|
||||
@@ -115,18 +144,35 @@ function NameserverTemplate({
|
||||
<div
|
||||
className={cn(
|
||||
"w-1/4",
|
||||
"bg-gradient-to-b h-full flex items-center justify-center from-white to-nb-gray-200 overflow-hidden p-6 border-r border-nb-gray-800",
|
||||
"bg-gradient-to-b h-full flex items-center justify-center from-white to-nb-gray-200 overflow-hidden p-4 border-r border-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
{src && <Image src={src} alt={title} width={100} />}
|
||||
{icon && icon}
|
||||
</div>
|
||||
<div className={"h-full flex flex-col text-left p-4 w-3/4"}>
|
||||
<p className={"font-medium text-sm"}>{title}</p>
|
||||
<div className={"h-full flex flex-col text-left px-4 py-3 w-3/4"}>
|
||||
<div className={"flex items-center"}>
|
||||
<p className={"font-medium text-sm"}>{title}</p>
|
||||
</div>
|
||||
{description && (
|
||||
<p className={"text-xs !text-nb-gray-300 mt-1"}>{description}</p>
|
||||
)}
|
||||
{href && (
|
||||
<div className={"relative mt-auto"}>
|
||||
<InlineLink
|
||||
href={href}
|
||||
className={"text-xs inline-flex"}
|
||||
target={"_blank"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{hrefTitle || "Learn more"}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -45,25 +45,22 @@ export const NetworkInformationSquare = ({
|
||||
<div
|
||||
className={cn(
|
||||
"h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br absolute bottom-0 right-0 transition-all",
|
||||
onClick && "group-hover/network:bg-nb-gray-910",
|
||||
onClick && "group-hover/table-row:bg-nb-gray-940",
|
||||
onClick && "group-hover/network:!bg-nb-gray-910",
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<div className={"mt-[0px] flex items-center flex-wrap"}>
|
||||
<TruncatedText
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium text-white text-left",
|
||||
"font-medium",
|
||||
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
|
||||
)}
|
||||
maxChars={24}
|
||||
text={name}
|
||||
/>
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
"text-left text-sm text-nb-gray-400",
|
||||
size == "lg" && "text-md mt-0.5",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<DescriptionWithTooltip
|
||||
className={cn("text-left", size == "lg" && "text-md mt-0.5")}
|
||||
maxChars={24}
|
||||
text={description}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
@@ -15,13 +17,14 @@ export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
className={"flex cursor-pointer"}
|
||||
className={"flex cursor-pointer items-center justify-center gap-1 group"}
|
||||
onClick={() => {
|
||||
if (!network || !permission.networks.update) return;
|
||||
openResourceGroupModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups groups={resource?.groups as Group[]} />
|
||||
{permission.networks.update && <TransparentEditIconButton />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,13 +5,26 @@ import { validator } from "@utils/helpers";
|
||||
import cidr from "ip-cidr";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
onError?: (error: string) => void;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
export const ResourceSingleAddressInput = ({ value, onChange }: Props) => {
|
||||
export const ResourceSingleAddressInput = ({
|
||||
value,
|
||||
onChange,
|
||||
label = "Address",
|
||||
className = "",
|
||||
onError,
|
||||
description = "Enter a single IP address, CIDR block or domain name",
|
||||
placeholder = "Address (IP, CIDR or Domain)",
|
||||
}: Props) => {
|
||||
const hasChars = useMemo(() => {
|
||||
return !!value.match(/[a-z*]/i);
|
||||
}, [value]);
|
||||
@@ -31,35 +44,39 @@ export const ResourceSingleAddressInput = ({ value, onChange }: Props) => {
|
||||
|
||||
// Case 1: If it has characters (potential domain) but is not a CIDR block
|
||||
if (hasChars && !isCIDRBlock) {
|
||||
if (!validator.isValidDomain(value)) {
|
||||
return "Please enter a valid domain, e.g. intra.example.com or *.example.com";
|
||||
if (
|
||||
!validator.isValidDomain(value) ||
|
||||
!value.includes(".") ||
|
||||
value.endsWith(".")
|
||||
) {
|
||||
return "Please enter a valid domain, e.g. service.internal, example.com or *.example.com";
|
||||
}
|
||||
return ""; // Valid domain
|
||||
}
|
||||
|
||||
// Case 2: If it's not a valid domain, check if it's a valid CIDR
|
||||
if (!cidr.isValidAddress(value)) {
|
||||
return "Please enter a valid IP or CIDR, e.g., 192.168.1.0/24";
|
||||
return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24";
|
||||
}
|
||||
|
||||
return ""; // Valid CIDR
|
||||
}, [value, hasChars, isCIDRBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
onError?.(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label>Address</Label>
|
||||
<HelpText>
|
||||
Enter a single IP address, CIDR block or domain name
|
||||
</HelpText>
|
||||
<Input
|
||||
customPrefix={PrefixIcon}
|
||||
error={error}
|
||||
placeholder={"Address (IP, CIDR or Domain)"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className={className}>
|
||||
<Label>{label}</Label>
|
||||
<HelpText>{description}</HelpText>
|
||||
<Input
|
||||
customPrefix={PrefixIcon}
|
||||
error={error}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -30,6 +31,11 @@ type Props = {
|
||||
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
filterFn: "exactMatch",
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Resource</DataTableHeader>;
|
||||
@@ -101,6 +107,8 @@ export default function ResourcesTable({
|
||||
headingTarget,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const params = useSearchParams();
|
||||
const resourceId = params.get("resource") ?? undefined;
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const { openResourceModal, network } = useNetworksContext();
|
||||
@@ -119,6 +127,10 @@ export default function ResourcesTable({
|
||||
text={"Resources"}
|
||||
columns={NetworkResourceColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
initialFilters={
|
||||
resourceId ? [{ id: "id", value: resourceId }] : undefined
|
||||
}
|
||||
initialSearch={resourceId}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
isLoading={isLoading}
|
||||
@@ -134,6 +146,7 @@ export default function ResourcesTable({
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
rightSide={() => (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -25,6 +25,7 @@ import NetworkNameCell from "@/modules/networks/table/NetworkNameCell";
|
||||
import { NetworkPolicyCell } from "@/modules/networks/table/NetworkPolicyCell";
|
||||
import { NetworkResourceCell } from "@/modules/networks/table/NetworkResourceCell";
|
||||
import NetworkRoutingPeerCell from "@/modules/networks/table/NetworkRoutingPeerCell";
|
||||
import { GlobalSearchModal } from "@/modules/search/GlobalSearchModal";
|
||||
|
||||
export const NetworkTableColumns: ColumnDef<Network>[] = [
|
||||
{
|
||||
@@ -79,9 +80,10 @@ export default function NetworksTable({
|
||||
isLoading,
|
||||
data,
|
||||
headingTarget,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
const [searchModal, setSearchModal] = useState(false);
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
@@ -95,75 +97,85 @@ export default function NetworksTable({
|
||||
);
|
||||
|
||||
return (
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"Networks"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={NetworkTableColumns}
|
||||
data={data}
|
||||
searchPlaceholder={"Search by network name or description..."}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<NetworkRoutesIcon className={"fill-nb-gray-200"} size={20} />
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<>
|
||||
<GlobalSearchModal open={searchModal} setOpen={setSearchModal} />
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"Networks"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={NetworkTableColumns}
|
||||
data={data}
|
||||
searchPlaceholder={"Search by network name or description..."}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
onSearchClick={() => setSearchModal(true)}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<NetworkRoutesIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() =>
|
||||
data &&
|
||||
data.length > 0 && (
|
||||
<div className={cn("gap-x-4 ml-auto flex")}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() =>
|
||||
data &&
|
||||
data.length > 0 && (
|
||||
<div className={cn("gap-x-4 ml-auto flex")}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage table={table} disabled={data?.length == 0} />
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/networks").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</NetworkProvider>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={data?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/networks").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</NetworkProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ export const OperatingSystemTab = ({
|
||||
|
||||
useEffect(() => {
|
||||
onError(versionError);
|
||||
}, [versionError]);
|
||||
}, [versionError, onError]);
|
||||
|
||||
return (
|
||||
<div className={""}>
|
||||
|
||||
391
src/modules/search/GlobalSearchModal.tsx
Normal file
391
src/modules/search/GlobalSearchModal.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import Kbd from "@components/Kbd";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CornerDownLeft,
|
||||
GlobeIcon,
|
||||
LayersIcon,
|
||||
NetworkIcon,
|
||||
TextSearchIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
enum SearchType {
|
||||
Network = "network",
|
||||
NetworkResource = "network-resource",
|
||||
}
|
||||
|
||||
type SearchResult<T, U extends SearchType> = {
|
||||
type: U;
|
||||
id: string;
|
||||
data: T;
|
||||
onAction?: (item: T) => void;
|
||||
};
|
||||
|
||||
type NetworkSearchResult = SearchResult<Network, SearchType.Network>;
|
||||
type ResourceSearchResult = SearchResult<
|
||||
NetworkResource,
|
||||
SearchType.NetworkResource
|
||||
>;
|
||||
type AnySearchResult = NetworkSearchResult | ResourceSearchResult;
|
||||
|
||||
const searchPredicate = (item: AnySearchResult, query: string) => {
|
||||
if (!query) return false;
|
||||
const lower = removeAllSpaces(query.toLowerCase());
|
||||
const { name, description, id } = item.data;
|
||||
const find = (s: string | undefined) =>
|
||||
removeAllSpaces(s?.toLowerCase()).includes(lower);
|
||||
|
||||
if (item.type === SearchType.Network) {
|
||||
if (find(name)) return true;
|
||||
if (find(description)) return true;
|
||||
if (find(id)) return true;
|
||||
}
|
||||
|
||||
if (item.type === SearchType.NetworkResource) {
|
||||
if (find(name)) return true;
|
||||
if (find(description)) return true;
|
||||
if (find(item.data?.address)) return true;
|
||||
if (find(id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const GlobalSearchModal = ({ open, setOpen }: Props) => {
|
||||
return open && <GlobalSearchModalContent open={open} setOpen={setOpen} />;
|
||||
};
|
||||
|
||||
const GlobalSearchModalContent = ({ open, setOpen }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: networks, isLoading: isNetworksLoading } = useFetchApi<
|
||||
Network[]
|
||||
>("/networks", true, false, open, {
|
||||
key: "global-search-networks",
|
||||
});
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources", true, false, open, {
|
||||
key: "global-search-resources",
|
||||
});
|
||||
|
||||
const findNetworkByResourceId = (resourceId: string) => {
|
||||
return networks?.find(
|
||||
(network) => network.resources?.some((res) => res === resourceId),
|
||||
);
|
||||
};
|
||||
|
||||
const items: AnySearchResult[] = useMemo(() => {
|
||||
if (isNetworksLoading || isResourcesLoading) return [];
|
||||
const networkResults: NetworkSearchResult[] = (networks ?? []).map(
|
||||
(network) => ({
|
||||
type: SearchType.Network,
|
||||
id: network.id,
|
||||
data: network,
|
||||
onAction: () => router.push(`/network?id=${network.id}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const resourceResults: ResourceSearchResult[] = (resources ?? []).map(
|
||||
(resource) => ({
|
||||
type: SearchType.NetworkResource,
|
||||
id: resource.id,
|
||||
data: resource,
|
||||
onAction: () => {
|
||||
const network = findNetworkByResourceId(resource.id);
|
||||
if (network)
|
||||
router.push(`/network?id=${network.id}&resource=${resource.id}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return [...networkResults, ...resourceResults];
|
||||
}, [isNetworksLoading, isResourcesLoading, networks, resources]);
|
||||
|
||||
const [filteredItems, search, setSearch, setQuery, isSearching] = useSearch(
|
||||
items,
|
||||
searchPredicate,
|
||||
{
|
||||
filter: false,
|
||||
debounce: 350,
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = isNetworksLoading || isResourcesLoading || isSearching;
|
||||
|
||||
const networksCount = useMemo(() => {
|
||||
return filteredItems.filter((i) => i.type === SearchType.Network).length;
|
||||
}, [filteredItems]);
|
||||
|
||||
const resourcesCount = useMemo(() => {
|
||||
return filteredItems.filter((i) => i.type === SearchType.NetworkResource)
|
||||
.length;
|
||||
}, [filteredItems]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setSearch("");
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
showClose={false}
|
||||
className={"py-0 overflow-hidden"}
|
||||
maxWidthClass={"max-w-xl"}
|
||||
>
|
||||
<DropdownInput
|
||||
hideEnterIcon={true}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
{search === "" && <BlankState />}
|
||||
|
||||
{isLoading && search !== "" && <LoadingState />}
|
||||
|
||||
{!isSearching && search !== "" && filteredItems.length === 0 && (
|
||||
<NotFoundState />
|
||||
)}
|
||||
|
||||
{!isSearching && search != "" && filteredItems.length !== 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
maxHeight={400}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
groupKey={(i) => i.type}
|
||||
estimatedItemHeight={48}
|
||||
estimatedHeadingHeight={32}
|
||||
heightAdjustment={5}
|
||||
onSelect={(item) => {
|
||||
const { onAction, data, type } = item;
|
||||
if (type === SearchType.Network) onAction?.(data);
|
||||
if (type === SearchType.NetworkResource) onAction?.(data);
|
||||
}}
|
||||
renderHeading={(item) => {
|
||||
return (
|
||||
<div className={"text-xs text-nb-gray-400 px-4 py-2"}>
|
||||
{item.type === SearchType.Network &&
|
||||
`Networks (${networksCount})`}
|
||||
{item.type === SearchType.NetworkResource &&
|
||||
`Resources (${resourcesCount})`}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderItem={(item) => {
|
||||
const network = findNetworkByResourceId(item.id);
|
||||
|
||||
return (
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
<div className={"flex justify-between items-center gap-3"}>
|
||||
<div
|
||||
className={
|
||||
"h-8 w-8 bg-nb-gray-850 group-aria-selected/list-item:bg-nb-gray-700 flex items-center justify-center rounded-md"
|
||||
}
|
||||
>
|
||||
{item.type === SearchType.Network && (
|
||||
<div className={"uppercase font-medium"}>
|
||||
{item.data.name.substring(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
{item.type === SearchType.NetworkResource && (
|
||||
<ResourceIcon type={item.data.type} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{item.data.name} {network && ` - ${network.name}`}
|
||||
</div>
|
||||
<div className={"text-nb-gray-400"}>
|
||||
{item.data.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex items-center justify-center gap-4"}>
|
||||
{item.type === SearchType.Network && (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
"text-[0.65rem] text-nb-gray-250 flex items-center gap-2 leading-none"
|
||||
}
|
||||
>
|
||||
<LayersIcon
|
||||
size={12}
|
||||
className={"relative -top-[1px]"}
|
||||
/>
|
||||
{item.data?.resources?.length} Resource(s)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.type === SearchType.NetworkResource && (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
"text-[0.62rem] font-mono text-nb-gray-250"
|
||||
}
|
||||
>
|
||||
{item.data?.address}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<CornerDownLeft
|
||||
size={14}
|
||||
className={
|
||||
"opacity-0 group-aria-selected/list-item:opacity-100 group-list-item-aria-selected:opacity-100"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<KeyboardShortcutsFooter />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceIcon = ({ type }: { type: NetworkResource["type"] }) => {
|
||||
const size = 14;
|
||||
switch (type) {
|
||||
case "host":
|
||||
return <WorkflowIcon size={size} />;
|
||||
case "domain":
|
||||
return <GlobeIcon size={size} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={size} />;
|
||||
default:
|
||||
return <WorkflowIcon size={size} />;
|
||||
}
|
||||
};
|
||||
|
||||
const BlankState = () => {
|
||||
return (
|
||||
<div className={"flex items-center justify-center pb-8"}>
|
||||
<div className={"text-center"}>
|
||||
<div className={"flex items-center justify-center mb-3 mt-3 gap-3"}>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-920 h-8 w-8 flex items-center justify-center rounded-md"
|
||||
}
|
||||
>
|
||||
<NetworkIcon size={16} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-920 h-8 w-8 flex items-center justify-center rounded-md"
|
||||
}
|
||||
>
|
||||
<WorkflowIcon size={16} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-920 h-8 w-8 flex items-center justify-center rounded-md"
|
||||
}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"text-nb-gray-100 mb-1"}>
|
||||
Search for Networks and Resources
|
||||
</div>
|
||||
<div className={"text-sm text-nb-gray-350 font-light"}>
|
||||
Quickly find networks and associated resources. <br />
|
||||
Start typing to search by name, description or address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NotFoundState = () => {
|
||||
return (
|
||||
<div className={"flex items-center justify-center pb-8"}>
|
||||
<div className={"text-center"}>
|
||||
<div className={"flex items-center justify-center mb-3 mt-3 gap-3"}>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-920 h-8 w-8 flex items-center justify-center rounded-md"
|
||||
}
|
||||
>
|
||||
<TextSearchIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"text-nb-gray-100 mb-1"}>
|
||||
Could not find any results
|
||||
</div>
|
||||
<div className={"text-sm text-nb-gray-350 font-light max-w-xs"}>
|
||||
{`We couldn't find any results. Please try a different search term.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingState = () => {
|
||||
return (
|
||||
<div className={"flex flex-col gap-1 px-3 mb-4 opacity-50"}>
|
||||
<Skeleton width={"100%"} height={40} />
|
||||
<Skeleton width={"100%"} height={40} />
|
||||
<Skeleton width={"100%"} height={40} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyboardShortcutsFooter = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-940 border-t border-nb-gray-910 px-4 py-3 text-xs text-nb-gray-300 flex items-center gap-5"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-1.5"}>
|
||||
<Kbd variant={"darker"}>
|
||||
<ArrowUpIcon size={12} />
|
||||
</Kbd>
|
||||
<Kbd variant={"darker"}>
|
||||
<ArrowDownIcon size={12} />
|
||||
</Kbd>
|
||||
<div className={"ml-1"}>Navigate</div>
|
||||
</div>
|
||||
<div className={"flex items-center gap-1.5"}>
|
||||
<Kbd variant={"darker"}>
|
||||
<CornerDownLeft size={12} />
|
||||
</Kbd>
|
||||
<div className={"ml-1"}>Open</div>
|
||||
</div>
|
||||
<div className={"flex items-center gap-1.5"}>
|
||||
<Kbd variant={"darker"} className={"text-[0.65rem] font-medium"}>
|
||||
esc
|
||||
</Kbd>
|
||||
<div className={"ml-1"}>Close</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useOidc } from "@axa-fr/react-oidc";
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
@@ -29,6 +28,13 @@ export default function DangerZoneTab({ account }: Props) {
|
||||
.del()
|
||||
.catch((error) => reject(error))
|
||||
.then(() => {
|
||||
// Clear browser storage after account deletion
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
// Optionally, clear cookies if needed
|
||||
// document.cookie = ... (set cookies to expire)
|
||||
}
|
||||
logout().then();
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -14,11 +14,13 @@ import { RoutingPeerSetupKeyInfo } from "@/modules/setup-netbird-modal/SetupModa
|
||||
type Props = {
|
||||
setupKey?: string;
|
||||
showSetupKeyInfo?: boolean;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export default function DockerTab({
|
||||
setupKey,
|
||||
showSetupKeyInfo = false,
|
||||
hostname,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.DOCKER)}>
|
||||
@@ -59,7 +61,16 @@ export default function DockerTab({
|
||||
</span>{" "}
|
||||
\
|
||||
</Code.Line>
|
||||
<Code.Line> -v netbird-client:/etc/netbird \</Code.Line>
|
||||
|
||||
{hostname && (
|
||||
<Code.Line>
|
||||
{" "}
|
||||
-e NB_HOSTNAME=
|
||||
<span className={"text-netbird"}>{`'${hostname}'`}</span> \
|
||||
</Code.Line>
|
||||
)}
|
||||
|
||||
<Code.Line> -v netbird-client:/var/lib/netbird \</Code.Line>
|
||||
{GRPC_API_ORIGIN && (
|
||||
<Code.Line>
|
||||
{" "}
|
||||
@@ -73,9 +84,7 @@ export default function DockerTab({
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p>Read our documentation</p>
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/getting-started#running-net-bird-in-docker"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/installation/docker"}
|
||||
passHref={true}
|
||||
target={"_blank"}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TerminalSquareIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
HostnameParameter,
|
||||
RoutingPeerSetupKeyInfo,
|
||||
SetupKeyParameter,
|
||||
} from "@/modules/setup-netbird-modal/SetupModal";
|
||||
@@ -21,11 +22,13 @@ import {
|
||||
type Props = {
|
||||
setupKey?: string;
|
||||
showSetupKeyInfo?: boolean;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export default function LinuxTab({
|
||||
setupKey,
|
||||
showSetupKeyInfo = false,
|
||||
hostname,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.LINUX)}>
|
||||
@@ -47,6 +50,7 @@ export default function LinuxTab({
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
<HostnameParameter hostname={hostname} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
@@ -104,6 +108,7 @@ export default function LinuxTab({
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
<HostnameParameter hostname={hostname} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
|
||||
@@ -24,6 +24,7 @@ import Link from "next/link";
|
||||
import React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
HostnameParameter,
|
||||
RoutingPeerSetupKeyInfo,
|
||||
SetupKeyParameter,
|
||||
} from "@/modules/setup-netbird-modal/SetupModal";
|
||||
@@ -31,10 +32,12 @@ import {
|
||||
type Props = {
|
||||
setupKey?: string;
|
||||
showSetupKeyInfo?: boolean;
|
||||
hostname?: string;
|
||||
};
|
||||
export default function MacOSTab({
|
||||
setupKey,
|
||||
showSetupKeyInfo,
|
||||
hostname,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.APPLE)}>
|
||||
@@ -120,6 +123,7 @@ export default function MacOSTab({
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
<HostnameParameter hostname={hostname} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
@@ -162,6 +166,7 @@ export default function MacOSTab({
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
<HostnameParameter hostname={hostname} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
@@ -222,6 +227,7 @@ export default function MacOSTab({
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
<HostnameParameter hostname={hostname} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import AndroidIcon from "@/assets/icons/AndroidIcon";
|
||||
import AppleIcon from "@/assets/icons/AppleIcon";
|
||||
import DockerIcon from "@/assets/icons/DockerIcon";
|
||||
@@ -34,6 +34,7 @@ type Props = {
|
||||
user?: OidcUserInfo;
|
||||
setupKey?: string;
|
||||
showOnlyRoutingPeerOS?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function SetupModal({
|
||||
@@ -41,9 +42,10 @@ export default function SetupModal({
|
||||
user,
|
||||
setupKey,
|
||||
showOnlyRoutingPeerOS = false,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<ModalContent showClose={showClose}>
|
||||
<ModalContent showClose={showClose} className={className}>
|
||||
<SetupModalContent
|
||||
user={user}
|
||||
setupKey={setupKey}
|
||||
@@ -60,6 +62,9 @@ type SetupModalContentProps = {
|
||||
tabAlignment?: "center" | "start" | "end";
|
||||
setupKey?: string;
|
||||
showOnlyRoutingPeerOS?: boolean;
|
||||
title?: string;
|
||||
hostname?: string;
|
||||
hideDocker?: boolean;
|
||||
};
|
||||
|
||||
export function SetupModalContent({
|
||||
@@ -69,12 +74,30 @@ export function SetupModalContent({
|
||||
tabAlignment = "center",
|
||||
setupKey,
|
||||
showOnlyRoutingPeerOS,
|
||||
title,
|
||||
hostname,
|
||||
hideDocker = false,
|
||||
}: Readonly<SetupModalContentProps>) {
|
||||
const os = useOperatingSystem();
|
||||
const [isFirstRun] = useLocalStorage<boolean>("netbird-first-run", true);
|
||||
const pathname = usePathname();
|
||||
const isInstallPage = pathname === "/install";
|
||||
|
||||
const titleMessage = useMemo(() => {
|
||||
if (title) return title;
|
||||
|
||||
if (isFirstRun && !isInstallPage) {
|
||||
let name = user?.given_name || "there";
|
||||
return (
|
||||
<>
|
||||
Hello {name}! 👋 <br /> It's time to add your first device.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return setupKey ? "Install NetBird with Setup Key" : "Install NetBird";
|
||||
}, [isFirstRun, isInstallPage, setupKey, title, user?.given_name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{header && (
|
||||
@@ -85,14 +108,7 @@ export function SetupModalContent({
|
||||
setupKey ? "text-2xl" : "text-3xl",
|
||||
)}
|
||||
>
|
||||
{isFirstRun && !isInstallPage ? (
|
||||
<>
|
||||
Hello {user?.given_name || "there"}! 👋 <br />
|
||||
{`It's time to add your first device.`}
|
||||
</>
|
||||
) : (
|
||||
<>Install NetBird{setupKey && " with Setup Key"}</>
|
||||
)}
|
||||
{titleMessage}
|
||||
</h2>
|
||||
<Paragraph
|
||||
className={cn("mx-auto mt-3", setupKey ? "max-w-sm" : "max-w-xs")}
|
||||
@@ -153,27 +169,32 @@ export function SetupModalContent({
|
||||
</>
|
||||
)}
|
||||
|
||||
<TabsTrigger value={String(OperatingSystem.DOCKER)}>
|
||||
<DockerIcon
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
{!hideDocker && (
|
||||
<TabsTrigger value={String(OperatingSystem.DOCKER)}>
|
||||
<DockerIcon
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<LinuxTab
|
||||
setupKey={setupKey}
|
||||
showSetupKeyInfo={showOnlyRoutingPeerOS}
|
||||
hostname={hostname}
|
||||
/>
|
||||
<WindowsTab
|
||||
setupKey={setupKey}
|
||||
showSetupKeyInfo={showOnlyRoutingPeerOS}
|
||||
hostname={hostname}
|
||||
/>
|
||||
<MacOSTab
|
||||
setupKey={setupKey}
|
||||
showSetupKeyInfo={showOnlyRoutingPeerOS}
|
||||
hostname={hostname}
|
||||
/>
|
||||
|
||||
{!setupKey && (
|
||||
@@ -183,10 +204,13 @@ export function SetupModalContent({
|
||||
</>
|
||||
)}
|
||||
|
||||
<DockerTab
|
||||
setupKey={setupKey}
|
||||
showSetupKeyInfo={showOnlyRoutingPeerOS}
|
||||
/>
|
||||
{!hideDocker && (
|
||||
<DockerTab
|
||||
setupKey={setupKey}
|
||||
showSetupKeyInfo={showOnlyRoutingPeerOS}
|
||||
hostname={hostname}
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
{footer && (
|
||||
<ModalFooter variant={"setup"}>
|
||||
@@ -227,6 +251,22 @@ export const SetupKeyParameter = ({ setupKey }: SetupKeyParameterProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const HostnameParameter = ({ hostname }: { hostname?: string }) => {
|
||||
return (
|
||||
hostname && (
|
||||
<>
|
||||
{" "}
|
||||
--hostname{" "}
|
||||
<span className={"text-netbird"}>
|
||||
{"'"}
|
||||
{hostname}
|
||||
{"'"}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const RoutingPeerSetupKeyInfo = () => {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import Link from "next/link";
|
||||
import React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
HostnameParameter,
|
||||
RoutingPeerSetupKeyInfo,
|
||||
SetupKeyParameter,
|
||||
} from "@/modules/setup-netbird-modal/SetupModal";
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
type Props = {
|
||||
setupKey?: string;
|
||||
showSetupKeyInfo?: boolean;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export default function WindowsTab({
|
||||
setupKey,
|
||||
showSetupKeyInfo,
|
||||
hostname,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.WINDOWS)}>
|
||||
@@ -67,6 +70,7 @@ export default function WindowsTab({
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
<HostnameParameter hostname={hostname} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { uniq } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { User } from "@/interfaces/User";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import GroupsRow from "@/modules/common-table-rows/GroupsRow";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
export default function UserGroupCell({ user }: Readonly<Props>) {
|
||||
const { groups, isLoading } = useGroups();
|
||||
const [modal, setModal] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const userRequest = useApiCall<User>("/users");
|
||||
|
||||
const allGroups = useMemo(() => {
|
||||
if (isLoading) return [];
|
||||
@@ -20,6 +27,10 @@ export default function UserGroupCell({ user }: Readonly<Props>) {
|
||||
.filter((g): g is Group => g !== undefined);
|
||||
}, [user.auto_groups, groups, isLoading]);
|
||||
|
||||
const userGroupIds = useMemo(() => {
|
||||
return (allGroups.map((group) => group.id) as string[]) || [];
|
||||
}, [allGroups]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className={"flex gap-2"}>
|
||||
@@ -28,9 +39,45 @@ export default function UserGroupCell({ user }: Readonly<Props>) {
|
||||
</div>
|
||||
);
|
||||
|
||||
return allGroups.length == 0 ? (
|
||||
<EmptyRow />
|
||||
) : (
|
||||
<MultipleGroups groups={allGroups} />
|
||||
const handleSave = async (promises: Promise<Group>[]) => {
|
||||
if (!user) return;
|
||||
|
||||
const groups = await Promise.all(promises);
|
||||
const groupIds =
|
||||
groups?.map((group) => group?.id).filter((id) => id !== undefined) || [];
|
||||
|
||||
notify({
|
||||
title: user?.name || user?.email || "User",
|
||||
description: "Groups of the user were successfully saved",
|
||||
promise: userRequest
|
||||
.put(
|
||||
{
|
||||
...user,
|
||||
auto_groups: groupIds,
|
||||
},
|
||||
`/${user.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
setModal(false);
|
||||
mutate(`/users?service_user=false`);
|
||||
mutate(`/integrations/msp/switcher`);
|
||||
mutate("/groups");
|
||||
}),
|
||||
loadingMessage: "Updating groups...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupsRow
|
||||
label={"Auto-assigned Groups"}
|
||||
description={"Groups will be assigned to peers added by this user."}
|
||||
groups={userGroupIds}
|
||||
onSave={handleSave}
|
||||
hideAllGroup={true}
|
||||
disabled={!permission.users.update}
|
||||
showAddGroupButton={permission.users.update}
|
||||
modal={modal}
|
||||
setModal={setModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export function randomBoolean() {
|
||||
return Math.random() >= 0.5;
|
||||
}
|
||||
|
||||
export function removeAllSpaces(str: string) {
|
||||
export function removeAllSpaces(str?: string) {
|
||||
if (!str) return "";
|
||||
return str.replace(/\s/g, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,13 @@ export const getNetBirdUpCommand = () => {
|
||||
if (GRPC_API_ORIGIN) {
|
||||
cmd += " --management-url " + GRPC_API_ORIGIN;
|
||||
}
|
||||
if (!isNetBirdHosted()) {
|
||||
let admin_url = window.location.protocol + "//" + window.location.hostname;
|
||||
if (window.location.port != "") {
|
||||
admin_url += ":" + window.location.port;
|
||||
}
|
||||
cmd += " --admin-url " + admin_url;
|
||||
}
|
||||
return cmd;
|
||||
};
|
||||
|
||||
export const getInstallUrl = () => {
|
||||
return window.location.origin + "/install";
|
||||
};
|
||||
|
||||
export const isNetBirdHosted = () => {
|
||||
return (
|
||||
window.location.hostname.endsWith(".netbird.io") ||
|
||||
|
||||
@@ -15,7 +15,7 @@ const config: Config = {
|
||||
"100": "#e4e7e9",
|
||||
"200": "#cbd2d6",
|
||||
"250": "#b7c0c6",
|
||||
"300": "#a7b1b9",
|
||||
"300": "#aab4bd",
|
||||
"350": "#8f9ca8",
|
||||
"400": "#7c8994",
|
||||
"500": "#616e79",
|
||||
@@ -28,7 +28,7 @@ const config: Config = {
|
||||
"920": "#25282d",
|
||||
"925": "#1e2123",
|
||||
"930": "#25282c",
|
||||
"940": "#1b1f22",
|
||||
"940": "#1c1d21",
|
||||
"950": "#181a1d",
|
||||
},
|
||||
netbird: {
|
||||
|
||||
Reference in New Issue
Block a user