diff --git a/src/assets/os-icons/FreeBSD.png b/src/assets/os-icons/FreeBSD.png new file mode 100644 index 00000000..ab095568 Binary files /dev/null and b/src/assets/os-icons/FreeBSD.png differ diff --git a/src/auth/SecureProvider.tsx b/src/auth/SecureProvider.tsx index 41cb13cb..dcc47d4b 100644 --- a/src/auth/SecureProvider.tsx +++ b/src/auth/SecureProvider.tsx @@ -11,9 +11,17 @@ export const SecureProvider = ({ children }: Props) => { const currentPath = usePathname(); useEffect(() => { + let timeout: NodeJS.Timeout | undefined = undefined; if (!isAuthenticated) { - login(currentPath); + timeout = setTimeout(async () => { + if (!isAuthenticated) { + await login(currentPath); + } + }, 1500); } + return () => { + clearTimeout(timeout); + }; }, [currentPath, isAuthenticated, login]); return ( diff --git a/src/components/DropdownInfoText.tsx b/src/components/DropdownInfoText.tsx new file mode 100644 index 00000000..a8d1879e --- /dev/null +++ b/src/components/DropdownInfoText.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; + +type Props = { + children: React.ReactNode; +}; + +export const DropdownInfoText = ({ children }: Props) => { + return ( +
{children}
+ ); +}; diff --git a/src/components/DropdownInput.tsx b/src/components/DropdownInput.tsx new file mode 100644 index 00000000..7928ab0f --- /dev/null +++ b/src/components/DropdownInput.tsx @@ -0,0 +1,48 @@ +import { IconArrowBack } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { SearchIcon } from "lucide-react"; +import * as React from "react"; +import { forwardRef } from "react"; + +type Props = { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}; + +export const DropdownInput = forwardRef( + ({ value, onChange, placeholder = "Search..." }, ref) => { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + /> +
+
+ +
+
+
+
+ +
+
+
+ ); + }, +); + +DropdownInput.displayName = "DropdownInput"; diff --git a/src/components/NetworkRouteSelector.tsx b/src/components/NetworkRouteSelector.tsx index eac7a48b..01cfb064 100644 --- a/src/components/NetworkRouteSelector.tsx +++ b/src/components/NetworkRouteSelector.tsx @@ -1,6 +1,7 @@ import { CommandItem } from "@components/Command"; import FullTooltip from "@components/FullTooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; +import TextWithTooltip from "@components/ui/TextWithTooltip"; import { IconArrowBack } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; import { cn } from "@utils/helpers"; @@ -108,12 +109,12 @@ export function NetworkRouteSelector({ {value ? (
- {value.network_id} +
- {option.network_id} +
{domains.join(", ")}
} > -
+
{firstDomain} {domains.length > 1 && "+" + (domains.length - 1)}
diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index c75994dc..c43402b2 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -29,6 +29,7 @@ interface MultiSelectProps { max?: number; disabled?: boolean; popoverWidth?: "auto" | number; + hideAllGroup?: boolean; } export function PeerGroupSelector({ onChange, @@ -37,6 +38,7 @@ export function PeerGroupSelector({ max, disabled = false, popoverWidth = "auto", + hideAllGroup = false, }: MultiSelectProps) { const { groups, dropdownOptions, setDropdownOptions } = useGroups(); const searchRef = React.useRef(null); @@ -47,7 +49,13 @@ export function PeerGroupSelector({ useEffect(() => { if (!groups) return; const sortedGroups = sortBy([...groups], "name") as Group[]; - setDropdownOptions(unionBy(sortedGroups, dropdownOptions, "name")); + + let uniqueGroups = unionBy(sortedGroups, dropdownOptions, "name"); + uniqueGroups = hideAllGroup + ? uniqueGroups.filter((group) => group.name !== "All") + : uniqueGroups; + + setDropdownOptions(uniqueGroups); // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups]); @@ -66,8 +74,11 @@ export function PeerGroupSelector({ const option = dropdownOptions.find((option) => option.name == name); const groupPeers: GroupPeer[] | undefined = (group?.peers as GroupPeer[]) || []; - groupPeers && - groupPeers.push({ id: peer?.id as string, name: peer?.name as string }); + + if (peer) { + groupPeers && + groupPeers.push({ id: peer?.id as string, name: peer?.name as string }); + } if (!group && !option) { setDropdownOptions((previous) => [ @@ -100,17 +111,18 @@ export function PeerGroupSelector({ const isSearching = search.length > 0; const groupDoesNotExist = dropdownOptions.filter((item) => item.name == trim(search)).length == 0; - return isSearching && groupDoesNotExist; + const isAllGroup = search.toLowerCase() == "all"; + return isSearching && groupDoesNotExist && !isAllGroup; }, [search, dropdownOptions]); const [open, setOpen] = useState(false); const folderIcon = useMemo(() => { - return ; + return ; }, []); const peerIcon = useMemo(() => { - return ; + return ; }, []); const [slice, setSlice] = useState(10); @@ -203,7 +215,7 @@ export function PeerGroupSelector({ "min-h-[42px] w-full relative", "border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center", "bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0", - "dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10", + "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10", )} ref={searchRef} value={search} @@ -238,9 +250,7 @@ export function PeerGroupSelector({ {searchedGroupNotFound && ( ); +MapPinIcon.displayName = "MapPinIcon"; + +const LinuxIcon = memo(() => ( + + + +)); +LinuxIcon.displayName = "LinuxIcon"; + interface MultiSelectProps { value?: Peer; onChange: React.Dispatch>; @@ -23,6 +33,13 @@ interface MultiSelectProps { disabled?: boolean; } +const searchPredicate = (item: Peer, query: string) => { + const lowerCaseQuery = query.toLowerCase(); + if (item.name.toLowerCase().includes(lowerCaseQuery)) return true; + if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true; + return item.ip.toLowerCase().startsWith(lowerCaseQuery); +}; + export function PeerSelector({ onChange, value, @@ -30,13 +47,16 @@ export function PeerSelector({ disabled = false, }: MultiSelectProps) { const { data: peers } = useFetchApi("/peers"); - - const [dropdownOptions, setDropdownOptions] = useState([]); - const searchRef = React.useRef(null); const [inputRef, { width }] = useElementSize(); - const [search, setSearch] = useState(""); - // Update dropdown options when peers change + const [unfilteredItems, setUnfilteredItems] = useState([]); + const [filteredItems, search, setSearch] = useSearch( + unfilteredItems, + searchPredicate, + { filter: true, debounce: 150 }, + ); + + // Update unfiltered items when peers change useEffect(() => { if (!peers) return; @@ -56,7 +76,7 @@ export function PeerSelector({ }); } - setDropdownOptions(unionBy(options, dropdownOptions, "id")); + setUnfilteredItems(unionBy(options, unfilteredItems, "id")); // eslint-disable-next-line react-hooks/exhaustive-deps }, [peers]); @@ -68,44 +88,11 @@ export function PeerSelector({ onChange(peer); setSearch(""); } + setOpen(false); }; - const peerNotFound = useMemo(() => { - const isSearching = search.length > 0; - - // Search peer by ip or name - const peerFound = - dropdownOptions.filter((item) => { - return ( - item.name.includes(search) || - item.hostname.includes(search) || - item.ip.includes(search) - ); - }).length > 0; - - return isSearching && !peerFound; - }, [search, dropdownOptions]); - const [open, setOpen] = useState(false); - const [slice, setSlice] = useState(10); - - useEffect(() => { - if (open) { - setTimeout(() => { - setSlice(dropdownOptions.length); - }, 100); - } else { - setSlice(10); - } - }, [open, dropdownOptions]); - - const LinuxIcon = ( - - - - ); - return (
- {LinuxIcon} +
@@ -150,7 +137,7 @@ export function PeerSelector({ "text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]" } > - + {value.ip}
@@ -168,113 +155,67 @@ export function PeerSelector({ style={{ width: width, }} - forceMount={true} align="start" side={"top"} sideOffset={10} > - { - const formatValue = trim(value.toLowerCase()); - const formatSearch = trim(search.toLowerCase()); - if (formatValue.includes(formatSearch)) return 1; - return 0; - }} - > - -
- -
-
- -
-
-
-
- -
-
-
+
+ + + {unfilteredItems.length == 0 && ( + + { + "Seems like you don't have any linux peers to assign as a routing peer." + } + + )} -
- {dropdownOptions.length == 0 && !peerNotFound && ( -
- { - "Seems like you don't have any linux peers to assign as a routing peer." - } -
- )} - {peerNotFound && ( -
- There are no peers matching your search. -
- )} - - - {dropdownOptions.slice(0, slice).map((option) => { - return ( - { - togglePeer(option); - setOpen(false); - }} - > -
- {LinuxIcon} - -
+ {filteredItems.length == 0 && ( + + There are no peers matching your search. + + )} -
- - {option.ip} -
-
- ); - })} -
-
-
- - + {filteredItems.length > 0 && ( + { + return ( + <> +
+ + +
+ +
+ + {option.ip} +
+ + ); + }} + /> + )} +
); diff --git a/src/components/ScrollArea.tsx b/src/components/ScrollArea.tsx index c6088675..a1bc67a8 100644 --- a/src/components/ScrollArea.tsx +++ b/src/components/ScrollArea.tsx @@ -4,30 +4,65 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { cn } from "@utils/helpers"; import * as React from "react"; +type AdditionalScrollAreaProps = { + withoutViewport?: boolean; +}; + const ScrollArea = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & + AdditionalScrollAreaProps +>(({ className, children, withoutViewport = false, ...props }, ref) => ( - - {children} - + {withoutViewport ? ( + children + ) : ( + + {children} + + )} )); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; +type AdditionalScrollAreaViewportProps = { + disableOverflowY?: boolean; +}; + +const ScrollAreaViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + AdditionalScrollAreaViewportProps +>(({ disableOverflowY = true, ...props }, ref) => { + return ( + + ); +}); +ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName; + const ScrollBar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, orientation = "vertical", ...props }, ref) => ( , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/src/components/VirtualScrollAreaList.tsx b/src/components/VirtualScrollAreaList.tsx new file mode 100644 index 00000000..47f36bf8 --- /dev/null +++ b/src/components/VirtualScrollAreaList.tsx @@ -0,0 +1,132 @@ +import { + MemoizedScrollArea, + MemoizedScrollAreaViewport, +} from "@components/ScrollArea"; +import { cn } from "@utils/helpers"; +import * as React from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; + +type Props = { + items: T[]; + onSelect: (item: T) => void; + renderItem?: (item: T) => React.ReactNode; +}; + +export function VirtualScrollAreaList({ + items, + onSelect, + renderItem, +}: Props) { + const virtuosoRef = useRef(null); + const [selected, setSelected] = useState(0); + + useEffect(() => { + setSelected(0); + }, [items]); + + const scrollToItem = useCallback((index: number) => { + virtuosoRef.current?.scrollIntoView({ + index, + behavior: "auto", + align: "center", + }); + }, []); + + const navigation = useCallback( + (e: KeyboardEvent) => { + if (items.length === 0) return; + const length = items.length - 1; + if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + e.preventDefault(); + const newSelected = selected === 0 ? length : selected - 1; + setSelected(newSelected); + scrollToItem(newSelected); + } else if (e.key === "ArrowDown" || e.key === "Tab") { + e.preventDefault(); + const newSelected = selected === length ? 0 : selected + 1; + setSelected(newSelected); + scrollToItem(newSelected); + } + if (e.key === "Enter") { + e.preventDefault(); + onSelect?.(items[selected]); + } + }, + [items, scrollToItem, selected], + ); + + useEffect(() => { + window.addEventListener("keydown", navigation); + return () => { + window.removeEventListener("keydown", navigation); + }; + }, [navigation]); + + const renderMemoizedItem = useMemo(() => renderItem, [renderItem]); + + return ( + + items[index].id as string} + context={{ selected, setSelected, onClick: onSelect }} + itemContent={(index, option, { selected, setSelected, onClick }) => { + return ( + setSelected(index)} + id={option.id} + onClick={() => onClick(option as T)} + ariaSelected={selected === index} + > + {renderMemoizedItem ? renderMemoizedItem(option) : option.id} + + ); + }} + style={{ height: 195 }} + components={{ + Scroller: MemoizedScrollAreaViewport, + }} + /> + + ); +} + +type ItemWrapperProps = { + children: React.ReactNode; + id?: string; + onMouseEnter?: () => void; + onClick?: () => void; + ariaSelected?: boolean; +}; + +export const VirtualScrollListItemWrapper = memo( + ({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => { + return ( +
+
+ {children} +
+
+ ); + }, +); +VirtualScrollListItemWrapper.displayName = "VirtualScrollListItemWrapper"; diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index 836c292f..54f026d8 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -1,6 +1,7 @@ "use client"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch"; +import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal"; import { DataTablePagination } from "@components/table/DataTablePagination"; import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton"; import { @@ -28,6 +29,7 @@ import { getSortedRowModel, PaginationState, Row, + RowSelectionState, SortingState, Table as TanStackTable, useReactTable, @@ -105,6 +107,7 @@ interface DataTableProps { aboveTable?: (table: TanStackTable) => React.ReactNode; searchPlaceholder?: string; columnVisibility?: VisibilityState; + setColumnVisibility?: React.Dispatch>; sorting?: SortingState; setSorting?: React.Dispatch>; text?: string; @@ -126,6 +129,11 @@ interface DataTableProps { rightSide?: (table: TanStackTable) => React.ReactNode; manualPagination?: boolean; showHeader?: boolean; + rowSelection?: RowSelectionState; + setRowSelection?: React.Dispatch>; + useRowId?: boolean; + headingTarget?: HTMLHeadingElement | null; + showResetFilterButton?: boolean; } export function DataTable(props: DataTableProps) { @@ -139,6 +147,7 @@ export function DataTableContent({ children, searchPlaceholder = "Search...", columnVisibility = {}, + setColumnVisibility, sorting = [], setSorting, text = "rows", @@ -159,6 +168,11 @@ export function DataTableContent({ rightSide, manualPagination = false, showHeader = true, + rowSelection, + setRowSelection, + useRowId, + headingTarget, + showResetFilterButton = true, }: DataTableProps) { const path = usePathname(); const [columnFilters, setColumnFilters] = useLocalStorage( @@ -176,9 +190,6 @@ export function DataTableContent({ pageSize: 10, }); - const [tableColumnVisibility, setColumnVisibility] = - React.useState(columnVisibility); - const hasInitialData = !!(data && data.length > 0); const table = useReactTable({ @@ -196,8 +207,9 @@ export function DataTableContent({ manualPagination: manualPagination, state: { sorting, + rowSelection: rowSelection ?? {}, columnFilters, - columnVisibility: tableColumnVisibility, + columnVisibility: columnVisibility, globalFilter: globalSearch, pagination: paginationState, }, @@ -207,6 +219,8 @@ export function DataTableContent({ pageSize: 10, }, }, + getRowId: useRowId ? (row) => row.id : undefined, + onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onPaginationChange: setPaginationState, onColumnFiltersChange: setColumnFilters, @@ -235,6 +249,7 @@ export function DataTableContent({ table.setPageIndex(0); setColumnFilters([]); setGlobalSearch(""); + setRowSelection?.({}); }; return ( @@ -248,11 +263,14 @@ export function DataTableContent({ setGlobalSearch={(val) => { table.setPageIndex(0); setGlobalSearch(val); + setRowSelection?.({}); }} placeholder={searchPlaceholder} /> {children && children(table)} - + {showResetFilterButton && ( + + )}
{rightSide && rightSide(table)} @@ -412,6 +430,11 @@ export function DataTableContent({
+
); } diff --git a/src/components/table/DataTableHeadingPortal.tsx b/src/components/table/DataTableHeadingPortal.tsx new file mode 100644 index 00000000..d503fbb2 --- /dev/null +++ b/src/components/table/DataTableHeadingPortal.tsx @@ -0,0 +1,73 @@ +import { Table } from "@tanstack/react-table"; +import * as React from "react"; +import { useRef } from "react"; +import { createPortal } from "react-dom"; + +type Props = { + table: Table | null; + headingTarget?: HTMLHeadingElement | null; + text: string; +}; +export const DataTableHeadingPortal = function ({ + table, + headingTarget, + text = "Items", +}: Props) { + const hasMounted = useRef(false); + + if (!headingTarget) return; + + if (!hasMounted.current) { + headingTarget.innerHTML = ""; + hasMounted.current = true; + } + + const totalItems = table?.getPreFilteredRowModel().rows.length; + const filteredItems = table?.getFilteredRowModel().rows.length; + + const hasAnyFiltersActive = + table && + !( + table?.getState().columnFilters.length <= 0 && + table?.getState().globalFilter === "" + ); + + return createPortal( + , + headingTarget, + ); +}; + +type HeadingProps = { + hasAnyFilterActive: boolean | null; + filteredItems?: number; + totalItems?: number; + text: string; +}; + +const Heading = ({ + hasAnyFilterActive, + filteredItems, + totalItems, + text, +}: HeadingProps) => { + if (!totalItems || totalItems == 1) { + return text; + } + + if (hasAnyFilterActive) { + return ( + <> + {filteredItems} of {totalItems}{" "} + {text} + + ); + } + + return `${totalItems} ${text}`; +}; diff --git a/src/components/ui/GroupBadge.tsx b/src/components/ui/GroupBadge.tsx index 24158893..141f2a9a 100644 --- a/src/components/ui/GroupBadge.tsx +++ b/src/components/ui/GroupBadge.tsx @@ -25,7 +25,10 @@ export default function GroupBadge({ useHover={true} variant={"gray-ghost"} className={cn("transition-all group whitespace-nowrap", className)} - onClick={onClick} + onClick={(e) => { + e.preventDefault(); + onClick?.(); + }} > diff --git a/src/contexts/PeersProvider.tsx b/src/contexts/PeersProvider.tsx index cfab94dd..d99e739b 100644 --- a/src/contexts/PeersProvider.tsx +++ b/src/contexts/PeersProvider.tsx @@ -1,5 +1,5 @@ import useFetchApi from "@utils/api"; -import React from "react"; +import React, { useMemo } from "react"; import type { Peer } from "@/interfaces/Peer"; type Props = { @@ -9,15 +9,21 @@ type Props = { const PeerContext = React.createContext( {} as { peers: Peer[] | undefined; + isLoading: boolean; }, ); -export default function PeersProvider({ children }: Props) { - const { data: peers } = useFetchApi("/peers"); +export default function PeersProvider({ children }: Readonly) { + const { data: peers, isLoading } = useFetchApi("/peers"); - return ( - {children} - ); + const data = useMemo(() => { + return { + peers, + isLoading, + }; + }, [peers, isLoading]); + + return {children}; } export const usePeers = () => React.useContext(PeerContext); diff --git a/src/hooks/useOperatingSystem.ts b/src/hooks/useOperatingSystem.ts index 8ca85e74..1a8e6a50 100644 --- a/src/hooks/useOperatingSystem.ts +++ b/src/hooks/useOperatingSystem.ts @@ -2,6 +2,10 @@ import { OperatingSystem } from "@/interfaces/OperatingSystem"; +/** + * Get the operating system of the user based on the user agent of the browser + * This is used for the setup modal to show the correct installation guide + */ export default function useOperatingSystem() { const isBrowser = typeof window !== "undefined"; const userAgent = isBrowser ? navigator.userAgent.toLowerCase() : ""; @@ -9,10 +13,18 @@ export default function useOperatingSystem() { ? /(iP*)/g.test(navigator.userAgent) && navigator.maxTouchPoints > 2 : false; if (iOS) return OperatingSystem.IOS; + // For FreeBSD, we return Linux as we currently don't have an official installation guide for FreeBSD + if (userAgent.toLowerCase().includes("freebsd")) return OperatingSystem.LINUX; return getOperatingSystem(userAgent); } +/** + * Get the operating system based on a string (user agent, api response, etc.) + * Falls back to Linux if the operating system is not recognized + */ export const getOperatingSystem = (os: string) => { + if (os.toLowerCase().includes("freebsd")) + return OperatingSystem.FREEBSD as const; if (os.toLowerCase().includes("darwin")) return OperatingSystem.APPLE as const; if (os.toLowerCase().includes("mac")) return OperatingSystem.APPLE as const; diff --git a/src/hooks/usePortalElement.tsx b/src/hooks/usePortalElement.tsx new file mode 100644 index 00000000..9aa51c3b --- /dev/null +++ b/src/hooks/usePortalElement.tsx @@ -0,0 +1,12 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +export function usePortalElement() { + const ref = useRef(null); + const [portalTarget, setPortalTarget] = useState(null); + + useLayoutEffect(() => { + setPortalTarget(ref.current); + }, []); + + return { ref, portalTarget, setPortalTarget }; +} diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 00000000..6e07b3f6 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react"; + +const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}; + +export default usePrevious; diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 00000000..1676f242 --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,91 @@ +import { debounce as lodashDebounce, isEqual } from "lodash"; +import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; +import usePrevious from "./usePrevious"; + +export type Predicate = (item: T, query: string) => boolean; + +export interface Options { + initialQuery?: string; + filter?: boolean; + debounce?: number; +} + +function filterCollection( + collection: T[], + predicate: Predicate, + query: string, + filter: boolean, +): T[] { + if (query) { + return collection.filter((item) => predicate(item, query)); + } else { + return filter ? collection : []; + } +} + +export function useSearch( + collection: T[], + predicate: Predicate, + { debounce, filter = false, initialQuery = "" }: Options = {}, +): [ + T[], + string, + (event: ChangeEvent | string) => void, + (querty: string) => void, +] { + const isMounted = useRef(false); + const [query, setQuery] = useState(initialQuery); + const prevCollection = usePrevious(collection); + const prevPredicate = usePrevious(predicate); + const prevQuery = usePrevious(query); + const prevFilter = usePrevious(filter); + const [filteredCollection, setFilteredCollection] = useState(() => + filterCollection(collection, predicate, query, filter), + ); + + const handleChange = useCallback( + (event: ChangeEvent | string) => { + setQuery(typeof event === "string" ? event : event.target.value); + }, + [setQuery], + ); + + const debouncedFilterCollection = useCallback( + lodashDebounce( + ( + collection: T[], + predicate: Predicate, + query: string, + filter: boolean, + ) => { + if (isMounted.current) { + setFilteredCollection( + filterCollection(collection, predicate, query, filter), + ); + } + }, + debounce, + ), + [debounce], + ); + + useEffect(() => { + if ( + !isEqual(collection, prevCollection) || + !isEqual(predicate, prevPredicate) || + !isEqual(query, prevQuery) || + !isEqual(filter, prevFilter) + ) + debouncedFilterCollection(collection, predicate, query, filter); + }, [collection, predicate, query, filter]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + return [filteredCollection, query, handleChange, setQuery]; +} diff --git a/src/interfaces/OperatingSystem.ts b/src/interfaces/OperatingSystem.ts index 7eef9926..843f7824 100644 --- a/src/interfaces/OperatingSystem.ts +++ b/src/interfaces/OperatingSystem.ts @@ -6,4 +6,5 @@ export enum OperatingSystem { DOCKER, IOS, UNKNOWN, + FREEBSD, } diff --git a/src/modules/common-table-rows/GroupsRow.tsx b/src/modules/common-table-rows/GroupsRow.tsx index 0a8fc8f0..1c2e2d1e 100644 --- a/src/modules/common-table-rows/GroupsRow.tsx +++ b/src/modules/common-table-rows/GroupsRow.tsx @@ -30,6 +30,7 @@ type Props = { description?: string; peer?: Peer; showAddGroupButton?: boolean; + hideAllGroup?: boolean; }; export default function GroupsRow({ @@ -41,6 +42,7 @@ export default function GroupsRow({ description = "Use groups to control what this peer can access", peer, showAddGroupButton = false, + hideAllGroup = false, }: Props) { const { groups: allGroups } = useGroups(); const { isUser } = useLoggedInUser(); @@ -78,6 +80,7 @@ export default function GroupsRow({ label={label} description={description} peer={peer} + hideAllGroup={hideAllGroup} /> ); @@ -89,6 +92,7 @@ type EditGroupsModalProps = { label?: string; description?: string; peer?: Peer; + hideAllGroup?: boolean; }; export function EditGroupsModal({ @@ -97,6 +101,7 @@ export function EditGroupsModal({ label, description, peer, + hideAllGroup = false, }: EditGroupsModalProps) { const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = useGroupHelper({ @@ -125,6 +130,7 @@ export function EditGroupsModal({ onChange={setSelectedGroups} values={selectedGroups} peer={peer} + hideAllGroup={hideAllGroup} />
diff --git a/src/modules/groups/GroupSelector.tsx b/src/modules/groups/GroupSelector.tsx index bd1ac24e..c3b77d80 100644 --- a/src/modules/groups/GroupSelector.tsx +++ b/src/modules/groups/GroupSelector.tsx @@ -174,7 +174,7 @@ export function GroupSelector({ "flex items-center gap-2 whitespace-nowrap text-sm" } > - +
void; +}; +export const PeerMultiSelect = ({ selectedPeers = {}, onCanceled }: Props) => { + return ( + + {Object.keys(selectedPeers).length > 0 && ( + + )} + + ); +}; + +const PeerGroupMassAssignmentContent = ({ + selectedPeers = {}, + onCanceled, +}: Props) => { + const { mutate } = useSWRConfig(); + const { confirm } = useDialog(); + + const { peers } = usePeers(); + + const groupCall = useApiCall("/groups"); + const getAllGroups = useApiCall("/groups").get; + const peerCall = useApiCall("/peers", true); + + const [showGroupAssignment, setShowGroupAssignment] = useState(false); + const groupAssignmentRef = React.useRef(null); + + const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = + useGroupHelper({ + initial: [], + }); + const [replaceAllGroups, setReplaceAllGroups] = useState(false); + + const peerCount = useMemo( + () => Object.keys(selectedPeers).length, + [selectedPeers], + ); + + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const isLoadingOrSuccess = isLoading || isSuccess; + + useEffect(() => { + const timeout = setTimeout(() => { + isSuccess && setIsSuccess(false); + }, 1000); + return () => clearTimeout(timeout); + }, [isSuccess]); + + const addGroupsToPeers = async () => { + if (replaceAllGroups) { + const choice = await confirm({ + title: `Overwrite existing groups?`, + description: `Are you sure you want to overwrite the existing groups of your ${peerCount} selected peer(s)? This action cannot be undone.`, + confirmText: "Overwrite", + cancelText: "Cancel", + type: "warning", + }); + if (!choice) return; + } + setIsSuccess(false); + setIsLoading(true); + + try { + const allGroups = await getAllGroups(); + const selectedGroupCalls = getAllGroupCalls(); + const selectedPeerIDs = Object.keys(selectedPeers); + let currentSelectedGroups = await Promise.all(selectedGroupCalls); + currentSelectedGroups = currentSelectedGroups + .map((g) => { + let findGroup = allGroups?.find((group) => group.id === g.id); + if (findGroup) return findGroup; + return g; + }) + .filter((g) => g !== undefined); + let selectedPeerGroups: Group[] = []; + + if (replaceAllGroups) { + // Get all the groups of the selected peers + selectedPeerGroups = uniqBy( + Object.keys(selectedPeers) + .map((id) => { + return peers?.find((p) => p.id === id)?.groups ?? []; + }) + .flat() + .filter((g) => g !== undefined), + "id", + ); + + // Find the groups + selectedPeerGroups = + allGroups?.filter((group) => + selectedPeerGroups.find((g) => g.id === group.id), + ) ?? []; + + // Remove the peers from the groups + selectedPeerGroups = selectedPeerGroups.map((group) => { + let previousPeers = group?.peers as GroupPeer[]; + let previousPeerIDs = previousPeers?.map((p) => p.id); + previousPeerIDs = previousPeerIDs + .filter((id) => !selectedPeerIDs.includes(id)) + .filter((id) => id !== "" && id !== null && id !== undefined); + + return { + ...group, + peers: previousPeerIDs, + }; + }) as Group[]; + } + + // Add selected peers to the selected groups + currentSelectedGroups = currentSelectedGroups + .map((group) => { + let previousPeers = (group?.peers as GroupPeer[]) ?? []; + let previousPeerIDs = previousPeers.map((p) => p.id); + + let peers = uniq( + [...previousPeerIDs, ...selectedPeerIDs].filter( + (p) => p !== "" && p !== null && p !== undefined, + ), + ); + return { + ...group, + peers, + }; + }) + .filter((g) => g !== undefined) as Group[]; + + // Merge the groups from the peers and the selected groups and remove duplicates + currentSelectedGroups = uniqBy( + [...currentSelectedGroups, ...selectedPeerGroups], + "id", + ); + + // Remove 'All' group if it exists + currentSelectedGroups = currentSelectedGroups.filter( + (group) => group.name !== "All", + ); + + // Create the update calls for each group + let updateGroupCalls = () => + Promise.all( + currentSelectedGroups.map((group) => { + return groupCall.put( + { + name: group.name, + peers: group.peers, + }, + "/" + group.id, + ); + }), + ); + + notify({ + title: "Assign Groups to Peers", + description: "Groups were successfully assigned to the peers", + promise: updateGroupCalls() + .then(() => { + if (currentSelectedGroups.length > 0) { + mutate("/groups"); + mutate("/peers"); + setIsSuccess(true); + } + }) + .finally(() => { + setIsLoading(false); + }), + loadingMessage: "Updating the groups of the selected peers...", + }); + } catch (e) { + setIsLoading(false); + } + }; + + const deleteAllPeers = async () => { + const choice = await confirm({ + title: `Delete '${peerCount}' ${peerCount > 1 ? "peers" : "peer"}?`, + description: `Are you sure you want to delete these peers? This action cannot be undone.`, + confirmText: "Delete All", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + + let batchDeleteCalls = () => + Object.keys(selectedPeers).map((id) => { + return peerCall.del({}, `/${id}`); + }); + + notify({ + title: "Delete Peers", + description: "Peers were successfully deleted", + promise: Promise.all(batchDeleteCalls()).then(() => { + mutate("/peers"); + onCanceled?.(); + }), + loadingMessage: "Deleting the selected peers...", + }); + }; + + return ( +
+ + + {showGroupAssignment && ( + + + {isLoadingOrSuccess && ( + + + {isLoading && ( + <> + + Assigning groups... + + )} + {!isLoading && isSuccess && ( + <> + + Groups successfully assigned + + )} + + + )} + +
+ + + Assign the following groups to the selected peers. Previously + assigned groups will be kept unless you choose to overwrite + them. + + +
+ + + + Overwrite Existing Groups +
+ } + helpText={ +
+ Overwrite the existing groups of the peers with the selected + ones. Previously assigned groups will be removed. +
+ } + /> + + )} + + + + +
+
+ + + + {peerCount} + {" "} + Peer(s) selected + +
+
+ {!showGroupAssignment ? ( + <> + Assign Groups + } + > + + + Delete All} + > + + + Cancel} + > + + + + ) : ( + <> + + + + )} +
+
+
+
+
+ +
+ ); +}; diff --git a/src/modules/peers/PeerOSCell.tsx b/src/modules/peers/PeerOSCell.tsx index 13e1f31f..85df9933 100644 --- a/src/modules/peers/PeerOSCell.tsx +++ b/src/modules/peers/PeerOSCell.tsx @@ -10,6 +10,7 @@ import { FaWindows } from "react-icons/fa6"; import { FcAndroidOs, FcLinux } from "react-icons/fc"; import IOSIcon from "@/assets/icons/IOSIcon"; import AppleLogo from "@/assets/os-icons/apple.svg"; +import FreeBSDLogo from "@/assets/os-icons/FreeBSD.png"; import { getOperatingSystem } from "@/hooks/useOperatingSystem"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; @@ -49,6 +50,8 @@ export function OSLogo({ os }: { os: string }) { return ; if (icon === OperatingSystem.APPLE) return {""}; + if (icon === OperatingSystem.FREEBSD) + return {""}; if (icon === OperatingSystem.IOS) return ; if (icon === OperatingSystem.ANDROID) diff --git a/src/modules/peers/PeersTable.tsx b/src/modules/peers/PeersTable.tsx index 2b77aaf9..f774f1de 100644 --- a/src/modules/peers/PeersTable.tsx +++ b/src/modules/peers/PeersTable.tsx @@ -1,5 +1,6 @@ import Button from "@components/Button"; import ButtonGroup from "@components/ButtonGroup"; +import { Checkbox } from "@components/Checkbox"; import InlineLink from "@components/InlineLink"; import SquareIcon from "@components/SquareIcon"; import { DataTable } from "@components/table/DataTable"; @@ -9,11 +10,15 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import AddPeerButton from "@components/ui/AddPeerButton"; import GetStartedTest from "@components/ui/GetStartedTest"; import { NotificationCountBadge } from "@components/ui/NotificationCountBadge"; -import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { + ColumnDef, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; import { uniqBy } from "lodash"; import { ExternalLinkIcon } from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import React from "react"; +import { usePathname } from "next/navigation"; +import React, { useState } from "react"; import { useSWRConfig } from "swr"; import PeerIcon from "@/assets/icons/PeerIcon"; import PeerProvider from "@/contexts/PeerProvider"; @@ -26,12 +31,36 @@ import PeerActionCell from "@/modules/peers/PeerActionCell"; import PeerAddressCell from "@/modules/peers/PeerAddressCell"; import PeerGroupCell from "@/modules/peers/PeerGroupCell"; import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell"; +import { PeerMultiSelect } from "@/modules/peers/PeerMultiSelect"; import PeerNameCell from "@/modules/peers/PeerNameCell"; import { PeerOSCell } from "@/modules/peers/PeerOSCell"; import PeerStatusCell from "@/modules/peers/PeerStatusCell"; import PeerVersionCell from "@/modules/peers/PeerVersionCell"; const PeersTableColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, { accessorKey: "name", header: ({ column }) => { @@ -148,10 +177,11 @@ const PeersTableColumns: ColumnDef[] = [ type Props = { peers?: Peer[]; isLoading: boolean; + headingTarget?: HTMLHeadingElement | null; }; -export default function PeersTable({ peers, isLoading }: Props) { + +export default function PeersTable({ peers, isLoading, headingTarget }: Props) { const { mutate } = useSWRConfig(); - const router = useRouter(); const path = usePathname(); // Default sorting state of the table @@ -180,193 +210,248 @@ export default function PeersTable({ peers, isLoading }: Props) { const { isUser } = useLoggedInUser(); + const [selectedRows, setSelectedRows] = useState({}); + + const resetSelectedRows = () => { + if (Object.keys(selectedRows).length > 0) { + setSelectedRows({}); + } + }; + return ( - router.push("/peer?id=" + row.original.id)} - text={"Peers"} - sorting={sorting} - setSorting={setSorting} - columns={PeersTableColumns} - data={peers} - searchPlaceholder={"Search by name, IP, owner or group..."} - columnVisibility={{ - connected: false, - approval_required: false, - group_name_strings: false, - group_names: false, - ip: false, - user_name: false, - user_email: false, - actions: !isUser, - }} - isLoading={isLoading} - getStartedCard={ - } - color={"gray"} - size={"large"} - /> - } - title={"Get Started with NetBird"} - description={ - "It looks like you don't have any connected machines.\n" + - "Get started by adding one to your network." - } - button={} - learnMore={ - <> - Learn more in our{" "} - + setSelectedRows({})} + /> + } + color={"gray"} + size={"large"} + /> + } + title={"Get Started with NetBird"} + description={ + "It looks like you don't have any connected machines.\n" + + "Get started by adding one to your network." + } + button={} + learnMore={ + <> + Learn more in our{" "} + + Getting Started Guide + + + + } + /> + } + rightSide={() => <>{peers && peers.length > 0 && }} + > + {(table) => ( + <> + + { + table.setPageIndex(0); + let groupFilters = table + .getColumn("group_names") + ?.getFilterValue(); + table.setColumnFilters([ + { + id: "connected", + value: undefined, + }, + { + id: "approval_required", + value: undefined, + }, + { + id: "group_names", + value: groupFilters ?? [], + }, + { + id: "group_names", + value: groupFilters ?? [], + }, + ]); + resetSelectedRows(); + }} + variant={ + table.getColumn("connected")?.getFilterValue() == undefined + ? "tertiary" + : "secondary" + } > - Getting Started Guide - - - - } - /> - } - rightSide={() => <>{peers && peers.length > 0 && }} - > - {(table) => ( - <> - - { - table.setPageIndex(0); - table.setColumnFilters([ - { - id: "connected", - value: undefined, - }, - { - id: "approval_required", - value: undefined, - }, - ]); - }} - variant={ - table.getColumn("connected")?.getFilterValue() == undefined - ? "tertiary" - : "secondary" - } - > - All - - { - table.setPageIndex(0); - table.setColumnFilters([ - { - id: "connected", - value: true, - }, - { - id: "approval_required", - value: undefined, - }, - ]); - }} + All + + { + table.setPageIndex(0); + let groupFilters = table + .getColumn("group_names") + ?.getFilterValue(); + table.setColumnFilters([ + { + id: "connected", + value: true, + }, + { + id: "approval_required", + value: undefined, + }, + { + id: "group_names", + value: groupFilters ?? [], + }, + { + id: "group_names", + value: groupFilters ?? [], + }, + ]); + resetSelectedRows(); + }} + disabled={peers?.length == 0} + variant={ + table.getColumn("connected")?.getFilterValue() == true + ? "tertiary" + : "secondary" + } + > + Online + + { + table.setPageIndex(0); + let groupFilters = table + .getColumn("group_names") + ?.getFilterValue(); + table.setColumnFilters([ + { + id: "connected", + value: false, + }, + { + id: "approval_required", + value: undefined, + }, + { + id: "group_names", + value: groupFilters ?? [], + }, + ]); + resetSelectedRows(); + }} + disabled={peers?.length == 0} + variant={ + table.getColumn("connected")?.getFilterValue() == false + ? "tertiary" + : "secondary" + } + > + Offline + + + + {pendingApprovalCount > 0 && ( + + )} + + + + - Online - - { + onChange={(groups) => { table.setPageIndex(0); - table.setColumnFilters([ - { - id: "connected", - value: false, - }, - { - id: "approval_required", - value: undefined, - }, - ]); + if (groups.length == 0) { + table.getColumn("group_names")?.setFilterValue(undefined); + return; + } else { + table.getColumn("group_names")?.setFilterValue(groups); + } + resetSelectedRows(); }} - disabled={peers?.length == 0} - variant={ - table.getColumn("connected")?.getFilterValue() == false - ? "tertiary" - : "secondary" - } - > - Offline - - + groups={tableGroups} + /> - {pendingApprovalCount > 0 && ( - - )} - - - { - table.setPageIndex(0); - if (groups.length == 0) { - table.getColumn("group_names")?.setFilterValue(undefined); - return; - } else { - table.getColumn("group_names")?.setFilterValue(groups); - } - }} - groups={tableGroups} - /> - - { - mutate("/groups").then(); - mutate("/users").then(); - mutate("/peers").then(); - }} - /> - - )} - + /> + + )} + + ); } diff --git a/src/modules/posture-checks/modal/PostureCheckBrowseModal.tsx b/src/modules/posture-checks/modal/PostureCheckBrowseModal.tsx index a54348a7..f053c3b7 100644 --- a/src/modules/posture-checks/modal/PostureCheckBrowseModal.tsx +++ b/src/modules/posture-checks/modal/PostureCheckBrowseModal.tsx @@ -1,33 +1,138 @@ -import { Modal, ModalContent } from "@components/modal/Modal"; -import { cn } from "@utils/helpers"; -import * as React from "react"; +import Button from "@components/Button"; +import { Checkbox } from "@components/Checkbox"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { useLocalStorage } from "@hooks/useLocalStorage"; +import { + ColumnDef, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; +import useFetchApi from "@utils/api"; +import { usePathname } from "next/navigation"; +import React, { useState } from "react"; +import { useSWRConfig } from "swr"; import { PostureCheck } from "@/interfaces/PostureCheck"; -import PostureCheckBrowseTable from "@/modules/posture-checks/table/PostureCheckBrowseTable"; +import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell"; +import { PostureCheckNameCell } from "@/modules/posture-checks/table/cells/PostureCheckNameCell"; type Props = { - onSuccess: (checks: PostureCheck[]) => void; - open: boolean; - onOpenChange: (open: boolean) => void; + onAdd: (checks: PostureCheck[]) => void; }; -export const PostureCheckBrowseModal = ({ - onSuccess, - open, - onOpenChange, -}: Props) => { + +export default function PostureCheckBrowseTable({ onAdd }: Readonly) { + const { data: postureChecks, isLoading } = + useFetchApi("/posture-checks"); + const { mutate } = useSWRConfig(); + const path = usePathname(); + + // Default sorting state of the table + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [ + { + id: "name", + desc: true, + }, + ], + ); + + const [selectedRows, setSelectedRows] = useState({}); + return ( - - + row.toggleSelected()} + rightSide={(table) => ( + <> + {postureChecks && postureChecks?.length > 0 && ( + + )} + + )} > - { - onSuccess(checks); - onOpenChange(false); - }} - /> - - + {() => { + return ( + { + mutate("/posture-checks"); + }} + /> + ); + }} + + ); -}; +} + +export const PostureChecksColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => { + return Name; + }, + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "id", + header: ({ column }) => { + return Checks; + }, + cell: ({ row }) => , + }, +]; diff --git a/src/modules/route-group/GroupedRouteHighAvailabilityCell.tsx b/src/modules/route-group/GroupedRouteHighAvailabilityCell.tsx index bd83c748..e037500a 100644 --- a/src/modules/route-group/GroupedRouteHighAvailabilityCell.tsx +++ b/src/modules/route-group/GroupedRouteHighAvailabilityCell.tsx @@ -5,10 +5,10 @@ import { cn } from "@utils/helpers"; import { HelpCircle, PlusCircle } from "lucide-react"; import { useRouter } from "next/navigation"; import * as React from "react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import PeerIcon from "@/assets/icons/PeerIcon"; import { GroupedRoute } from "@/interfaces/Route"; -import RouteAddRoutingPeerModal from "@/modules/routes/RouteAddRoutingPeerModal"; +import { useAddRoutingPeer } from "@/modules/routes/RouteAddRoutingPeerProvider"; type Props = { groupedRoute: GroupedRoute; @@ -43,109 +43,99 @@ export default function GroupedRouteHighAvailabilityCell({ [], ); - const [modal, setModal] = useState(false); + const { openAddRoutingPeerModal } = useAddRoutingPeer(); return ( - <> - {!groupedRoute.is_using_route_groups && ( - - )} - - - {!isActive && !groupedRoute.is_using_route_groups && ( - <> - {disabledText} -
- Go ahead and add more routing peers to enable high - availability for this network route. -
- - )} - {isActive && !groupedRoute.is_using_route_groups && ( - <> - {enabledText} -
- You can add more peers to increase the availability of this - network route. -
- - )} - {!isActive && groupedRoute.is_using_route_groups && ( - <> - {disabledText} -
- To configure, you must add more peers to a group in this - route. You can do it in the Peers menu. -
- - )} - {isActive && groupedRoute.is_using_route_groups && ( - <> - {enabledText} -
- You can add more peers to a group in this route by going to - the peers page. -
- - )} - - } - > -
- - {isActive ? ( - <> -
- {groupedRoute.high_availability_count} Peer(s) - - ) : ( - <> -
- Disabled - - )} - -
- {groupedRoute.is_using_route_groups && ( - + + {!isActive && !groupedRoute.is_using_route_groups && ( + <> + {disabledText} +
+ Go ahead and add more routing peers to enable high availability + for this network route. +
+ + )} + {isActive && !groupedRoute.is_using_route_groups && ( + <> + {enabledText} +
+ You can add more peers to increase the availability of this + network route. +
+ + )} + {!isActive && groupedRoute.is_using_route_groups && ( + <> + {disabledText} +
+ To configure, you must add more peers to a group in this route. + You can do it in the Peers menu. +
+ + )} + {isActive && groupedRoute.is_using_route_groups && ( + <> + {enabledText} +
+ You can add more peers to a group in this route by going to the + peers page. +
+ )} - {!groupedRoute.is_using_route_groups && ( - - )}{" "}
-
- + } + > +
+ + {isActive ? ( + <> +
+ {groupedRoute.high_availability_count} Peer(s) + + ) : ( + <> +
+ Disabled + + )} + +
+ {groupedRoute.is_using_route_groups && ( + + )} + {!groupedRoute.is_using_route_groups && ( + + )}{" "} +
+ ); } diff --git a/src/modules/route-group/NetworkRoutesTable.tsx b/src/modules/route-group/NetworkRoutesTable.tsx index 582c7e42..65870bf1 100644 --- a/src/modules/route-group/NetworkRoutesTable.tsx +++ b/src/modules/route-group/NetworkRoutesTable.tsx @@ -23,6 +23,7 @@ import GroupedRouteHighAvailabilityCell from "@/modules/route-group/GroupedRoute import GroupedRouteNameCell from "@/modules/route-group/GroupedRouteNameCell"; import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell"; import GroupedRouteTypeCell from "@/modules/route-group/GroupedRouteTypeCell"; +import { RouteAddRoutingPeerProvider } from "@/modules/routes/RouteAddRoutingPeerProvider"; import RouteModal from "@/modules/routes/RouteModal"; import RouteTable from "@/modules/routes/RouteTable"; @@ -138,130 +139,135 @@ export default function NetworkRoutesTable({ ); return ( - { - const data = cloneDeep(row); - return ( - - - - ); - }} - getStartedCard={ - - } - color={"gray"} - size={"large"} - /> - } - title={"Create New Route"} - description={ - "It looks like you don't have any routes. Access LANs and VPC by adding a network route." - } - button={ -
- - - - -
- } - learnMore={ - <> - Learn more about - + { + const data = cloneDeep(row); + return ( + + + + ); + }} + getStartedCard={ + + } + color={"gray"} + size={"large"} + /> + } + title={"Create New Route"} + description={ + "It looks like you don't have any routes. Access LANs and VPC by adding a network route." + } + button={ +
+ + + + +
+ } + learnMore={ + <> + Learn more about + + Network Routes + + + + } + /> + } + rightSide={() => ( + <> + {routes && routes?.length > 0 && ( +
+ + + + +
+ )} + + )} + > + {(table) => ( + <> + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(true); + }} + disabled={routes?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() == true + ? "tertiary" + : "secondary" } - target={"_blank"} > - Network Routes - -
- - } - /> - } - rightSide={() => ( - <> - {routes && routes?.length > 0 && ( -
- - - - -
- )} - - )} - > - {(table) => ( - <> - - { - table.setPageIndex(0); - table.getColumn("enabled")?.setFilterValue(true); - }} + Enabled + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(undefined); + }} + disabled={routes?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() == undefined + ? "tertiary" + : "secondary" + } + > + All + + + - Enabled - - + { - table.setPageIndex(0); - table.getColumn("enabled")?.setFilterValue(undefined); + mutate("/setup-keys").then(); + mutate("/groups").then(); }} - disabled={routes?.length == 0} - variant={ - table.getColumn("enabled")?.getFilterValue() == undefined - ? "tertiary" - : "secondary" - } - > - All - - - - { - mutate("/setup-keys").then(); - mutate("/groups").then(); - }} - /> - - )} -
+ /> + + )} + + ); } diff --git a/src/modules/routes/RouteAddRoutingPeerProvider.tsx b/src/modules/routes/RouteAddRoutingPeerProvider.tsx new file mode 100644 index 00000000..f70b6a1a --- /dev/null +++ b/src/modules/routes/RouteAddRoutingPeerProvider.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { useState } from "react"; +import { GroupedRoute } from "@/interfaces/Route"; +import RouteAddRoutingPeerModal from "@/modules/routes/RouteAddRoutingPeerModal"; + +type Props = { + children: React.ReactNode; +}; + +const GroupedRouteContext = React.createContext( + {} as { + openAddRoutingPeerModal: (groupedRoute: GroupedRoute) => void; + }, +); + +export const RouteAddRoutingPeerProvider = ({ children }: Props) => { + const [groupedRoute, setGroupedRoute] = useState(); + const [modal, setModal] = useState(false); + const openAddRoutingPeerModal = (groupedRoute: GroupedRoute) => { + setGroupedRoute(groupedRoute); + setModal(true); + }; + + return ( + + {children} + {modal && groupedRoute && ( + + )} + + ); +}; + +export const useAddRoutingPeer = () => { + const context = React.useContext(GroupedRouteContext); + if (context === undefined) { + throw new Error( + "useGroupedRoute must be used within a GroupedRouteProvider", + ); + } + return context; +}; diff --git a/src/modules/routes/RouteModal.tsx b/src/modules/routes/RouteModal.tsx index 43aa9408..e4800097 100644 --- a/src/modules/routes/RouteModal.tsx +++ b/src/modules/routes/RouteModal.tsx @@ -579,7 +579,7 @@ export function RouteModalContent({
- A lower metric indicates a higher priority route. + A lower metric indicates higher priority routes.
diff --git a/src/modules/setup-keys/SetupKeyGroupsCell.tsx b/src/modules/setup-keys/SetupKeyGroupsCell.tsx index f862be1c..b35abe14 100644 --- a/src/modules/setup-keys/SetupKeyGroupsCell.tsx +++ b/src/modules/setup-keys/SetupKeyGroupsCell.tsx @@ -46,6 +46,7 @@ export default function SetupKeyGroupsCell({ setupKey }: Props) { } groups={setupKey.auto_groups || []} onSave={handleSave} + hideAllGroup={true} showAddGroupButton={true} modal={modal} setModal={setModal} diff --git a/src/modules/setup-keys/SetupKeyModal.tsx b/src/modules/setup-keys/SetupKeyModal.tsx index 06a4d5b2..1dc3c6ea 100644 --- a/src/modules/setup-keys/SetupKeyModal.tsx +++ b/src/modules/setup-keys/SetupKeyModal.tsx @@ -297,6 +297,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) { diff --git a/src/modules/setup-netbird-modal/SetupModal.tsx b/src/modules/setup-netbird-modal/SetupModal.tsx index ee2e1cfb..e7a4d591 100644 --- a/src/modules/setup-netbird-modal/SetupModal.tsx +++ b/src/modules/setup-netbird-modal/SetupModal.tsx @@ -6,6 +6,7 @@ import Paragraph from "@components/Paragraph"; import SmallParagraph from "@components/SmallParagraph"; import { Tabs, TabsList, TabsTrigger } from "@components/Tabs"; import { ExternalLinkIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; import React from "react"; import AndroidIcon from "@/assets/icons/AndroidIcon"; import AppleIcon from "@/assets/icons/AppleIcon"; @@ -53,23 +54,25 @@ export function SetupModalContent({ }) { const os = useOperatingSystem(); const [isFirstRun] = useLocalStorage("netbird-first-run", true); + const pathname = usePathname(); + const isInstallPage = pathname === "/install"; return ( <> {header && (

- {isFirstRun ? ( + {isFirstRun && !isInstallPage ? ( <> Hello {user?.given_name || "there"}! 👋
{`It's time to add your first device.`} ) : ( - <>Add new peer + <>Install NetBird )}

- To get started, install NetBird and log in using your email account. + To get started, install NetBird and log in with your email account.
)} diff --git a/src/modules/users/ServiceUsersTable.tsx b/src/modules/users/ServiceUsersTable.tsx index d79facaa..2f81374f 100644 --- a/src/modules/users/ServiceUsersTable.tsx +++ b/src/modules/users/ServiceUsersTable.tsx @@ -8,6 +8,7 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import GetStartedTest from "@components/ui/GetStartedTest"; import { IconSettings2 } from "@tabler/icons-react"; import { ColumnDef, SortingState } from "@tanstack/react-table"; +import useFetchApi from "@utils/api"; import { ExternalLinkIcon, PlusCircle } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import React from "react"; @@ -65,6 +66,7 @@ type Props = { }; export default function ServiceUsersTable({ users, isLoading }: Props) { + useFetchApi("/groups"); const { mutate } = useSWRConfig(); const router = useRouter(); const path = usePathname(); @@ -161,7 +163,8 @@ export default function ServiceUsersTable({ users, isLoading }: Props) { { - mutate("/users?service_user=true").then(); + mutate("/users?service_user=true"); + mutate("/groups"); }} /> diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index 18484b68..2d1574c9 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -155,6 +155,7 @@ export function UserInviteModalContent({ onSuccess }: ModalProps) { diff --git a/src/modules/users/UserRoleSelector.tsx b/src/modules/users/UserRoleSelector.tsx index 2bf9d365..e91fbcb0 100644 --- a/src/modules/users/UserRoleSelector.tsx +++ b/src/modules/users/UserRoleSelector.tsx @@ -8,9 +8,10 @@ import { ChevronsUpDown, Cog, User2 } from "lucide-react"; import * as React from "react"; import { useState } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useDialog } from "@/contexts/DialogProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useElementSize } from "@/hooks/useElementSize"; -import { Role } from "@/interfaces/User"; +import { Role, User } from "@/interfaces/User"; interface MultiSelectProps { value?: Role; @@ -18,6 +19,7 @@ interface MultiSelectProps { disabled?: boolean; popoverWidth?: "auto" | number; hideOwner?: boolean; + currentUser?: User; } const UserRoles = [ @@ -44,11 +46,39 @@ export function UserRoleSelector({ disabled = false, popoverWidth = "auto", hideOwner = false, + currentUser, }: MultiSelectProps) { const [inputRef, { width }] = useElementSize(); const { isOwner } = useLoggedInUser(); + const { confirm } = useDialog(); + + const toggle = async (item: Role) => { + if (item === Role.Owner) { + let ok = await confirm({ + title: "Transfer Ownership?", + type: "warning", + description: ( +
+ This action will transfer the{" "} + Owner{" "} + role to{" "} + {currentUser ? ( + + {currentUser.name} + + ) : ( + "this user" + )}{" "} + and leave you with the{" "} + Admin{" "} + role. This action can only be undone if the new owner transfers the + role back to you. +
+ ), + }); + if (!ok) return; + } - const toggle = (item: Role) => { const isSelected = value == item; if (isSelected) { } else { diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx index 61d25c69..bec24c82 100644 --- a/src/modules/users/UsersTable.tsx +++ b/src/modules/users/UsersTable.tsx @@ -7,6 +7,7 @@ import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import GetStartedTest from "@components/ui/GetStartedTest"; import { ColumnDef, SortingState } from "@tanstack/react-table"; +import useFetchApi from "@utils/api"; import { isLocalDev, isNetBirdHosted } from "@utils/netbird"; import dayjs from "dayjs"; import { ExternalLinkIcon, MailPlus, PlusCircle } from "lucide-react"; @@ -100,6 +101,7 @@ type Props = { }; export default function UsersTable({ users, isLoading }: Props) { + useFetchApi("/groups"); const { mutate } = useSWRConfig(); const path = usePathname(); @@ -196,7 +198,8 @@ export default function UsersTable({ users, isLoading }: Props) { { - mutate("/users?service_user=false").then(); + mutate("/users?service_user=false"); + mutate("/groups"); }} /> diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index fd15ba02..627f6574 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -42,7 +42,7 @@ export const sleep = (ms: number) => { export const validator = { isValidDomain: (domain: string) => { const unicodeDomain = - /^(?!.*\.\.)(?!.*\.$)(?!.*\s)(?:(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{1,63}(?