Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
750f660bcc | ||
|
|
ea148545e8 | ||
|
|
d2febbf27b | ||
|
|
615b4487ad | ||
|
|
a7c7800916 | ||
|
|
3d51e0893e | ||
|
|
d7d44b5817 | ||
|
|
f67f39b68b | ||
|
|
d2bc7a1f57 | ||
|
|
818ba5daa4 | ||
|
|
3a30f76629 | ||
|
|
34dc21c89d | ||
|
|
2e37703622 | ||
|
|
8aec338c43 | ||
|
|
f4f0c240fd | ||
|
|
04e22a3c7e | ||
|
|
54ef076303 | ||
|
|
92676b6c38 | ||
|
|
3affa8908f |
13
.github/workflows/build_and_push.yml
vendored
13
.github/workflows/build_and_push.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: setup-node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -54,8 +54,19 @@ jobs:
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=development" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Contributor License Agreement
|
||||
|
||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
||||
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
|
||||
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
|
||||
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
||||
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
||||
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
||||
|
||||
12
announcements.json
Normal file
12
announcements.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"tag": "New",
|
||||
"text": "Custom DNS Zones for Private Network Resolution",
|
||||
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
|
||||
"linkText": "Read Release Article",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
"closeable": true,
|
||||
"isCloudOnly": false
|
||||
}
|
||||
]
|
||||
@@ -15,4 +15,4 @@
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION:
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
4440
package-lock.json
generated
4440
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
68
package.json
68
package.json
@@ -2,6 +2,9 @@
|
||||
"name": "netbird-dashboard",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
||||
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
||||
@@ -13,34 +16,34 @@
|
||||
"cypress:open": "cypress open"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^7.22.18",
|
||||
"@axa-fr/react-oidc": "^7.26.3",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/match-sorter-utils": "^8.8.4",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "^4.14.200",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
@@ -49,8 +52,9 @@
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.30.0",
|
||||
@@ -58,23 +62,23 @@
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"framer-motion": "^12.29.2",
|
||||
"ip-address": "^10.1.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^14.2.35",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-hotjar": "^6.2.0",
|
||||
"react-hotjar": "^6.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
@@ -90,7 +94,7 @@
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
|
||||
@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
@@ -40,7 +40,7 @@ export default function NameServers() {
|
||||
href={"/dns/nameservers"}
|
||||
label={"Nameservers"}
|
||||
active
|
||||
icon={<ServerIcon size={13} />}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Nameservers</h1>
|
||||
|
||||
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal file
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Zones - DNS - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
const DNSZonesTable = lazy(
|
||||
() => import("@/modules/dns/zones/table/DNSZonesTable"),
|
||||
);
|
||||
|
||||
export default function DNSZonePage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/zones"}
|
||||
label={"Zones"}
|
||||
active
|
||||
icon={<DNSZoneIcon size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Zones</h1>
|
||||
<Paragraph>
|
||||
Manage DNS zones to control domain name resolution for your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
data={zones}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
@@ -24,6 +25,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection";
|
||||
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
|
||||
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
|
||||
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
|
||||
@@ -134,7 +136,9 @@ const validAllGroupTabs = [
|
||||
"resources",
|
||||
"network-routes",
|
||||
"nameservers",
|
||||
"zones",
|
||||
];
|
||||
|
||||
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
|
||||
|
||||
const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
@@ -162,6 +166,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
const resourcesCount = groupDetails?.resources_count || 0;
|
||||
const routesCount = groupDetails?.routes?.length || 0;
|
||||
const nameserversCount = groupDetails?.nameservers?.length || 0;
|
||||
const zonesCount = groupDetails?.zones?.length || 0;
|
||||
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
|
||||
|
||||
return (
|
||||
@@ -249,6 +254,19 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
{singularize("Nameservers", nameserversCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"zones"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<DNSZoneIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Zones", zonesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"setup-keys"}
|
||||
@@ -304,6 +322,13 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"zones"} className={"pb-8"}>
|
||||
<GroupDNSZonesSection
|
||||
zones={groupDetails?.zones}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection
|
||||
setupKeys={groupDetails?.setupKeys}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
RadioTowerIcon,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -61,12 +61,13 @@ import type { Peer } from "@/interfaces/Peer";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import Link from "next/link";
|
||||
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -80,12 +81,6 @@ export default function PeerPage() {
|
||||
|
||||
useRedirect("/peers", false, !peerId || isRestricted);
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -106,7 +101,7 @@ export default function PeerPage() {
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peerKey} />
|
||||
<PeerOverview key={peer?.id} />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
@@ -142,12 +137,6 @@ const PeerGeneralInformation = () => {
|
||||
const { peer, user, peerGroups, update } = usePeer();
|
||||
const [name, setName] = useState(peer.name);
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [inactivityExpiration, setInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||
@@ -159,8 +148,6 @@ const PeerGeneralInformation = () => {
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async (newName?: string) => {
|
||||
@@ -170,8 +157,6 @@ const PeerGeneralInformation = () => {
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
|
||||
} else {
|
||||
@@ -184,11 +169,7 @@ const PeerGeneralInformation = () => {
|
||||
promise: Promise.all(batchCall).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
updateHasChangedRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving the peer...",
|
||||
});
|
||||
@@ -284,41 +265,7 @@ const PeerGeneralInformation = () => {
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
{permission.peers.update && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!loginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
value={inactivityExpiration}
|
||||
onChange={setInactivityExpiration}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!loginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PeerExpirationSettings />
|
||||
|
||||
<PeerSSHToggle />
|
||||
|
||||
@@ -382,6 +329,13 @@ const PeerOverviewTabs = () => {
|
||||
Accessible Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.delete && (
|
||||
<TabsTrigger value={"peer-job"}>
|
||||
<RadioTowerIcon size={16} />
|
||||
Remote Jobs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{permission.routes.read && (
|
||||
@@ -395,6 +349,11 @@ const PeerOverviewTabs = () => {
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{peer.id && permission.peers.delete && (
|
||||
<TabsContent value={"peer-job"} className={"pb-8"}>
|
||||
<PeerRemoteJobsSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -582,9 +541,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
peer.connected
|
||||
? "just now"
|
||||
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
|
||||
" (" +
|
||||
dayjs().to(peer.last_seen) +
|
||||
")"
|
||||
" (" +
|
||||
dayjs().to(peer.last_seen) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
@@ -85,11 +84,8 @@ function RDPSession({ peer }: Props) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
await client.connectTemporary(peer.id, [
|
||||
`${protocol}/${rdpCredentials.port}`,
|
||||
`tcp/${rdpCredentials.port}`,
|
||||
]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
html{
|
||||
@apply bg-nb-gray;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-2xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
|
||||
|
||||
8
src/app/invite/layout.tsx
Normal file
8
src/app/invite/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Accept Invite - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
321
src/app/invite/page.tsx
Normal file
321
src/app/invite/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
KeyRound,
|
||||
Mail,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { UserInviteInfo } from "@/interfaces/User";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
return (
|
||||
<Suspense fallback={<FullScreenLoading />}>
|
||||
<InviteAcceptContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteAcceptContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const token = searchParams?.get("token");
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRateLimited, setIsRateLimited] = useState(false);
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError("No invite token provided");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchInviteInfo(token)
|
||||
.then((info) => {
|
||||
setInviteInfo(info);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code === 429) {
|
||||
setError("Too many attempts. Please wait a moment and try again.");
|
||||
setIsRateLimited(true);
|
||||
} else {
|
||||
setError(err.message || "Invalid or expired invite link");
|
||||
setIsRateLimited(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const hasMinLength = password.length >= 8;
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
|
||||
const canSubmit = passwordValid && passwordsMatch && !submitting;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !token) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await acceptInvite(token, password);
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpired = useMemo(() => {
|
||||
if (!inviteInfo) return false;
|
||||
return new Date(inviteInfo.expires_at) < new Date();
|
||||
}, [inviteInfo]);
|
||||
|
||||
if (loading) {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
if (error && !inviteInfo) {
|
||||
if (isRateLimited) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Too Many Requests
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400 text-base">
|
||||
You've made too many requests. Please wait a moment and try
|
||||
again.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Invalid Invite
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400 text-base">
|
||||
This invite link is invalid or has expired. Please contact your
|
||||
administrator to receive a new invitation.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Account Created!
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400">
|
||||
Your account has been created successfully. You can now log in with
|
||||
your email and password.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpired || !inviteInfo?.valid) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Invite Expired
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400">
|
||||
This invite link has expired. Please contact your administrator to
|
||||
receive a new invitation.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<NetBirdIcon size={48} />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Welcome to NetBird
|
||||
</h1>
|
||||
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
|
||||
You've been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
|
||||
<User2 className="w-5 h-5 text-nb-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{inviteInfo.name}</div>
|
||||
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
{inviteInfo.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
customPrefix={
|
||||
<KeyRound size={16} className="text-nb-gray-400" />
|
||||
}
|
||||
/>
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<PasswordRule met={hasMinLength} text="At least 8 characters" />
|
||||
<PasswordRule met={hasUppercase} text="One uppercase letter" />
|
||||
<PasswordRule met={hasLowercase} text="One lowercase letter" />
|
||||
<PasswordRule met={hasNumber} text="One number" />
|
||||
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
customPrefix={
|
||||
<KeyRound size={16} className="text-nb-gray-400" />
|
||||
}
|
||||
/>
|
||||
{confirmPassword && !passwordsMatch && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
Passwords do not match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? "Creating Account..." : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-nb-gray-500">
|
||||
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordRule({ met, text }: { met: boolean; text: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{met ? (
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
|
||||
)}
|
||||
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
|
||||
import { useInstanceSetup } from "@/contexts/InstanceSetupProvider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function SetupPage() {
|
||||
return <InstanceSetupWizard />;
|
||||
const { setupRequired, loading } = useInstanceSetup();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !setupRequired) router.replace("/peers");
|
||||
}, [loading, setupRequired]);
|
||||
|
||||
return loading || !setupRequired ? (
|
||||
<FullScreenLoading />
|
||||
) : (
|
||||
<InstanceSetupWizard />
|
||||
);
|
||||
}
|
||||
|
||||
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function DNSZoneIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
src/assets/icons/SlackIcon.tsx
Normal file
30
src/assets/icons/SlackIcon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function SlackIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="127"
|
||||
height="127"
|
||||
viewBox="0 0 127 127"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,9 @@ export default function OIDCProvider({ children }: Props) {
|
||||
// We bypass authentication for pages that do not require auth.
|
||||
// E.g., when we just want to show installation steps for public.
|
||||
// Or the instance setup wizard for first-time setup.
|
||||
if (path === "/install" || path === "/setup") return children;
|
||||
// Or the invite acceptance page for new users.
|
||||
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
|
||||
return children;
|
||||
|
||||
return mounted && providerConfig ? (
|
||||
<OidcProvider
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { Input } from "@components/Input";
|
||||
import { Popover, PopoverContent } from "@components/Popover";
|
||||
import { useElementSize } from "@hooks/useElementSize";
|
||||
import { Anchor } from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FaWindows } from "react-icons/fa6";
|
||||
|
||||
type Props = {};
|
||||
export const AutoCompleteInput = ({}: Props) => {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [elementWidth, { width }] = useElementSize<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
|
||||
const onFocus = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
if (input) {
|
||||
inputRef.current.addEventListener("focus", onFocus);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (input) {
|
||||
inputRef.current.removeEventListener("focus", onFocus);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={"z-10 relative"}>
|
||||
<Popover modal={false} open={open} onOpenChange={setOpen}>
|
||||
<Anchor ref={elementWidth}>
|
||||
<Input
|
||||
placeholder={"11"}
|
||||
ref={inputRef}
|
||||
maxWidthClass={"max-w-[200px]"}
|
||||
customPrefix={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Checkbox></Checkbox>
|
||||
<div
|
||||
className={"flex gap-2 items-center text-sm text-nb-gray-200"}
|
||||
>
|
||||
<FaWindows className={"text-sky-600 text-lg"} />
|
||||
Windows
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Anchor>
|
||||
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: width,
|
||||
}}
|
||||
forceMount={true}
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.target !== inputRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.target !== inputRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onFocusOutside={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.target !== inputRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
></PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -76,6 +76,7 @@ export const buttonVariants = cva(
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||
],
|
||||
danger: [
|
||||
"", // TODO - add danger button styles for light mode
|
||||
|
||||
@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
>(({ className, inset, variant = "default", onClick, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
inset,
|
||||
variant = "default",
|
||||
onClick,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (href) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} target={target} rel={rel}>
|
||||
{props.children}
|
||||
</a>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface InputProps
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
errorTooltip?: boolean;
|
||||
errorTooltipPosition?: "top" | "top-right";
|
||||
errorTooltipPosition?: "top" | "top-right" | "bottom";
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
}
|
||||
|
||||
@@ -53,13 +53,10 @@ const TooltipContent = React.forwardRef<
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
|
||||
36
src/components/TooltipListItem.tsx
Normal file
36
src/components/TooltipListItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
export const TooltipListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
labelClassName,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-nb-gray-100 font-medium",
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
148
src/components/VersionInfo.tsx
Normal file
148
src/components/VersionInfo.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
|
||||
|
||||
function formatVersion(version: string): string {
|
||||
if (!version) return "";
|
||||
// Add "v" prefix if version starts with a number
|
||||
if (/^\d/.test(version)) return `v${version}`;
|
||||
return version;
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
// Returns true if latest is newer than current
|
||||
if (!current || !latest) return false;
|
||||
if (current === "development") return false;
|
||||
|
||||
// Strip "v" prefix if present
|
||||
const normalizedCurrent = current.replace(/^v/, "");
|
||||
const normalizedLatest = latest.replace(/^v/, "");
|
||||
|
||||
const currentParts = normalizedCurrent
|
||||
.split(".")
|
||||
.map((p) => parseInt(p, 10) || 0);
|
||||
const latestParts = normalizedLatest
|
||||
.split(".")
|
||||
.map((p) => parseInt(p, 10) || 0);
|
||||
|
||||
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
||||
const c = currentParts[i] || 0;
|
||||
const l = latestParts[i] || 0;
|
||||
if (l > c) return true;
|
||||
if (l < c) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const NavigationVersionInfo = () => {
|
||||
const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext();
|
||||
|
||||
// Only show for self-hosted, not cloud
|
||||
if (isNetBirdHosted()) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-4 animate-fade-in",
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
"hidden md:group-hover/navigation:block",
|
||||
)}
|
||||
>
|
||||
<NavigationVersionInfoContent />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NavigationVersionInfoContent = () => {
|
||||
const { data: versionInfo, isLoading } = useFetchApi<VersionInfoType>(
|
||||
"/instance/version",
|
||||
true, // ignore errors
|
||||
false, // don't revalidate on focus
|
||||
);
|
||||
|
||||
const dashboardVersion =
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development";
|
||||
|
||||
if (isLoading)
|
||||
return <Skeleton height={80} className={"rounded-lg opacity-60"} />;
|
||||
|
||||
if (!versionInfo) return null;
|
||||
|
||||
// Compare versions to detect updates (returns false for "development" versions)
|
||||
const managementUpdateAvailable = compareVersions(
|
||||
versionInfo.management_current_version,
|
||||
versionInfo.management_available_version,
|
||||
);
|
||||
const dashboardUpdateAvailable = compareVersions(
|
||||
dashboardVersion,
|
||||
versionInfo.dashboard_available_version,
|
||||
);
|
||||
const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-md text-xs flex flex-col gap-2 whitespace-normal border text-left",
|
||||
"bg-nb-gray-900/20 py-3 px-3 border-nb-gray-800/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-nb-gray-400">
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Management</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(dashboardVersion)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
|
||||
{hasUpdate && (
|
||||
<a
|
||||
href="https://docs.netbird.io/selfhosted/maintenance/upgrade"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-white font-medium bg-netbird hover:bg-netbird-500 transition-colors rounded-md py-1.5 px-2 mt-1"
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
<span>Update available</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationVersionInfo;
|
||||
@@ -1,12 +1,25 @@
|
||||
import {
|
||||
MemoizedScrollArea,
|
||||
MemoizedScrollAreaViewport,
|
||||
ScrollAreaViewport,
|
||||
} from "@components/ScrollArea";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
const VirtuosoScroller = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>((props, ref) => <ScrollAreaViewport ref={ref} {...props} />);
|
||||
|
||||
type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
@@ -183,7 +196,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
}}
|
||||
style={virtuosoHeight}
|
||||
components={{
|
||||
Scroller: MemoizedScrollAreaViewport,
|
||||
Scroller: VirtuosoScroller,
|
||||
}}
|
||||
/>
|
||||
</MemoizedScrollArea>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { DialogTriggerProps } from "@radix-ui/react-dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -74,18 +75,19 @@ const ModalContent = React.forwardRef<
|
||||
{...props}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||
</VisuallyHidden>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</ModalOverlay>
|
||||
</ModalPortal>
|
||||
@@ -129,18 +131,19 @@ const SidebarModalContent = React.forwardRef<
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||
</VisuallyHidden>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
|
||||
@@ -14,11 +14,6 @@ import {
|
||||
TableWrapper,
|
||||
} from "@components/table/Table";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
} from "@radix-ui/react-accordion";
|
||||
import { RankingInfo } from "@tanstack/match-sorter-utils";
|
||||
import {
|
||||
ColumnDef,
|
||||
@@ -493,117 +488,97 @@ export function DataTable<TData, TValue>({
|
||||
</TableHeaderComponent>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
asChild={true}
|
||||
type={"multiple"}
|
||||
value={accordion}
|
||||
onValueChange={setAccordion}
|
||||
<TableBodyComponent
|
||||
className={cn(
|
||||
"relative",
|
||||
data == undefined && "blur-sm",
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
<TableBodyComponent
|
||||
className={cn(
|
||||
"relative",
|
||||
data == undefined && "blur-sm",
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
const rowContent = (
|
||||
<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"
|
||||
}
|
||||
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];
|
||||
}
|
||||
});
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
const rowId = row.original.id ?? row.id;
|
||||
const isExpanded = accordion?.includes(rowId);
|
||||
const rowContent = (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={rowId}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"relative group/accordion",
|
||||
(onRowClick || expandedRow) && "cursor-pointer",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={isExpanded ? "opened" : "closed"}
|
||||
onClick={(e) => {
|
||||
if (expandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(rowId)) {
|
||||
return prev.filter(
|
||||
(item) => item !== rowId,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), rowId];
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{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>
|
||||
<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>
|
||||
|
||||
{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"}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
{expandedRow && isExpanded && (
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
);
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return renderRow
|
||||
? renderRow(row.original, rowContent)
|
||||
: rowContent;
|
||||
})
|
||||
return renderRow
|
||||
? renderRow(row.original, rowContent)
|
||||
: rowContent;
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
<TableCellComponent
|
||||
@@ -614,8 +589,7 @@ export function DataTable<TData, TValue>({
|
||||
</TableCellComponent>
|
||||
</TableRowUnstyledComponent>
|
||||
)}
|
||||
</TableBodyComponent>
|
||||
</Accordion>
|
||||
</TableBodyComponent>
|
||||
</TableComponent>
|
||||
)}
|
||||
</TableWrapper>
|
||||
|
||||
@@ -104,7 +104,7 @@ const TableRow = React.forwardRef<
|
||||
" transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70",
|
||||
"dark:data-[state=selected]:border-nb-gray-900",
|
||||
minimal
|
||||
? "dark:hover:bg-nb-gray-900/10"
|
||||
? "dark:hover:bg-nb-gray-910/[15%]"
|
||||
: "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -64,8 +64,16 @@ const Time = ({
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const { ref, ...rootProps } = getRootProps();
|
||||
|
||||
return (
|
||||
<div className={"timescape w-full"} {...getRootProps()}>
|
||||
<div
|
||||
className={"timescape w-full"}
|
||||
ref={(element) => {
|
||||
ref(element);
|
||||
}}
|
||||
{...rootProps}
|
||||
>
|
||||
<div>
|
||||
<input {...getInputProps("years")} />
|
||||
<span className={"separator"}>/</span>
|
||||
|
||||
@@ -19,40 +19,46 @@ function Calendar({
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-y-0 relative",
|
||||
month: "space-y-4 pr-4 last:pr-0",
|
||||
month_caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-0 top-0 z-10",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-0 top-0 z-10",
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-neutral-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-neutral-400",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-neutral-100/50 [&:has([aria-selected])]:bg-neutral-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected].day-outside)]:bg-neutral-800/50 dark:[&:has([aria-selected])]:bg-neutral-800",
|
||||
day: cn("h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||
day_range_end: "day-range-end rounded-r-md",
|
||||
day_range_start: "day-range-start rounded-l-md",
|
||||
day_selected:
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-neutral-100/50 [&:has([aria-selected])]:bg-neutral-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected].day-outside)]:bg-neutral-800/50 dark:[&:has([aria-selected])]:bg-neutral-800",
|
||||
day_button: cn("h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||
range_end: "day-range-end rounded-r-md",
|
||||
range_start: "day-range-start rounded-l-md",
|
||||
selected:
|
||||
"bg-neutral-900 text-neutral-50 hover:bg-neutral-900 hover:text-neutral-50 focus:bg-neutral-900 focus:text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50 dark:hover:text-neutral-900 dark:focus:bg-neutral-50 dark:focus:text-neutral-900",
|
||||
day_today: "text-neutral-900 dark:text-red-500",
|
||||
day_outside:
|
||||
today: "text-neutral-900 dark:text-red-500",
|
||||
outside:
|
||||
"day-outside text-neutral-500 opacity-50 aria-selected:bg-neutral-100/50 aria-selected:text-neutral-500 aria-selected:opacity-30 dark:text-neutral-400 dark:aria-selected:bg-neutral-800/50 dark:aria-selected:text-neutral-400",
|
||||
day_disabled: "text-neutral-500 opacity-50 dark:text-neutral-400",
|
||||
day_range_middle:
|
||||
disabled: "text-neutral-500 opacity-50 dark:text-neutral-400",
|
||||
range_middle:
|
||||
"aria-selected:bg-neutral-100 aria-selected:text-neutral-900 dark:aria-selected:bg-nb-gray-800 dark:aria-selected:text-neutral-50 rounded-none",
|
||||
day_hidden: "invisible",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: () => <ChevronRight className="h-4 w-4" />,
|
||||
Chevron: ({ orientation }) =>
|
||||
orientation === "left" ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
145
src/components/ui/HelpAndSupportButton.tsx
Normal file
145
src/components/ui/HelpAndSupportButton.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BookText,
|
||||
CircleQuestionMark,
|
||||
MailIcon,
|
||||
MessageSquareShare,
|
||||
MessagesSquareIcon,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Button from "@components/Button";
|
||||
import { cn } from "@utils/helpers";
|
||||
import SlackIcon from "@/assets/icons/SlackIcon";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
|
||||
export default function HelpAndSupportButton() {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"default-outline"}
|
||||
className={cn(
|
||||
"!rounded-full h-[38px] w-[38px] !p-0",
|
||||
dropdownOpen && "text-white",
|
||||
)}
|
||||
>
|
||||
<CircleQuestionMark size={18} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1 px-1">
|
||||
<div className="text-sm font-normal leading-none text-nb-gray-200 py-1">
|
||||
Help and Support
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
href="https://docs.netbird.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<BookText size={14} />
|
||||
Documentation
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
href="https://docs.netbird.io/help/troubleshooting-client"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<TriangleAlert size={14} />
|
||||
Troubleshooting
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isNetBirdHosted() && (
|
||||
<DropdownMenuItem href="mailto:support@netbird.io?subject=Support Request">
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<MailIcon size={14} />
|
||||
support@netbird.io
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
href="https://forum.netbird.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<MessagesSquareIcon size={14} />
|
||||
NetBird Forum
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
href="https://docs.netbird.io/slack-url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SlackIcon size={14} />
|
||||
NetBird Slack
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
href={"https://forms.gle/TeLw2zrXEdw6RcQ36"}
|
||||
target={"_blank"}
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<MessageSquareShare size={14} />
|
||||
Feedback
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ type Props = {
|
||||
className?: string;
|
||||
hasFiltersApplied?: boolean;
|
||||
onResetFilters?: () => void;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export default function NoResults({
|
||||
icon,
|
||||
title = "Could not find any results",
|
||||
@@ -23,6 +25,7 @@ export default function NoResults({
|
||||
className,
|
||||
hasFiltersApplied = false,
|
||||
onResetFilters,
|
||||
contentClassName,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -65,7 +68,9 @@ export default function NoResults({
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("max-w-md mx-auto relative z-20 py-6")}>
|
||||
<div
|
||||
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"
|
||||
|
||||
@@ -19,11 +19,12 @@ export default function PeerCountBadge({
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions } = useGroups();
|
||||
const { dropdownOptions, groups } = useGroups();
|
||||
|
||||
const currentGroup = useMemo(() => {
|
||||
return dropdownOptions?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions]);
|
||||
const options = dropdownOptions?.find((g) => g.name === group?.name);
|
||||
return options ?? groups?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions, groups]);
|
||||
|
||||
const peerCount = useMemo(() => {
|
||||
let peerCount = currentGroup?.peers_count ?? 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn, generateColorFromUser } from "@utils/helpers";
|
||||
import { Avatar } from "flowbite-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
|
||||
type Props = {
|
||||
@@ -13,26 +13,27 @@ export const UserAvatar = ({ size = "default" }: Props) => {
|
||||
const [pictureLoaded, setPictureLoaded] = useState(true);
|
||||
|
||||
const getAvatarSize = () => {
|
||||
if (size === "small") return "sm";
|
||||
if (size === "large") return "lg";
|
||||
return "md";
|
||||
if (size === "small") return 32;
|
||||
if (size === "default") return 40;
|
||||
if (size === "large") return 48;
|
||||
return 35.2;
|
||||
};
|
||||
|
||||
return pictureLoaded ? (
|
||||
<Avatar
|
||||
alt=""
|
||||
img={user?.picture}
|
||||
rounded
|
||||
return pictureLoaded && user?.picture ? (
|
||||
<Image
|
||||
src={user?.picture}
|
||||
alt={""}
|
||||
onError={() => setPictureLoaded(false)}
|
||||
size={getAvatarSize()}
|
||||
className={"shrink-0"}
|
||||
width={getAvatarSize()}
|
||||
height={getAvatarSize()}
|
||||
className={"rounded-full"}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird uppercase",
|
||||
size == "small" && "w-8 h-8",
|
||||
size == "medium" && "w-[2.3rem] h-[2.3rem]",
|
||||
size == "medium" && "w-[2.2rem] h-[2.2rem]",
|
||||
size == "default" && "w-10 h-10",
|
||||
size == "large" && "w-12 h-12",
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { LogOutIcon, User2 } from "lucide-react";
|
||||
import { KeyRound, LogOutIcon, User2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
@@ -19,9 +19,13 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import useOSDetection from "@/hooks/useOperatingSystem";
|
||||
import { ChangePasswordModalContent } from "@/modules/users/ChangePasswordModal";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
|
||||
export default function UserDropdown() {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [changePasswordModal, setChangePasswordModal] = useState(false);
|
||||
const { user } = useApplicationContext();
|
||||
const { loggedInUser, logout } = useLoggedInUser();
|
||||
const { isRestricted, permission } = usePermissions();
|
||||
@@ -31,17 +35,28 @@ export default function UserDropdown() {
|
||||
useHotkeys("shift+mod+l", () => logout(), []);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<>
|
||||
<Modal
|
||||
open={changePasswordModal}
|
||||
onOpenChange={setChangePasswordModal}
|
||||
key={changePasswordModal ? 1 : 0}
|
||||
>
|
||||
<ChangePasswordModalContent
|
||||
userId={loggedInUser?.id}
|
||||
onSuccess={() => setChangePasswordModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<UserAvatar size={"medium"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex flex-col space-y-0.5 px-1">
|
||||
<div className="text-sm font-medium leading-none dark:text-gray-300">
|
||||
<TextWithTooltip
|
||||
text={user?.name}
|
||||
@@ -72,6 +87,20 @@ export default function UserDropdown() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isNetBirdHosted() && loggedInUser?.idp_id === "local" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
setChangePasswordModal(true);
|
||||
}}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<KeyRound size={14} />
|
||||
Change Password
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={logout}>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<LogOutIcon size={14} />
|
||||
@@ -81,6 +110,7 @@ export default function UserDropdown() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function AnalyticsProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
}
|
||||
if (hjid && window._DATADOG_SYNTHETICS_BROWSER === undefined) {
|
||||
hotjar.initialize(hjid, 6);
|
||||
hotjar.initialize({ id: hjid, sv: 6 });
|
||||
}
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import md5 from "crypto-js/md5";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
|
||||
const initialAnnouncements: Announcement[] = [
|
||||
{
|
||||
tag: "New",
|
||||
text: "NetBird v0.61 Released - Granular SSH Access Control and Automatic Updates",
|
||||
link: "https://netbird.io/knowledge-hub/granular-ssh-access-automatic-updates",
|
||||
linkText: "Read Release Article",
|
||||
variant: "important", // "default" or "important"
|
||||
isExternal: true,
|
||||
closeable: true,
|
||||
isCloudOnly: false,
|
||||
},
|
||||
];
|
||||
const ANNOUNCEMENTS_URL =
|
||||
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
|
||||
const STORAGE_KEY = "netbird-announcements";
|
||||
const CACHE_DURATION_MS = 30 * 60 * 1000;
|
||||
const BANNER_HEIGHT = 40;
|
||||
|
||||
interface AnnouncementStore {
|
||||
timestamp: number;
|
||||
announcements: Announcement[];
|
||||
closedAnnouncements: string[];
|
||||
}
|
||||
|
||||
export interface Announcement extends AnnouncementVariant {
|
||||
tag: string;
|
||||
@@ -36,7 +41,7 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const AnnouncementContext = React.createContext(
|
||||
const AnnouncementContext = createContext(
|
||||
{} as {
|
||||
bannerHeight: number;
|
||||
announcements?: AnnouncementInfo[];
|
||||
@@ -47,59 +52,99 @@ const AnnouncementContext = React.createContext(
|
||||
},
|
||||
);
|
||||
|
||||
const bannerHeight = 40;
|
||||
const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
|
||||
try {
|
||||
let stored: AnnouncementStore | null = null;
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
stored = data ? JSON.parse(data) : null;
|
||||
} catch {}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
let raw: Announcement[];
|
||||
|
||||
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
raw = stored.announcements;
|
||||
} else {
|
||||
const response = await fetch(ANNOUNCEMENTS_URL);
|
||||
if (!response.ok) return [];
|
||||
|
||||
raw = await response.json();
|
||||
}
|
||||
|
||||
const isCloud = isNetBirdHosted();
|
||||
const filtered = raw.filter((a) => !a.isCloudOnly || isCloud);
|
||||
const hashes = new Set(filtered.map((a) => md5(a.text).toString()));
|
||||
const closed = (stored?.closedAnnouncements ?? []).filter((h) =>
|
||||
hashes.has(h),
|
||||
);
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
timestamp: now,
|
||||
announcements: raw,
|
||||
closedAnnouncements: closed,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
|
||||
return filtered.map((a) => {
|
||||
const hash = md5(a.text).toString();
|
||||
return { ...a, hash, isOpen: !closed.includes(hash) };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveAnnouncements = (closedAnnouncements: string[]) => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
const stored: AnnouncementStore | null = data ? JSON.parse(data) : null;
|
||||
if (stored) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ ...stored, closedAnnouncements }),
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export default function AnnouncementProvider({ children }: Readonly<Props>) {
|
||||
const [height, setHeight] = useState(0);
|
||||
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
|
||||
string[]
|
||||
>("netbird-closed-announcements", []);
|
||||
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
|
||||
const { isRestricted } = usePermissions();
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (announcements && announcements.length > 0) return;
|
||||
|
||||
if (isRestricted) return;
|
||||
const initial = initialAnnouncements.map((announcement) => {
|
||||
const hash = md5(announcement.text).toString();
|
||||
const isOpen = !closedAnnouncements.some((h) => h === hash);
|
||||
return {
|
||||
...announcement,
|
||||
hash,
|
||||
isOpen,
|
||||
} as AnnouncementInfo;
|
||||
});
|
||||
if (initial.length > 0) {
|
||||
setAnnouncements(initial);
|
||||
}
|
||||
}, [closedAnnouncements, announcements]);
|
||||
if (announcements !== undefined || isRestricted || fetchingRef.current)
|
||||
return;
|
||||
fetchingRef.current = true;
|
||||
getAnnouncements()
|
||||
.then((a) => setAnnouncements(a))
|
||||
.finally(() => (fetchingRef.current = false));
|
||||
}, [announcements, isRestricted]);
|
||||
|
||||
const closeAnnouncement = (hash: string) => {
|
||||
setClosedAnnouncements([...closedAnnouncements, hash]);
|
||||
setAnnouncements(() => {
|
||||
return announcements?.map((a) => {
|
||||
if (a.hash === hash) {
|
||||
return { ...a, isOpen: false };
|
||||
}
|
||||
return a;
|
||||
});
|
||||
});
|
||||
if (!announcements) return;
|
||||
const updated = announcements.map((a) =>
|
||||
a.hash === hash ? { ...a, isOpen: false } : a,
|
||||
);
|
||||
const closedAnnouncements = updated
|
||||
.filter((a) => !a.isOpen)
|
||||
.map((a) => a.hash);
|
||||
saveAnnouncements(closedAnnouncements);
|
||||
setAnnouncements(updated);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isAnnouncementOpen = announcements?.some((a) => a.isOpen);
|
||||
if (isAnnouncementOpen) {
|
||||
setHeight(bannerHeight);
|
||||
} else {
|
||||
setHeight(0);
|
||||
}
|
||||
}, [announcements]);
|
||||
const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0;
|
||||
|
||||
return (
|
||||
<AnnouncementContext.Provider
|
||||
value={{
|
||||
bannerHeight: height,
|
||||
bannerHeight,
|
||||
announcements,
|
||||
closeAnnouncement,
|
||||
setAnnouncements,
|
||||
@@ -110,6 +155,4 @@ export default function AnnouncementProvider({ children }: Readonly<Props>) {
|
||||
);
|
||||
}
|
||||
|
||||
export const useAnnouncement = () => {
|
||||
return React.useContext(AnnouncementContext);
|
||||
};
|
||||
export const useAnnouncement = () => useContext(AnnouncementContext);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function DialogProvider({ children }: Props) {
|
||||
isOpen: false,
|
||||
});
|
||||
const [dialogOptions, setDialogOptions] = useState<DialogOptions>();
|
||||
const fn = useRef<Function>();
|
||||
const fn = useRef<Function>(undefined);
|
||||
|
||||
const confirm = useCallback((data: DialogOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -66,6 +66,8 @@ export default function DialogProvider({ children }: Props) {
|
||||
<ModalContent
|
||||
maxWidthClass={dialogOptions.maxWidthClass || "max-w-[400px]"}
|
||||
showClose={false}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { netbirdTheme } from "@utils/theme";
|
||||
import { Flowbite } from "flowbite-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import * as React from "react";
|
||||
@@ -26,11 +24,9 @@ export function GlobalThemeProvider({
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
<Flowbite theme={{ theme: netbirdTheme }}>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
</Flowbite>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ export default function InstanceSetupProvider({
|
||||
const pathname = usePathname();
|
||||
|
||||
// Routes that don't need setup check
|
||||
const bypassRoutes = ["/setup", "/install"];
|
||||
const shouldBypass =
|
||||
bypassRoutes.includes(pathname) || isOIDCCallback();
|
||||
const bypassRoutes = ["/install"];
|
||||
const shouldBypass = bypassRoutes.includes(pathname) || isOIDCCallback();
|
||||
|
||||
// Skip setup check for NetBird hosted (cloud) deployments
|
||||
const isCloud = isNetBirdHosted();
|
||||
const isSetupPage = pathname === "/setup";
|
||||
|
||||
// Check instance status on mount
|
||||
useEffect(() => {
|
||||
@@ -70,10 +70,10 @@ export default function InstanceSetupProvider({
|
||||
|
||||
// Handle redirect separately to avoid setState during render conflicts
|
||||
useEffect(() => {
|
||||
if (setupRequired && !shouldBypass) {
|
||||
if (setupRequired && !shouldBypass && !isSetupPage) {
|
||||
router.replace("/setup");
|
||||
}
|
||||
}, [setupRequired, shouldBypass, router]);
|
||||
}, [setupRequired, shouldBypass, router, isSetupPage]);
|
||||
|
||||
// Show loading while checking (only for non-cloud, non-bypass routes)
|
||||
if (loading && !shouldBypass && !isCloud) {
|
||||
@@ -81,7 +81,7 @@ export default function InstanceSetupProvider({
|
||||
}
|
||||
|
||||
// If setup required and not on setup page, wait for redirect
|
||||
if (setupRequired && !shouldBypass) {
|
||||
if (setupRequired && !shouldBypass && !isSetupPage) {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function useIsVisible(ref: RefObject<HTMLElement>) {
|
||||
export default function useIsVisible(ref: RefObject<HTMLElement | null>) {
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const [isOnScreen, setIsOnScreen] = useState(false);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>();
|
||||
const ref = useRef<T>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Account {
|
||||
lazy_connection_enabled: boolean;
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
local_auth_disabled?: boolean;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
}
|
||||
|
||||
25
src/interfaces/DNS.ts
Normal file
25
src/interfaces/DNS.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface DNSZone {
|
||||
id?: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
enable_search_domain: boolean;
|
||||
distribution_groups: string[];
|
||||
records?: DNSRecord[];
|
||||
groups_search?: string;
|
||||
}
|
||||
|
||||
export interface DNSRecord {
|
||||
id?: string;
|
||||
name: string;
|
||||
type: "A" | "AAAA" | "CNAME";
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export type DNSRecordType = "A" | "AAAA" | "CNAME";
|
||||
|
||||
export const DNS_ZONE_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/dns/custom-zones";
|
||||
export const DNS_RECORDS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/dns/custom-zones#adding-records-to-a-zone";
|
||||
@@ -17,3 +17,9 @@ export interface ApiError {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
management_current_version: string;
|
||||
management_available_version: string;
|
||||
dashboard_available_version: string;
|
||||
}
|
||||
|
||||
23
src/interfaces/Job.ts
Normal file
23
src/interfaces/Job.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface Job {
|
||||
id: string;
|
||||
triggered_by: string;
|
||||
completed_at: Date | null;
|
||||
created_at: Date;
|
||||
failed_reason: string | null;
|
||||
workload: Workload;
|
||||
status: "pending" | "succeeded" | "failed";
|
||||
}
|
||||
|
||||
export interface Workload {
|
||||
type: "bundle";
|
||||
parameters: BundleJobParameters;
|
||||
result: string | null;
|
||||
}
|
||||
|
||||
// Parameters for bundle job
|
||||
export interface BundleJobParameters {
|
||||
anonymize: boolean;
|
||||
bundle_for: boolean;
|
||||
bundle_for_time: number;
|
||||
log_file_count: number;
|
||||
}
|
||||
@@ -17,6 +17,51 @@ export interface User {
|
||||
idp_id?: string;
|
||||
}
|
||||
|
||||
export interface UserInviteCreateRequest {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
auto_groups: string[];
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export interface UserInvite {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
auto_groups: string[];
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
expired: boolean;
|
||||
invite_token?: string;
|
||||
}
|
||||
|
||||
export interface UserInviteInfo {
|
||||
email: string;
|
||||
name: string;
|
||||
expires_at: string;
|
||||
valid: boolean;
|
||||
invited_by: string;
|
||||
}
|
||||
|
||||
export interface UserInviteAcceptRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserInviteAcceptResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface UserInviteRegenerateRequest {
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export interface UserInviteRegenerateResponse {
|
||||
invite_token: string;
|
||||
invite_expires_at: string;
|
||||
}
|
||||
|
||||
export enum Role {
|
||||
User = "user",
|
||||
Admin = "admin",
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function AppLayout({
|
||||
<head>
|
||||
<GoogleTagManagerHeadScript />
|
||||
</head>
|
||||
<body className={cn(inter.className, "dark:bg-nb-gray bg-gray-50")}>
|
||||
<body className={cn(inter.className)}>
|
||||
<Suspense fallback={<FullScreenLoading />}>
|
||||
<AnalyticsProvider>
|
||||
<DialogProvider>
|
||||
|
||||
@@ -11,8 +11,9 @@ import React from "react";
|
||||
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import HelpAndSupportButton from "@components/ui/HelpAndSupportButton";
|
||||
|
||||
export const headerHeight = 75;
|
||||
export const headerHeight = 65;
|
||||
|
||||
export default function NavbarWithDropdown() {
|
||||
const router = useRouter();
|
||||
@@ -31,7 +32,7 @@ export default function NavbarWithDropdown() {
|
||||
<AnnouncementBanner />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"bg-white px-2 py-3 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
|
||||
"flex justify-between items-center transition-all",
|
||||
)}
|
||||
@@ -62,7 +63,8 @@ export default function NavbarWithDropdown() {
|
||||
<ToggleCollapsableNavigationButton />
|
||||
</div>
|
||||
|
||||
<div className="flex md:order-2 gap-4 items-center">
|
||||
<div className="flex md:order-2 gap-5 items-center">
|
||||
<HelpAndSupportButton />
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import SidebarItem from "@/components/SidebarItem";
|
||||
import { NavigationVersionInfo } from "@/components/VersionInfo";
|
||||
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -143,6 +144,12 @@ export default function Navigation({
|
||||
href={"/dns/nameservers"}
|
||||
visible={permission.nameservers.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Zones"
|
||||
isChild
|
||||
href={"/dns/zones"}
|
||||
visible={permission?.dns?.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="DNS Settings"
|
||||
isChild
|
||||
@@ -195,6 +202,7 @@ export default function Navigation({
|
||||
/>
|
||||
</SidebarItemGroup>
|
||||
</div>
|
||||
<NavigationVersionInfo />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -277,6 +277,50 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.password.change")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Password was changed for user <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* User Invite Link
|
||||
*/
|
||||
|
||||
if (event.activity_code == "user.invite.link.create")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Invite link was created for <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.invite.link.accept")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Invite link was accepted by <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.invite.link.regenerate")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Invite link was regenerated for <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.invite.link.delete")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Invite link was deleted for <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Service User
|
||||
*/
|
||||
@@ -693,6 +737,16 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Jobs
|
||||
*/
|
||||
|
||||
if (event.activity_code == "peer.job.create")
|
||||
return (<div className={"inline"}>
|
||||
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
|
||||
@@ -33,6 +33,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
|
||||
rename: ActionStatus.INFO,
|
||||
unblock: ActionStatus.INFO,
|
||||
login: ActionStatus.INFO,
|
||||
change: ActionStatus.INFO,
|
||||
};
|
||||
|
||||
export function getColorFromCode(code: string): string {
|
||||
|
||||
@@ -9,7 +9,7 @@ import GoogleLogo from "@/assets/nameservers/google.svg";
|
||||
import Quad9Logo from "@/assets/nameservers/quad9.svg";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
|
||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -19,14 +19,14 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||
import NameserverTemplateModal from "@/modules/dns-nameservers/NameserverTemplateModal";
|
||||
import NameserverActionCell from "@/modules/dns-nameservers/table/NameserverActionCell";
|
||||
import NameserverActiveCell from "@/modules/dns-nameservers/table/NameserverActiveCell";
|
||||
import NameserverDistributionGroupsCell from "@/modules/dns-nameservers/table/NameserverDistributionGroupsCell";
|
||||
import NameserverMatchDomainsCell from "@/modules/dns-nameservers/table/NameserverMatchDomainsCell";
|
||||
import NameserverNameCell from "@/modules/dns-nameservers/table/NameserverNameCell";
|
||||
import NameserverNameserversCell from "@/modules/dns-nameservers/table/NameserverNameserversCell";
|
||||
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
|
||||
import NameserverTemplateModal from "@/modules/dns/nameservers/NameserverTemplateModal";
|
||||
import NameserverActionCell from "@/modules/dns/nameservers/table/NameserverActionCell";
|
||||
import NameserverActiveCell from "@/modules/dns/nameservers/table/NameserverActiveCell";
|
||||
import NameserverDistributionGroupsCell from "@/modules/dns/nameservers/table/NameserverDistributionGroupsCell";
|
||||
import NameserverMatchDomainsCell from "@/modules/dns/nameservers/table/NameserverMatchDomainsCell";
|
||||
import NameserverNameCell from "@/modules/dns/nameservers/table/NameserverNameCell";
|
||||
import NameserverNameserversCell from "@/modules/dns/nameservers/table/NameserverNameserversCell";
|
||||
|
||||
export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
|
||||
{
|
||||
361
src/modules/dns/zones/DNSRecordModal.tsx
Normal file
361
src/modules/dns/zones/DNSRecordModal.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import Separator from "@components/Separator";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { Address4, Address6 } from "ip-address";
|
||||
import { ClockIcon, ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
DNS_RECORDS_DOCS_LINK,
|
||||
DNSRecord,
|
||||
DNSRecordType,
|
||||
DNSZone,
|
||||
} from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
zone: DNSZone;
|
||||
record?: DNSRecord;
|
||||
};
|
||||
|
||||
export default function DNSRecordModal({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
zone,
|
||||
record,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||
{open && (
|
||||
<DNSRecordModalContent
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
onSuccessAdded={() => {
|
||||
setTimeout(() => {
|
||||
const row = document.querySelector<HTMLElement>(
|
||||
`[data-row-id="${zone.id}"]`,
|
||||
);
|
||||
if (row?.getAttribute("data-accordion") === "closed") {
|
||||
row?.click();
|
||||
}
|
||||
row?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 200);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
zone={zone}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess?: () => void;
|
||||
onSuccessAdded?: () => void;
|
||||
zone: DNSZone;
|
||||
record?: DNSRecord;
|
||||
};
|
||||
|
||||
export function DNSRecordModalContent({
|
||||
onSuccess,
|
||||
onSuccessAdded,
|
||||
zone,
|
||||
record,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { addRecord, updateRecord } = useDNSZones();
|
||||
|
||||
const getInitialDomain = () => {
|
||||
if (!record) return "";
|
||||
if (record.name === zone.domain) return "";
|
||||
return record.name.replace(`.${zone.domain}`, "");
|
||||
};
|
||||
|
||||
const [domain, setDomain] = useState(record?.name ? getInitialDomain() : "");
|
||||
const [ttl, setTtl] = useState(record ? record.ttl.toString() : "300");
|
||||
const [type, setType] = useState<DNSRecordType>(record?.type ?? "A");
|
||||
const [recordValue, setRecordValue] = useState(record?.content ?? "");
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (domain == "") return "";
|
||||
if (domain === "*") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: true,
|
||||
allowOnlyTld: true,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
const ipv4Error = useMemo(() => {
|
||||
if (recordValue === "" || type !== "A") return "";
|
||||
const valid = Address4.isValid(recordValue);
|
||||
if (!valid) {
|
||||
return "Please enter a valid IPv4 address, e.g. 192.168.1.1";
|
||||
}
|
||||
}, [recordValue, type]);
|
||||
|
||||
const ipv6Error = useMemo(() => {
|
||||
if (recordValue === "" || type !== "AAAA") return "";
|
||||
const valid = Address6.isValid(recordValue);
|
||||
if (!valid) {
|
||||
return "Please enter a valid IPv6 address, e.g. 2001:0db8:85a3::8a2e:0370:7334";
|
||||
}
|
||||
}, [recordValue, type]);
|
||||
|
||||
const cnameError = useMemo(() => {
|
||||
if (recordValue === "" || type !== "CNAME") return "";
|
||||
const valid = validator.isValidDomain(recordValue, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or server.example.com";
|
||||
}
|
||||
}, [recordValue, type]);
|
||||
|
||||
const handleAddRecord = async () => {
|
||||
const name = domain !== "" ? `${domain}.${zone.domain}` : zone.domain;
|
||||
|
||||
if (record) {
|
||||
updateRecord(zone, {
|
||||
id: record.id,
|
||||
name,
|
||||
type,
|
||||
content: recordValue,
|
||||
ttl: parseInt(ttl),
|
||||
}).then(onSuccess);
|
||||
} else {
|
||||
addRecord(zone, {
|
||||
name,
|
||||
type,
|
||||
content: recordValue,
|
||||
ttl: parseInt(ttl),
|
||||
}).then(onSuccessAdded);
|
||||
}
|
||||
};
|
||||
|
||||
const canUpdateOrCreate =
|
||||
!cnameError &&
|
||||
!ipv6Error &&
|
||||
!ipv4Error &&
|
||||
!domainError &&
|
||||
recordValue !== "";
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
title={record ? "Update DNS Record" : "Add DNS Record"}
|
||||
description={
|
||||
record
|
||||
? `Update record of '${zone.domain}' zone`
|
||||
: `Add new record to the '${zone.domain}' zone`
|
||||
}
|
||||
icon={<GlobeIcon size={16} />}
|
||||
/>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6 flex flex-col gap-6"}>
|
||||
<div className={"flex items-center justify-between gap-10"}>
|
||||
<div>
|
||||
<Label>Record Type</Label>
|
||||
<HelpText className={"max-w-sm"}>
|
||||
Select the type of record you want to add
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"min-w-[130px]"}>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
setType(v as DNSRecordType);
|
||||
setRecordValue("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full pl-4"
|
||||
data-cy={"dns-record-type-select"}
|
||||
>
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="AAAA">AAAA</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"w-full mb-3"}>
|
||||
<Label>Hostname</Label>
|
||||
<HelpText>
|
||||
Enter a subdomain, wildcard or leave empty to use the primary
|
||||
domain.
|
||||
</HelpText>
|
||||
<div className={"flex w-full"}>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
placeholder={"E.g., dev, * or leave empty for primary domain"}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"bottom"}
|
||||
error={domainError}
|
||||
value={domain}
|
||||
className={"rounded-r-none"}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-900 rounded-r-md border text-nb-gray-300 border-l-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 opacity-80"
|
||||
}
|
||||
>
|
||||
.{zone.domain}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-4 items-start mb-3"}>
|
||||
{type === "A" && (
|
||||
<div className={"flex-1"}>
|
||||
<Label>IPv4 Address</Label>
|
||||
<Input
|
||||
className={"mt-1.5 font-mono text-[0.82rem]"}
|
||||
placeholder={"192.168.1.1"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={ipv4Error}
|
||||
value={recordValue}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setRecordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "AAAA" && (
|
||||
<div className={"flex-1"}>
|
||||
<Label>IPv6 Address</Label>
|
||||
<Input
|
||||
className={"mt-1.5 font-mono text-[0.82rem]"}
|
||||
placeholder={"2001:0db8:85a3::8a2e:0370:7334"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={ipv6Error}
|
||||
value={recordValue}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setRecordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "CNAME" && (
|
||||
<div className={"flex-1"}>
|
||||
<Label>Target Domain</Label>
|
||||
<Input
|
||||
className={"mt-1.5"}
|
||||
placeholder={"e.g., example.com or intra.example.com"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={cnameError}
|
||||
value={recordValue}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setRecordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={"min-w-[200px]"}>
|
||||
<Label>TTL (Time to Live)</Label>
|
||||
<div className={"mt-2.5"}>
|
||||
<Select value={ttl} onValueChange={(v) => setTtl(v)}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
data-cy={"dns-record-ttl-select"}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<ClockIcon size={14} className={"text-nb-gray-300"} />
|
||||
<SelectValue placeholder="Select TTL..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="60">{getTTLLabel(60)}</SelectItem>
|
||||
<SelectItem value="120">{getTTLLabel(120)}</SelectItem>
|
||||
<SelectItem value="300">{getTTLLabel(300)}</SelectItem>
|
||||
<SelectItem value="600">{getTTLLabel(600)}</SelectItem>
|
||||
<SelectItem value="900">{getTTLLabel(900)}</SelectItem>
|
||||
<SelectItem value="1800">{getTTLLabel(1800)}</SelectItem>
|
||||
<SelectItem value="3600">{getTTLLabel(3600)}</SelectItem>
|
||||
<SelectItem value="7200">{getTTLLabel(7200)}</SelectItem>
|
||||
<SelectItem value="43200">{getTTLLabel(43200)}</SelectItem>
|
||||
<SelectItem value="86400">{getTTLLabel(86400)}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_RECORDS_DOCS_LINK} target={"_blank"}>
|
||||
DNS Records
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={handleAddRecord}
|
||||
disabled={!canUpdateOrCreate}
|
||||
>
|
||||
{record ? "Save Changes" : "Add Record"}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export const getTTLLabel = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds} Sec.`;
|
||||
if (seconds < 3600) {
|
||||
const minutes = seconds / 60;
|
||||
return minutes === 1 ? "1 Min." : `${minutes} Min.`;
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
const hours = seconds / 3600;
|
||||
return hours === 1 ? "1 Hour" : `${hours} Hours`;
|
||||
}
|
||||
const days = seconds / 86400;
|
||||
return days === 1 ? "1 Day" : `${days} Days`;
|
||||
};
|
||||
225
src/modules/dns/zones/DNSZoneModal.tsx
Normal file
225
src/modules/dns/zones/DNSZoneModal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import Button from "@components/Button";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, Power, ScanSearch } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (zone: DNSZone) => void;
|
||||
onSuccessAdded?: (zone: DNSZone) => void;
|
||||
initialDistributionGroups?: Group[];
|
||||
zone?: DNSZone;
|
||||
};
|
||||
|
||||
export default function DNSZoneModal({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
onSuccessAdded,
|
||||
initialDistributionGroups,
|
||||
zone,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||
{open && (
|
||||
<DNSZoneModalContent
|
||||
onSuccess={(z) => {
|
||||
onOpenChange(false);
|
||||
onSuccess?.(z);
|
||||
}}
|
||||
onSuccessAdded={(z) => {
|
||||
onOpenChange(false);
|
||||
onSuccessAdded?.(z);
|
||||
}}
|
||||
zone={zone}
|
||||
initialDistributionGroups={initialDistributionGroups}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess?: (zone: DNSZone) => void;
|
||||
onSuccessAdded?: (zone: DNSZone) => void;
|
||||
initialDistributionGroups?: Group[];
|
||||
zone?: DNSZone;
|
||||
};
|
||||
|
||||
export function DNSZoneModalContent({
|
||||
onSuccess,
|
||||
onSuccessAdded,
|
||||
zone,
|
||||
initialDistributionGroups,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { createZone, updateZone } = useDNSZones();
|
||||
const [domain, setDomain] = useState(zone?.domain ?? "");
|
||||
const [enabled, setEnabled] = useState<boolean>(zone?.enabled ?? true);
|
||||
const [searchDomainsEnabled, setSearchDomainsEnabled] = useState(
|
||||
zone?.enable_search_domain ?? false,
|
||||
);
|
||||
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
|
||||
initial: initialDistributionGroups ?? zone?.distribution_groups ?? [],
|
||||
});
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (domain == "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
const handleOnSubmit = async () => {
|
||||
return saveGroups().then((distributionGroups) => {
|
||||
const groupIds = distributionGroups.map((group) => group.id as string);
|
||||
|
||||
if (zone) {
|
||||
updateZone({
|
||||
id: zone.id,
|
||||
domain,
|
||||
name: domain,
|
||||
distribution_groups: groupIds,
|
||||
enabled,
|
||||
enable_search_domain: searchDomainsEnabled,
|
||||
} as DNSZone).then(onSuccess);
|
||||
} else {
|
||||
createZone({
|
||||
domain,
|
||||
name: domain,
|
||||
distribution_groups: groupIds,
|
||||
enabled,
|
||||
enable_search_domain: searchDomainsEnabled,
|
||||
} as DNSZone).then(onSuccessAdded);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const canUpdateOrCreate = !domainError && groups?.length > 0 && domain !== "";
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-2xl"}>
|
||||
<ModalHeader
|
||||
icon={<DNSZoneIcon size={20} className={"fill-netbird"} />}
|
||||
title={zone ? "Update DNS Zone" : "Add DNS Zone"}
|
||||
description={
|
||||
"Use a zone to control domain name resolution for your network."
|
||||
}
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 pt-6 pb-7 flex-col flex gap-6"}>
|
||||
<div>
|
||||
<Label>Domain</Label>
|
||||
<HelpText>
|
||||
Enter a domain for this zone (e.g., company.internal,
|
||||
intra.example.com)
|
||||
</HelpText>
|
||||
<Input
|
||||
disabled={!!zone}
|
||||
readOnly={!!zone}
|
||||
placeholder={"e.g., company.internal"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={domainError}
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={"mb-2"}>
|
||||
<Label>Distribution Groups</Label>
|
||||
<HelpText>
|
||||
Advertise this zone and its records to peers that belong to the
|
||||
following groups
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
onChange={setGroups}
|
||||
values={groups}
|
||||
showResources={false}
|
||||
showResourceCounter={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={searchDomainsEnabled}
|
||||
onChange={setSearchDomainsEnabled}
|
||||
label={
|
||||
<>
|
||||
<ScanSearch size={15} />
|
||||
Enable Search Domains
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"E.g., 'server.company.internal' will be accessible with 'server'"
|
||||
}
|
||||
/>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable DNS Zone
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the dns zone."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={handleOnSubmit}
|
||||
disabled={!canUpdateOrCreate}
|
||||
>
|
||||
{zone ? "Save Changes" : "Add Zone"}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
264
src/modules/dns/zones/DNSZonesProvider.tsx
Normal file
264
src/modules/dns/zones/DNSZonesProvider.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { DNSRecord, DNSZone } from "@/interfaces/DNS";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import DNSRecordModal from "@/modules/dns/zones/DNSRecordModal";
|
||||
import DNSZoneModal from "@/modules/dns/zones/DNSZoneModal";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DNSZonesContext = React.createContext(
|
||||
{} as {
|
||||
createZone: (zone: DNSZone) => Promise<DNSZone>;
|
||||
updateZone: (zone: DNSZone) => Promise<DNSZone>;
|
||||
deleteZone: (zone: DNSZone) => Promise<DNSZone>;
|
||||
openZoneModal: (
|
||||
zone?: DNSZone,
|
||||
initialDistributionGroups?: Group[],
|
||||
) => void;
|
||||
openRecordModal: (zone: DNSZone, record?: DNSRecord) => void;
|
||||
addRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
|
||||
updateRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
|
||||
deleteRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
|
||||
askForRecord: (zone: DNSZone) => void;
|
||||
},
|
||||
);
|
||||
|
||||
export const DNSZonesProvider = ({ children }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const zoneRequest = useApiCall<DNSZone>("/dns/zones", true);
|
||||
const recordRequest = useApiCall<DNSRecord>("/dns/zones", true);
|
||||
const [dnsModal, setDnsModal] = useState(false);
|
||||
const [recordModal, setRecordModal] = useState(false);
|
||||
const [currentZone, setCurrentZone] = useState<DNSZone>();
|
||||
const [currentRecord, setCurrentRecord] = useState<DNSRecord>();
|
||||
const [initialDistributionGroups, setInitialDistributionGroups] =
|
||||
useState<Group[]>();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const createZone = async (zone: DNSZone): Promise<DNSZone> => {
|
||||
const promise = zoneRequest.post(zone).then((zone) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(zone);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `DNS Zone '${zone.domain}'`,
|
||||
description: `DNS Zone was added successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Adding DNS Zone...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const updateZone = async (zone: DNSZone): Promise<DNSZone> => {
|
||||
if (!zone?.id) return Promise.reject("Can not update DNS Zone without ID");
|
||||
const promise = zoneRequest.put(zone, `/${zone.id}`).then((zone) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(zone);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `DNS Zone '${zone.domain}'`,
|
||||
description: `DNS Zone was updated successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Updating DNS Zone...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteZone = async (zone: DNSZone): Promise<DNSZone> => {
|
||||
if (!zone?.id) return Promise.reject("Can not delete DNS Zone without ID");
|
||||
|
||||
const choice = await confirm({
|
||||
title: `Delete zone '${zone.domain}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this zone? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return Promise.resolve(zone);
|
||||
|
||||
const promise = zoneRequest.del({}, `/${zone.id}`).then((zone) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(zone);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `DNS Zone '${zone.domain}'`,
|
||||
description: `DNS Zone was deleted successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Deleting DNS Zone...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addRecord = async (
|
||||
zone: DNSZone,
|
||||
record: DNSRecord,
|
||||
): Promise<DNSRecord> => {
|
||||
if (!zone?.id)
|
||||
return Promise.reject("Can not add DNS Record without DNS Zone");
|
||||
const promise = recordRequest
|
||||
.post(record, `/${zone.id}/records`)
|
||||
.then((record) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(record);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `${record.type} Record '${record.name}'`,
|
||||
description: `DNS Record was added successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Adding DNS Record...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const updateRecord = async (
|
||||
zone: DNSZone,
|
||||
record: DNSRecord,
|
||||
): Promise<DNSRecord> => {
|
||||
if (!zone?.id)
|
||||
return Promise.reject("Can not update DNS Record without DNS Zone");
|
||||
if (!record?.id)
|
||||
return Promise.reject("Can not update DNS Record without ID");
|
||||
const promise = recordRequest
|
||||
.put(record, `/${zone.id}/records/${record.id}`)
|
||||
.then((record) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(record);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `${record.type} Record '${record.name}'`,
|
||||
description: `DNS Record was updated successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Updating DNS Record...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteRecord = async (
|
||||
zone: DNSZone,
|
||||
record: DNSRecord,
|
||||
): Promise<DNSRecord> => {
|
||||
if (!zone?.id)
|
||||
return Promise.reject("Can not delete DNS Record without DNS Zone");
|
||||
if (!record?.id)
|
||||
return Promise.reject("Can not delete DNS Record without ID");
|
||||
|
||||
const choice = await confirm({
|
||||
title: `Delete record '${record.name}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this record? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return Promise.resolve(record);
|
||||
|
||||
const promise = recordRequest
|
||||
.del({}, `/${zone.id}/records/${record.id}`)
|
||||
.then((record) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(record);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `${record.type} Record '${record.name}'`,
|
||||
description: `DNS Record was deleted successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Deleting DNS Record...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openZoneModal = (zone?: DNSZone, distributionGroups?: Group[]) => {
|
||||
if (zone) setCurrentZone(zone);
|
||||
if (distributionGroups) setInitialDistributionGroups(distributionGroups);
|
||||
setDnsModal(true);
|
||||
};
|
||||
|
||||
const openRecordModal = (zone: DNSZone, record?: DNSRecord) => {
|
||||
setCurrentZone(zone);
|
||||
if (record) setCurrentRecord(record);
|
||||
setRecordModal(true);
|
||||
};
|
||||
|
||||
const askForRecord = async (zone: DNSZone) => {
|
||||
const choice = await confirm({
|
||||
title: `Add new record to '${zone.name}'?`,
|
||||
description:
|
||||
"Add either an A, AAAA or a CNAME record to control domain name resolution for your network.",
|
||||
confirmText: "Add Record",
|
||||
cancelText: "Later",
|
||||
type: "default",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return;
|
||||
openRecordModal(zone);
|
||||
};
|
||||
|
||||
return (
|
||||
<DNSZonesContext.Provider
|
||||
value={{
|
||||
createZone,
|
||||
updateZone,
|
||||
deleteZone,
|
||||
openZoneModal,
|
||||
openRecordModal,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
deleteRecord,
|
||||
askForRecord,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<DNSZoneModal
|
||||
open={dnsModal}
|
||||
onOpenChange={(open) => {
|
||||
setDnsModal(open);
|
||||
if (!open) {
|
||||
setCurrentZone(undefined);
|
||||
setInitialDistributionGroups(undefined);
|
||||
}
|
||||
}}
|
||||
onSuccessAdded={(z) => askForRecord(z)}
|
||||
zone={currentZone}
|
||||
initialDistributionGroups={initialDistributionGroups}
|
||||
/>
|
||||
{currentZone && (
|
||||
<DNSRecordModal
|
||||
open={recordModal}
|
||||
onOpenChange={(open) => {
|
||||
setRecordModal(open);
|
||||
if (!open) {
|
||||
setCurrentZone(undefined);
|
||||
setCurrentRecord(undefined);
|
||||
}
|
||||
}}
|
||||
zone={currentZone}
|
||||
record={currentRecord}
|
||||
/>
|
||||
)}
|
||||
</DNSZonesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDNSZones = () => React.useContext(DNSZonesContext);
|
||||
40
src/modules/dns/zones/records/DNSRecordActionCell.tsx
Normal file
40
src/modules/dns/zones/records/DNSRecordActionCell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Button from "@components/Button";
|
||||
import { PenSquare, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import { useDNSZone } from "@/modules/dns/zones/records/DNSRecordsTable";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordActionCell = ({ record }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { deleteRecord, openRecordModal } = useDNSZones();
|
||||
const zone = useDNSZone();
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => openRecordModal(zone, record)}
|
||||
disabled={!permission?.dns?.update}
|
||||
>
|
||||
<PenSquare size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => deleteRecord(zone, record)}
|
||||
disabled={!permission?.dns?.delete}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
src/modules/dns/zones/records/DNSRecordContentCell.tsx
Normal file
19
src/modules/dns/zones/records/DNSRecordContentCell.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordContentCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate font-mono">
|
||||
<CopyToClipboardText>
|
||||
<span className={"font-normal truncate text-[0.82rem]"}>
|
||||
{record.content}
|
||||
</span>
|
||||
</CopyToClipboardText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
src/modules/dns/zones/records/DNSRecordNameCell.tsx
Normal file
17
src/modules/dns/zones/records/DNSRecordNameCell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordNameCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
|
||||
<CopyToClipboardText>
|
||||
<span className={"font-normal truncate"}>{record.name}</span>
|
||||
</CopyToClipboardText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx
Normal file
21
src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
import { getTTLLabel } from "@/modules/dns/zones/DNSRecordModal";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordTimeToLiveCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all py-2 px-3 rounded-md"
|
||||
}
|
||||
>
|
||||
<ClockIcon size={14} />
|
||||
{getTTLLabel(record.ttl)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/modules/dns/zones/records/DNSRecordTypeCell.tsx
Normal file
20
src/modules/dns/zones/records/DNSRecordTypeCell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Badge from "@components/Badge";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordTypeCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium"}
|
||||
>
|
||||
{record.type}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
80
src/modules/dns/zones/records/DNSRecordsTable.tsx
Normal file
80
src/modules/dns/zones/records/DNSRecordsTable.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { DNSRecord, DNSZone } from "@/interfaces/DNS";
|
||||
import { DNSRecordActionCell } from "@/modules/dns/zones/records/DNSRecordActionCell";
|
||||
import { DNSRecordContentCell } from "@/modules/dns/zones/records/DNSRecordContentCell";
|
||||
import { DNSRecordNameCell } from "@/modules/dns/zones/records/DNSRecordNameCell";
|
||||
import { DNSRecordTimeToLiveCell } from "@/modules/dns/zones/records/DNSRecordTimeToLiveCell";
|
||||
import { DNSRecordTypeCell } from "@/modules/dns/zones/records/DNSRecordTypeCell";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSRecordsTableColumns: ColumnDef<DNSRecord>[] = [
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Type</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordTypeCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Hostname</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordNameCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Content</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordContentCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "ttl",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>TTL</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordTimeToLiveCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => <DNSRecordActionCell record={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const ZoneContext = createContext({} as DNSZone);
|
||||
|
||||
export default function DNSRecordsTable({ zone }: Props) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
return (
|
||||
<ZoneContext.Provider value={zone}>
|
||||
<DataTable
|
||||
uniqueKey={zone.id}
|
||||
keepStateInLocalStorage={false}
|
||||
tableClassName={"mt-0"}
|
||||
minimal={true}
|
||||
showSearchAndFilters={false}
|
||||
rowClassName={"last:pb-10"}
|
||||
className={"bg-nb-gray-960 py-2"}
|
||||
inset={true}
|
||||
text={"DNS Records"}
|
||||
manualPagination={true}
|
||||
sorting={sorting}
|
||||
columnVisibility={{}}
|
||||
setSorting={setSorting}
|
||||
columns={DNSRecordsTableColumns}
|
||||
data={zone.records}
|
||||
/>
|
||||
</ZoneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDNSZone = () => useContext(ZoneContext);
|
||||
58
src/modules/dns/zones/table/DNSZonesActionCell.tsx
Normal file
58
src/modules/dns/zones/table/DNSZonesActionCell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesActionCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openZoneModal, deleteZone } = useDNSZones();
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button variant={"secondary"} className={"!px-3"}>
|
||||
<MoreVertical size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto" align="end">
|
||||
<DropdownMenuItem onClick={() => openZoneModal(zone)}>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SquarePenIcon size={14} className={"shrink-0"} />
|
||||
Edit
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteZone(zone)}
|
||||
variant={"danger"}
|
||||
disabled={!permission?.dns?.delete}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Delete
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
src/modules/dns/zones/table/DNSZonesActiveCell.tsx
Normal file
32
src/modules/dns/zones/table/DNSZonesActiveCell.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesActiveCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { updateZone } = useDNSZones();
|
||||
|
||||
return (
|
||||
<div className={"flex min-w-[0px]"}>
|
||||
<ToggleSwitch
|
||||
disabled={!permission?.dns?.update}
|
||||
checked={zone.enabled}
|
||||
size={"small"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateZone({
|
||||
...zone,
|
||||
enabled: !zone.enabled,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/modules/dns/zones/table/DNSZonesGroupCell.tsx
Normal file
60
src/modules/dns/zones/table/DNSZonesGroupCell.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import GroupsRow from "@/modules/common-table-rows/GroupsRow";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesGroupCell = ({ zone }: Props) => {
|
||||
const { groups } = useGroups();
|
||||
const { updateZone } = useDNSZones();
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const allGroups = zone?.distribution_groups
|
||||
.map((group) => {
|
||||
return groups?.find((g) => g.id == group);
|
||||
})
|
||||
.filter((g) => g != undefined) as Group[];
|
||||
|
||||
const groupIDs = useMemo(() => {
|
||||
return allGroups
|
||||
?.map((group) => group.id)
|
||||
.filter((id) => id !== undefined) as string[];
|
||||
}, [allGroups]);
|
||||
|
||||
const handleSave = async (promises: Promise<Group>[]) => {
|
||||
const groups = await Promise.all(promises);
|
||||
const groupIds = groups?.map((g) => g.id as string);
|
||||
await updateZone({
|
||||
...zone,
|
||||
distribution_groups: groupIds,
|
||||
}).then(() => {
|
||||
setModal(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!zone?.distribution_groups) return <EmptyRow />;
|
||||
|
||||
return (
|
||||
<GroupsRow
|
||||
label={"Distribution Groups"}
|
||||
description={
|
||||
"Advertise this zone to peers that belong to the following groups"
|
||||
}
|
||||
groups={groupIDs || []}
|
||||
hideAllGroup={false}
|
||||
disabled={!permission?.dns?.update}
|
||||
onSave={handleSave}
|
||||
modal={modal}
|
||||
setModal={setModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
38
src/modules/dns/zones/table/DNSZonesNameCell.tsx
Normal file
38
src/modules/dns/zones/table/DNSZonesNameCell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ChevronDown, ChevronRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesNameCell = ({ zone }: Props) => {
|
||||
const hasRecords = (zone?.records?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className={"flex gap-6 items-center min-w-[270px] max-w-[270px]"}>
|
||||
<ChevronRightIcon
|
||||
size={20}
|
||||
className={cn(
|
||||
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||
!hasRecords && "cursor-default opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||
!hasRecords && "cursor-default opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ActiveInactiveRow
|
||||
active={zone.enabled}
|
||||
inactiveDot={"gray"}
|
||||
text={zone.domain}
|
||||
dataCy={zone.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
src/modules/dns/zones/table/DNSZonesRecordsCell.tsx
Normal file
47
src/modules/dns/zones/table/DNSZonesRecordsCell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import { GlobeIcon, PlusCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesRecordsCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openRecordModal } = useDNSZones();
|
||||
|
||||
const recordsCount = zone?.records?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className={"flex gap-3"}>
|
||||
{recordsCount > 0 && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={true}
|
||||
className={"cursor-pointer"}
|
||||
onClick={() => void 0}
|
||||
>
|
||||
<GlobeIcon size={12} />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>{recordsCount}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"min-w-[130px]"}
|
||||
onClick={() => openRecordModal(zone)}
|
||||
disabled={!permission?.dns?.create}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add Record
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx
Normal file
32
src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesSearchDomainCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { updateZone } = useDNSZones();
|
||||
|
||||
return (
|
||||
<div className={"flex min-w-[0px]"}>
|
||||
<ToggleSwitch
|
||||
disabled={!permission?.dns?.update}
|
||||
checked={zone?.enable_search_domain}
|
||||
size={"small"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateZone({
|
||||
...zone,
|
||||
enable_search_domain: !zone.enable_search_domain,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
303
src/modules/dns/zones/table/DNSZonesTable.tsx
Normal file
303
src/modules/dns/zones/table/DNSZonesTable.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import Card from "@components/Card";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSRecordsTable from "@/modules/dns/zones/records/DNSRecordsTable";
|
||||
import { DNSZonesActionCell } from "@/modules/dns/zones/table/DNSZonesActionCell";
|
||||
import { DNSZonesActiveCell } from "@/modules/dns/zones/table/DNSZonesActiveCell";
|
||||
import { DNSZonesGroupCell } from "@/modules/dns/zones/table/DNSZonesGroupCell";
|
||||
import { DNSZonesNameCell } from "@/modules/dns/zones/table/DNSZonesNameCell";
|
||||
import { DNSZonesRecordsCell } from "@/modules/dns/zones/table/DNSZonesRecordsCell";
|
||||
import { DNSZonesSearchDomainCell } from "@/modules/dns/zones/table/DNSZonesSearchDomainCell";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
|
||||
export const DNSZonesColumns: ColumnDef<DNSZone>[] = [
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Zone</DataTableHeader>
|
||||
),
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <DNSZonesNameCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Active</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <DNSZonesActiveCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "records",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Records</DataTableHeader>
|
||||
),
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <DNSZonesRecordsCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "distribution_groups",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Distribution Groups</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <DNSZonesGroupCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "enable_search_domain",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Search Domain</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <DNSZonesSearchDomainCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => "",
|
||||
cell: ({ row }) => <DNSZonesActionCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "searchString",
|
||||
accessorFn: (row) => {
|
||||
return [
|
||||
row?.groups_search,
|
||||
row?.name,
|
||||
row?.domain,
|
||||
row?.records?.map((r) => r.name).join(""),
|
||||
row?.records?.map((r) => r.content).join(""),
|
||||
row?.records?.map((r) => r.type).join(""),
|
||||
]?.join("");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean;
|
||||
data?: DNSZone[];
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
isGroupPage?: boolean;
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
|
||||
export default function DNSZonesTable({
|
||||
data,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
isGroupPage = false,
|
||||
distributionGroups,
|
||||
}: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
const { groups } = useGroups();
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "domain",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
!isGroupPage,
|
||||
);
|
||||
|
||||
const zonesWithGroups = useMemo(() => {
|
||||
return (
|
||||
data?.map((zone) => {
|
||||
return {
|
||||
...zone,
|
||||
groups_search: groups
|
||||
?.map((g) =>
|
||||
zone?.distribution_groups?.includes(g?.id ?? "") ? g.name : "",
|
||||
)
|
||||
.join(""),
|
||||
} as DNSZone;
|
||||
}) ?? []
|
||||
);
|
||||
}, [data, groups]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"DNS Zones"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={DNSZonesColumns}
|
||||
data={zonesWithGroups}
|
||||
useRowId={true}
|
||||
wrapperComponent={isGroupPage ? Card : undefined}
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
|
||||
inset={false}
|
||||
minimal={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
searchPlaceholder={"Search by domain, ip, content or group..."}
|
||||
columnVisibility={{ searchString: false }}
|
||||
renderExpandedRow={(zone) => {
|
||||
const hasRecords = (zone?.records?.length ?? 0) > 0;
|
||||
if (!hasRecords) return;
|
||||
return (
|
||||
<>
|
||||
<DNSRecordsTable zone={zone} />
|
||||
<div className={"h-2 w-full bg-nb-gray-960"}></div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
getStartedCard={
|
||||
isGroupPage ? (
|
||||
<NoResults
|
||||
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
|
||||
className={"py-4"}
|
||||
contentClassName={"max-w-lg"}
|
||||
title={"This group is not used within any zones yet"}
|
||||
description={
|
||||
"Assign this group as a distribution group in your zones to see them listed here."
|
||||
}
|
||||
>
|
||||
<div className={"gap-x-4 flex items-center justify-center mt-4"}>
|
||||
<AddZoneButton distributionGroups={distributionGroups} />
|
||||
</div>
|
||||
</NoResults>
|
||||
) : (
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Zone"}
|
||||
description={
|
||||
"It looks like you don't have any zones. Control domain name resolution for your network by adding a zone."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<AddZoneButton distributionGroups={distributionGroups} />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
{data && data?.length > 0 && (
|
||||
<div className={"gap-x-4 ml-auto flex"}>
|
||||
<AddZoneButton distributionGroups={distributionGroups} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={data?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
disabled={data?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
}}
|
||||
disabled={data?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Active
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(false);
|
||||
}}
|
||||
disabled={data?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Inactive
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage table={table} disabled={data?.length == 0} />
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/dns/zones").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
type AddZoneButtonProps = {
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
|
||||
const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openZoneModal } = useDNSZones();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
disabled={!permission?.dns?.create}
|
||||
onClick={() => openZoneModal(undefined, distributionGroups)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Zone
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
29
src/modules/groups/details/GroupDNSZonesSection.tsx
Normal file
29
src/modules/groups/details/GroupDNSZonesSection.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSZonesTable from "@/modules/dns/zones/table/DNSZonesTable";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
export const GroupDNSZonesSection = ({
|
||||
zones,
|
||||
isLoading = true,
|
||||
}: {
|
||||
zones?: DNSZone[];
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isGroupPage={true}
|
||||
isLoading={isLoading}
|
||||
data={zones}
|
||||
distributionGroups={[group]}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import useFetchApi from "@/utils/api";
|
||||
export interface GroupDetails extends Group {
|
||||
policies: Policy[];
|
||||
nameservers: NameserverGroup[];
|
||||
zones?: DNSZone[];
|
||||
routes: Route[];
|
||||
setupKeys: SetupKey[];
|
||||
users: User[];
|
||||
@@ -31,6 +33,8 @@ export default function useGroupDetails(groupId: string) {
|
||||
useFetchApi<Policy[]>(`/policies`);
|
||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||
useFetchApi<NameserverGroup[]>(`/dns/nameservers`);
|
||||
const { data: zones, isLoading: isZonesLoading } =
|
||||
useFetchApi<DNSZone[]>(`/dns/zones`);
|
||||
const { data: routes, isLoading: isRoutesLoading } =
|
||||
useFetchApi<Route[]>(`/routes`);
|
||||
const { data: setupKeys, isLoading: isSetupKeysLoading } =
|
||||
@@ -65,6 +69,12 @@ export default function useGroupDetails(groupId: string) {
|
||||
return nameservers?.filter((ns) => ns.groups?.includes(groupId)) || [];
|
||||
}, [nameservers, groupId]);
|
||||
|
||||
const linkedZones = useMemo(() => {
|
||||
return (
|
||||
zones?.filter((ns) => ns.distribution_groups?.includes(groupId)) || []
|
||||
);
|
||||
}, [zones, groupId]);
|
||||
|
||||
const linkedRoutes = useMemo(() => {
|
||||
return (
|
||||
routes?.filter((route) => {
|
||||
@@ -117,6 +127,7 @@ export default function useGroupDetails(groupId: string) {
|
||||
isGroupsLoading ||
|
||||
isPoliciesLoading ||
|
||||
isNameserversLoading ||
|
||||
isZonesLoading ||
|
||||
isRoutesLoading ||
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading ||
|
||||
@@ -131,6 +142,7 @@ export default function useGroupDetails(groupId: string) {
|
||||
...group,
|
||||
policies: linkedPolicies,
|
||||
nameservers: linkedNameservers,
|
||||
zones: linkedZones,
|
||||
routes: linkedRoutes,
|
||||
setupKeys: linkedSetupKeys,
|
||||
users: linkedUsers,
|
||||
@@ -142,6 +154,7 @@ export default function useGroupDetails(groupId: string) {
|
||||
group,
|
||||
linkedPolicies,
|
||||
linkedNameservers,
|
||||
linkedZones,
|
||||
linkedRoutes,
|
||||
linkedSetupKeys,
|
||||
linkedUsers,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
@@ -19,7 +20,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell";
|
||||
import GroupsCountCell from "@/modules/groups/table/GroupsCountCell";
|
||||
import GroupsNameCell from "@/modules/groups/table/GroupsNameCell";
|
||||
import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
{
|
||||
@@ -178,6 +179,28 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "zones_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-xs normal-case"}>Zones</div>}
|
||||
>
|
||||
<DNSZoneIcon size={16} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<DNSZoneIcon size={14} />}
|
||||
groupName={row.original.name}
|
||||
href={`/group?id=${row.original.id}&tab=zones`}
|
||||
text={"Zone(s)"}
|
||||
count={row.original.zones_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "setup_keys_count",
|
||||
header: ({ column }) => {
|
||||
@@ -216,7 +239,8 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.routes_count > 0 ||
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0
|
||||
row.resources_count > 0 ||
|
||||
row.zones_count
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { useMemo } from "react";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
@@ -11,6 +12,7 @@ export interface GroupUsage extends Group {
|
||||
peers_count: number;
|
||||
policies_count: number;
|
||||
nameservers_count: number;
|
||||
zones_count: number;
|
||||
routes_count: number;
|
||||
setup_keys_count: number;
|
||||
users_count: number;
|
||||
@@ -24,6 +26,8 @@ export default function useGroupsUsage() {
|
||||
useFetchApi<Policy[]>(`/policies`); // Policies
|
||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||
useFetchApi<NameserverGroup[]>(`/dns/nameservers`); // DNS
|
||||
const { data: zones, isLoading: isZonesLoading } =
|
||||
useFetchApi<DNSZone[]>(`/dns/zones`); // DNS Zones
|
||||
const { data: routes, isLoading: isRoutesLoading } =
|
||||
useFetchApi<Route[]>(`/routes`); // Routes
|
||||
const { data: setupKeys, isLoading: isSetupKeysLoading } =
|
||||
@@ -57,6 +61,14 @@ export default function useGroupsUsage() {
|
||||
.filter((u) => u !== undefined);
|
||||
}, [nameservers, isNameserversLoading]);
|
||||
|
||||
const zonesGroups = useMemo(() => {
|
||||
if (isZonesLoading) return;
|
||||
if (!zones) return [];
|
||||
return zones
|
||||
?.map((zone) => zone.distribution_groups)
|
||||
.filter((u) => u !== undefined);
|
||||
}, [zones, isZonesLoading]);
|
||||
|
||||
const setupKeysGroups = useMemo(() => {
|
||||
if (isSetupKeysLoading) return;
|
||||
if (!setupKeys) return [];
|
||||
@@ -78,6 +90,7 @@ export default function useGroupsUsage() {
|
||||
isGroupsLoading ||
|
||||
isPoliciesLoading ||
|
||||
isNameserversLoading ||
|
||||
isZonesLoading ||
|
||||
isRoutesLoading ||
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading
|
||||
@@ -86,6 +99,7 @@ export default function useGroupsUsage() {
|
||||
isGroupsLoading,
|
||||
isPoliciesLoading,
|
||||
isNameserversLoading,
|
||||
isZonesLoading,
|
||||
isRoutesLoading,
|
||||
isSetupKeysLoading,
|
||||
isUsersLoading,
|
||||
@@ -104,6 +118,10 @@ export default function useGroupsUsage() {
|
||||
return nameserver.includes(group.id as string);
|
||||
}).length;
|
||||
|
||||
const zonesCount = zonesGroups?.filter((zone) => {
|
||||
return zone.includes(group.id as string);
|
||||
}).length;
|
||||
|
||||
const routeCount = (
|
||||
routes?.filter((route) => {
|
||||
const groupId = group.id as string;
|
||||
@@ -133,6 +151,7 @@ export default function useGroupsUsage() {
|
||||
resources_count: group.resources_count,
|
||||
policies_count: policyCount,
|
||||
nameservers_count: nameserverCount,
|
||||
zones_count: zonesCount,
|
||||
routes_count: routeCount,
|
||||
setup_keys_count: setupKeyCount,
|
||||
users_count: userCount,
|
||||
@@ -143,6 +162,7 @@ export default function useGroupsUsage() {
|
||||
groups,
|
||||
policiesGroups,
|
||||
nameserversGroups,
|
||||
zonesGroups,
|
||||
routes,
|
||||
isRoutesLoading,
|
||||
setupKeysGroups,
|
||||
|
||||
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal file
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
AlarmClock,
|
||||
BugPlay,
|
||||
FileText,
|
||||
PlusCircle,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import Button from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import HelpText from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import {
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@/components/modal/Modal";
|
||||
import ModalHeader from "@/components/modal/ModalHeader";
|
||||
import { notify } from "@/components/Notification";
|
||||
import Separator from "@/components/Separator";
|
||||
import { Workload } from "@/interfaces/Job";
|
||||
import { useApiCall } from "@/utils/api";
|
||||
|
||||
type Props = {
|
||||
peerID: string;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
|
||||
const jobRequest = useApiCall<Workload>(`/peers/${peerID}/jobs`, true);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [bundleForTimeEnabled, setBundleForTimeEnabled] = useState(false);
|
||||
const [bundleForTime, setBundleForTime] = useState<string>("");
|
||||
const [logFileCount, setLogFileCount] = useState<string>("10");
|
||||
const [anonymize, setAnonymize] = useState<boolean>(false);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
let validBundleFor = true;
|
||||
let validLogFileCount = true;
|
||||
|
||||
const logFileCountNumber = Number(logFileCount);
|
||||
const bundleForTimeNumber = Number(bundleForTime);
|
||||
|
||||
if (bundleForTime) {
|
||||
validBundleFor = bundleForTimeNumber >= 1 && bundleForTimeNumber <= 5;
|
||||
}
|
||||
|
||||
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
|
||||
|
||||
return validLogFileCount && validBundleFor;
|
||||
}, [bundleForTime, logFileCount]);
|
||||
|
||||
const createDebugJob = async () => {
|
||||
notify({
|
||||
title: "Create Debug Job",
|
||||
description: "Debug job triggered successfully.",
|
||||
loadingMessage: "Creating job...",
|
||||
promise: jobRequest
|
||||
.post({
|
||||
workload: {
|
||||
type: "bundle",
|
||||
parameters: {
|
||||
anonymize,
|
||||
bundle_for: bundleForTimeEnabled,
|
||||
bundle_for_time: bundleForTimeEnabled
|
||||
? Number(bundleForTime)
|
||||
: undefined,
|
||||
log_file_count: logFileCount ? Number(logFileCount) : 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((job) => {
|
||||
mutate(`/peers/${peerID}/jobs`);
|
||||
onSuccess();
|
||||
return job;
|
||||
}),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<ModalContent maxWidthClass="max-w-xl">
|
||||
<ModalHeader
|
||||
icon={<BugPlay size={20} />}
|
||||
title="Debug Bundle"
|
||||
description="Generate a debug bundle on this peer with logs and diagnostics. Useful for troubleshooting without CLI access."
|
||||
color="netbird"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<div className={"px-8 py-6 flex flex-col gap-4"}>
|
||||
{/* Log File Count */}
|
||||
<div className="flex justify-between gap-6">
|
||||
<div className={"max-w-[300px]"}>
|
||||
<Label>Log File Count</Label>
|
||||
<HelpText>
|
||||
Sets the limit for how many individual log files will be included
|
||||
in the debug bundle.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={"10"}
|
||||
max={50}
|
||||
value={logFileCount}
|
||||
onChange={(e) => setLogFileCount(e.target.value)}
|
||||
maxWidthClass="w-[220px]"
|
||||
customPrefix={<FileText size={16} className="text-nb-gray-300" />}
|
||||
customSuffix="File(s)"
|
||||
/>
|
||||
</div>
|
||||
{/* Bundle Duration */}
|
||||
<div>
|
||||
<FancyToggleSwitch
|
||||
value={bundleForTimeEnabled}
|
||||
onChange={(enabled) => {
|
||||
setBundleForTimeEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setBundleForTime("");
|
||||
} else {
|
||||
setBundleForTime("2");
|
||||
}
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
<AlarmClock size={15} />
|
||||
Enable Bundle Duration
|
||||
</>
|
||||
}
|
||||
helpText="When enabled, allows you to specify a time period for log collection before generating the debug bundle."
|
||||
/>
|
||||
|
||||
{bundleForTimeEnabled && (
|
||||
<div className="flex justify-between gap-6 mt-6 mb-3">
|
||||
<div className={"max-w-[300px]"}>
|
||||
<Label>Duration</Label>
|
||||
<HelpText>
|
||||
Time period for which logs should be collected before creating
|
||||
the debug bundle.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={bundleForTime}
|
||||
onChange={(e) => setBundleForTime(e.target.value)}
|
||||
maxWidthClass="w-[220px]"
|
||||
placeholder={"2"}
|
||||
customPrefix={
|
||||
<AlarmClock size={16} className="text-nb-gray-300" />
|
||||
}
|
||||
customSuffix="Minute(s)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Anonymize Data */}
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={
|
||||
<>
|
||||
<Shield size={15} />
|
||||
Anonymize Log Data
|
||||
</>
|
||||
}
|
||||
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalFooter className="items-center">
|
||||
<div className="flex gap-3 w-full justify-end">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!isValid}
|
||||
onClick={createDebugJob}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Debug Bundle
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
60
src/modules/jobs/table/JobOutputCell.tsx
Normal file
60
src/modules/jobs/table/JobOutputCell.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import Badge from "@components/Badge";
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { Input } from "@components/Input";
|
||||
import * as React from "react";
|
||||
import { Job } from "@/interfaces/Job";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
job: Job;
|
||||
};
|
||||
|
||||
export const JobOutputCell = ({ job }: Props) => {
|
||||
if (job.status === "succeeded" && job.workload.result) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start justify-center pb-1">
|
||||
{Object.entries(job.workload.result).map(([key, value]) => (
|
||||
<div key={key} className="text-sm max-w-[200px]">
|
||||
<span className="font-normal capitalize text-nb-gray-300 text-xs">
|
||||
{key.replaceAll("_", " ")}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-nb-gray-200 truncate">
|
||||
<CopyToClipboardText
|
||||
message={"Upload key has been copied to your clipboard"}
|
||||
alwaysShowIcon={true}
|
||||
>
|
||||
<span className={"font-mono truncate"}>
|
||||
{typeof value === "boolean"
|
||||
? value
|
||||
? "Yes"
|
||||
: "No"
|
||||
: String(value)}
|
||||
</span>
|
||||
</CopyToClipboardText>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (job.status === "failed" && job.failed_reason) {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>{job.failed_reason}</div>
|
||||
}
|
||||
>
|
||||
<Badge variant={"red"} className={"px-3 max-w-[200px]"}>
|
||||
<div className={"truncate"}>{job.failed_reason}</div>
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmptyRow />;
|
||||
};
|
||||
56
src/modules/jobs/table/JobParametersCell.tsx
Normal file
56
src/modules/jobs/table/JobParametersCell.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { TooltipListItem } from "@components/TooltipListItem";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
export const JobParametersCell = ({ parameters }: { parameters: any }) => {
|
||||
if (!parameters || Object.keys(parameters).length === 0) {
|
||||
return <EmptyRow />;
|
||||
}
|
||||
|
||||
const entries = Object.entries(parameters);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
side={"top"}
|
||||
interactive={true}
|
||||
delayDuration={250}
|
||||
skipDelayDuration={100}
|
||||
contentClassName={"p-0"}
|
||||
content={
|
||||
<div
|
||||
className={"text-xs flex flex-col"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{entries.map(([key, value]) => (
|
||||
<TooltipListItem
|
||||
label={key.replaceAll("_", " ")}
|
||||
labelClassName={"capitalize"}
|
||||
value={
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
? "Yes"
|
||||
: "No"
|
||||
: String(value)
|
||||
}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
variant="gray"
|
||||
className="flex items-center gap-1.5 cursor-default"
|
||||
>
|
||||
<InfoIcon size={12} />
|
||||
{entries.length} Parameters
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
30
src/modules/jobs/table/JobStatusCell.tsx
Normal file
30
src/modules/jobs/table/JobStatusCell.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import { Job } from "@/interfaces/Job";
|
||||
|
||||
type Props = {
|
||||
job: Job;
|
||||
};
|
||||
|
||||
export default function JobStatusCell({ job }: Readonly<Props>) {
|
||||
const status = job.status;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
|
||||
data-cy={"job-status-cell"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
status == "pending" && "bg-yellow-400",
|
||||
status == "failed" && "bg-red-500",
|
||||
status == "succeeded" && "bg-green-500",
|
||||
)}
|
||||
></span>
|
||||
{status == "pending" && "Pending"}
|
||||
{status == "failed" && "Failed"}
|
||||
{status == "succeeded" && "Completed"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/modules/jobs/table/JobTypeCell.tsx
Normal file
22
src/modules/jobs/table/JobTypeCell.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { BugIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Job } from "@/interfaces/Job";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
job: Job;
|
||||
};
|
||||
export const JobTypeCell = ({ job }: Props) => {
|
||||
if (job.workload.type === "bundle") {
|
||||
return (
|
||||
<div
|
||||
className={"flex items-center gap-2 whitespace-nowrap text-nb-gray-200"}
|
||||
>
|
||||
<BugIcon size={14} />
|
||||
<span>Debug Bundle</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmptyRow />;
|
||||
};
|
||||
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal file
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import Card from "@components/Card";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import DataTableRefreshButton from "@/components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@/components/table/DataTableRowsPerPage";
|
||||
import { Job } from "@/interfaces/Job";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||
import { JobOutputCell } from "@/modules/jobs/table/JobOutputCell";
|
||||
import { JobParametersCell } from "@/modules/jobs/table/JobParametersCell";
|
||||
import JobStatusCell from "@/modules/jobs/table/JobStatusCell";
|
||||
import { JobTypeCell } from "@/modules/jobs/table/JobTypeCell";
|
||||
import { RemoteJobDropdownButton } from "@/modules/peer/RemoteJobDropdownButton";
|
||||
|
||||
type Props = {
|
||||
jobs?: Job[];
|
||||
peerID: string;
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
};
|
||||
|
||||
const PeerRemoteJobsColumns: ColumnDef<Job>[] = [
|
||||
{
|
||||
accessorKey: "Type",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Type</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <JobTypeCell job={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "CreatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Created</DataTableHeader>
|
||||
),
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => (
|
||||
<LastTimeRow date={row.original.created_at} text="Created at" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Status",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Status</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <JobStatusCell job={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "CompletedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Completed</DataTableHeader>
|
||||
),
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) =>
|
||||
row.original.completed_at ? (
|
||||
<LastTimeRow date={row.original.completed_at} text="Completed at" />
|
||||
) : (
|
||||
<EmptyRow />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Parameters",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Parameters</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<JobParametersCell parameters={row.original.workload.parameters} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ResultOrReason",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Output</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <JobOutputCell job={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function PeerRemoteJobsTable({
|
||||
jobs,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
peerID,
|
||||
}: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "CreatedAt", desc: true },
|
||||
]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
rightSide={() => (
|
||||
<div className={"gap-x-4 ml-auto flex"}>
|
||||
<RemoteJobDropdownButton />
|
||||
</div>
|
||||
)}
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 w-full" }}
|
||||
headingTarget={headingTarget}
|
||||
useRowId={true}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
showSearchAndFilters={true}
|
||||
inset={false}
|
||||
tableClassName="mt-0"
|
||||
text="Jobs"
|
||||
columns={PeerRemoteJobsColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={jobs}
|
||||
searchPlaceholder="Search by type, status, or parameters..."
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className="py-4"
|
||||
title="This peer has no remote jobs"
|
||||
description="Create a debug bundle or trigger other remote jobs to see them listed here."
|
||||
icon={<ClipboardList size={20} className="text-nb-gray-300" />}
|
||||
/>
|
||||
}
|
||||
paginationPaddingClassName="px-0 pt-8"
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage table={table} disabled={jobs?.length == 0} />
|
||||
<DataTableRefreshButton
|
||||
isDisabled={jobs?.length == 0}
|
||||
onClick={() => {
|
||||
mutate(`/peers/${peerID}/jobs`).then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export default function AddRouteDropdownButton() {
|
||||
icon={<PlusCircle size={14} />}
|
||||
color={"green"}
|
||||
margin={""}
|
||||
size={"small"}
|
||||
/>
|
||||
<div className={"flex flex-col text-left"}>
|
||||
<div className={"text-left text-white"}>New Network Route</div>
|
||||
@@ -79,6 +80,7 @@ export default function AddRouteDropdownButton() {
|
||||
}
|
||||
color={"netbird"}
|
||||
margin={""}
|
||||
size={"small"}
|
||||
/>
|
||||
<div className={"flex flex-col text-left"}>
|
||||
<div className={"text-left text-white"}>Existing Network</div>
|
||||
|
||||
105
src/modules/peer/PeerExpirationSettings.tsx
Normal file
105
src/modules/peer/PeerExpirationSettings.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { TimerResetIcon } from "lucide-react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
|
||||
export const PeerExpirationSettings = () => {
|
||||
const { peer, update } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { mutate } = useSWRConfig();
|
||||
const account = useAccount();
|
||||
|
||||
const [peerLoginExpiration, setPeerLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [peerInactivityExpiration, setPeerInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
|
||||
const updateExpiration = async ({
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
}: {
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
}) => {
|
||||
if (!permission?.peers.update) return;
|
||||
|
||||
const promise = update({
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
}).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: "Expiration was successfully updated",
|
||||
promise,
|
||||
loadingMessage: "Updating setting...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const isAccountInactivityExpirationDisabled =
|
||||
account && account?.settings?.peer_inactivity_expiration_enabled === false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={peerLoginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
type={"login-expiration"}
|
||||
onChange={async (state) => {
|
||||
setPeerLoginExpiration(state);
|
||||
!state && setPeerInactivityExpiration(false);
|
||||
|
||||
await updateExpiration({
|
||||
loginExpiration: state,
|
||||
inactivityExpiration: !state ? false : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{permission?.peers.update && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!peerLoginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
isAccountInactivityExpirationDisabled &&
|
||||
"opacity-50 bg-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
type={"inactivity-expiration"}
|
||||
value={peerInactivityExpiration}
|
||||
onChange={async (state) => {
|
||||
setPeerInactivityExpiration(state);
|
||||
await updateExpiration({
|
||||
inactivityExpiration: state,
|
||||
});
|
||||
}}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!peerLoginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,10 +3,13 @@ import FancyToggleSwitch, {
|
||||
} from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, LockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
@@ -16,6 +19,7 @@ type Props = {
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
type?: "login-expiration" | "inactivity-expiration";
|
||||
} & FancyToggleSwitchVariants;
|
||||
|
||||
export const PeerExpirationToggle = ({
|
||||
@@ -27,12 +31,26 @@ export const PeerExpirationToggle = ({
|
||||
icon,
|
||||
className,
|
||||
variant = "default",
|
||||
type = "login-expiration",
|
||||
}: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const account = useAccount();
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
const noPermissionOrNoUser = !peer.user_id || !permission?.peers.update;
|
||||
|
||||
const isAccountLoginExpirationDisabled =
|
||||
account && account?.settings?.peer_login_expiration_enabled === false;
|
||||
const isAccountInactivityExpirationDisabled =
|
||||
account && account?.settings?.peer_inactivity_expiration_enabled === false;
|
||||
|
||||
const isGlobalSettingDisabled =
|
||||
type === "login-expiration"
|
||||
? isAccountLoginExpirationDisabled
|
||||
: isAccountInactivityExpirationDisabled;
|
||||
|
||||
const tooltipContent = useMemo(() => {
|
||||
if (noPermissionOrNoUser) {
|
||||
return (
|
||||
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
|
||||
{!peer.user_id ? (
|
||||
<>
|
||||
@@ -50,14 +68,37 @@ export const PeerExpirationToggle = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isGlobalSettingDisabled) {
|
||||
const text =
|
||||
type === "login-expiration"
|
||||
? "'Peer Session Expiration'"
|
||||
: "'Require login after disconnect'";
|
||||
return (
|
||||
<div className={"flex flex-col gap-2 text-xs max-w-xs"}>
|
||||
<div>
|
||||
Global setting {text} is currently disabled. Enable the global
|
||||
setting to be able to toggle it individually per peer.{" "}
|
||||
<InlineLink href={"/settings"}>
|
||||
Go to Settings <ArrowUpRightIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [noPermissionOrNoUser, peer, type, isGlobalSettingDisabled]);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={tooltipContent}
|
||||
className={"w-full block"}
|
||||
disabled={!!peer.user_id && permission.peers.update}
|
||||
disabled={tooltipContent === undefined}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
className={className}
|
||||
disabled={!peer.user_id || !permission.peers.update}
|
||||
value={value}
|
||||
disabled={isGlobalSettingDisabled || noPermissionOrNoUser}
|
||||
value={isGlobalSettingDisabled ? false : value}
|
||||
onChange={onChange}
|
||||
variant={variant}
|
||||
label={
|
||||
|
||||
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal file
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import InlineLink from "@/components/InlineLink";
|
||||
import Paragraph from "@/components/Paragraph";
|
||||
import SkeletonTable, {
|
||||
SkeletonTableHeader,
|
||||
} from "@/components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@/hooks/usePortalElement";
|
||||
import { Job } from "@/interfaces/Job";
|
||||
import useFetchApi from "@/utils/api";
|
||||
|
||||
const PeerRemoteJobsTable = lazy(
|
||||
() => import("@/modules/jobs/table/PeerRemoteJobsTable"),
|
||||
);
|
||||
type Props = {
|
||||
peerID: string;
|
||||
};
|
||||
|
||||
export const PeerRemoteJobsSection = ({ peerID }: Props) => {
|
||||
const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`);
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<div className="pb-10 px-8">
|
||||
<div className="max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<div>
|
||||
<h2 ref={headingRef}>Remote Jobs</h2>
|
||||
<Paragraph>
|
||||
Remotely trigger actions such as debug bundles or other tasks on
|
||||
this peer, without requiring CLI access.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink href={"https://docs.netbird.io"} target={"_blank"}>
|
||||
Remote Jobs <ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div>
|
||||
<SkeletonTableHeader className="!p-0" />
|
||||
<div className="mt-8 w-full">
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PeerRemoteJobsTable
|
||||
peerID={peerID}
|
||||
jobs={jobs}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,7 +37,12 @@ import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
export const PeerSSHToggle = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const { data: policies } = useFetchApi<Policy[]>(
|
||||
"/policies",
|
||||
true,
|
||||
true,
|
||||
permission?.policies.read,
|
||||
);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [sshPolicyModal, setSshPolicyModal] = useState(false);
|
||||
@@ -201,7 +206,11 @@ export const PeerSSHToggle = () => {
|
||||
|
||||
<div className={"flex gap-3"}>
|
||||
{isSSHClientEnabled ? (
|
||||
<Button variant={"secondary"} onClick={() => setSshPolicyModal(true)}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setSshPolicyModal(true)}
|
||||
disabled={!permission?.policies.create}
|
||||
>
|
||||
<CirclePlusIcon size={14} />
|
||||
Create SSH Policy
|
||||
</Button>
|
||||
@@ -301,29 +310,31 @@ export const PeerSSHToggle = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PoliciesProvider>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
{permission?.policies.create && (
|
||||
<PoliciesProvider>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<PeerSSHPolicyModal
|
||||
open={sshPolicyModal}
|
||||
onOpenChange={setSshPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</Modal>
|
||||
<PeerSSHPolicyModal
|
||||
open={sshPolicyModal}
|
||||
onOpenChange={setSshPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</PoliciesProvider>
|
||||
</PoliciesProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal file
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { BugPlay, ChevronDown } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { CreateDebugJobModalContent } from "../jobs/CreateDebugJobModal";
|
||||
|
||||
export const RemoteJobDropdownButton = () => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const isConnected = peer?.connected;
|
||||
const disabled = !permission.peers.delete;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
|
||||
<CreateDebugJobModalContent
|
||||
peerID={peer.id!}
|
||||
onSuccess={() => setModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Button variant={"primary"} disabled={disabled}>
|
||||
Run Remote Job
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto" align="end" sideOffset={10}>
|
||||
{!isConnected && (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"text-xs flex items-center w-full justify-center max-w-xs px-3 py-3 text-nb-gray-200 font-light"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
Peer{" "}
|
||||
<span className={"text-white font-medium"}>{peer.name}</span>{" "}
|
||||
is currently offline. Please connect the peer to run remote
|
||||
jobs.
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setModal(true)}
|
||||
disabled={disabled || !isConnected}
|
||||
>
|
||||
<div className={"flex gap-3 items-center justify-center pr-3"}>
|
||||
<SquareIcon
|
||||
icon={<BugPlay size={14} />}
|
||||
margin={""}
|
||||
size={"small"}
|
||||
/>
|
||||
<div className={"flex flex-col text-left"}>
|
||||
<div className={"text-left text-white"}>Debug Bundle</div>
|
||||
<div className={"text-xs"}>
|
||||
Collect debug information for troubleshooting
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -20,11 +20,13 @@ type Props = {
|
||||
version: string;
|
||||
os: string;
|
||||
serial?: string;
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
export default function PeerVersionCell({ version, os, serial }: Props) {
|
||||
export default function PeerVersionCell({ version, os, serial, ephemeral }: Props) {
|
||||
const { latestVersion, latestUrl } = useApplicationContext();
|
||||
|
||||
const updateAvailable = useMemo(() => {
|
||||
if (ephemeral) return false;
|
||||
const operatingSystem = getOperatingSystem(os);
|
||||
if (
|
||||
operatingSystem === OperatingSystem.IOS ||
|
||||
@@ -33,7 +35,7 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
|
||||
return false;
|
||||
if (!latestVersion) return false;
|
||||
return !compareVersions(version, latestVersion);
|
||||
}, [os, version, latestVersion]);
|
||||
}, [os, version, latestVersion, ephemeral]);
|
||||
|
||||
const updateIcon = useMemo(() => {
|
||||
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;
|
||||
|
||||
@@ -170,6 +170,7 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
version={row.original.version}
|
||||
os={row.original.os}
|
||||
serial={row.original.serial_number}
|
||||
ephemeral={row.original.ephemeral}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
version?: string;
|
||||
versionText?: string;
|
||||
versionList?: SelectOption[];
|
||||
icon: React.FunctionComponent<{ size: number }>;
|
||||
icon: (props: { size: number }) => React.ReactElement;
|
||||
os: string;
|
||||
};
|
||||
export const PostureCheckOperatingSystemInfo = ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user