mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be79b9955 |
No files matched your search
@@ -9,7 +9,6 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import React, { useMemo } from "react";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -73,9 +72,7 @@ export default function ProxyEventsPage() {
|
||||
defaultPageSize={10}
|
||||
defaultFilters={defaultFilters}
|
||||
>
|
||||
<PeersProvider>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
</PeersProvider>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
</ServerPaginationProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,5 +1,116 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function PeersIndex() {
|
||||
redirect("/peers/users");
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function Peers() {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{isRestricted ? (
|
||||
<PeersBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
<PeersView />
|
||||
</PeersProvider>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PeersView() {
|
||||
const { peers, isLoading } = usePeers();
|
||||
const { users } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const peersWithUser = peers?.map((peer) => {
|
||||
if (!users) return peer;
|
||||
return {
|
||||
...peer,
|
||||
user: users?.find((user) => user.id === peer.user_id),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Peers</h1>
|
||||
<Paragraph>
|
||||
A list of all machines and devices connected to your private network.
|
||||
Use this view to manage peers.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Peers
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PeersTable
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PeersBlockedView() {
|
||||
return (
|
||||
<div className={"flex items-center justify-center flex-col"}>
|
||||
<div className={"p-default py-6 max-w-3xl text-center"}>
|
||||
<h1>Add new device to your network</h1>
|
||||
<Paragraph className={"inline"}>
|
||||
To get started, install NetBird and log in using your email account.
|
||||
After that you should be connected. If you have further questions
|
||||
check out our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started#installation"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Installation Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Servers - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function ServersPage() {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{isRestricted ? (
|
||||
<ServersBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
<ServersView />
|
||||
</PeersProvider>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ServersView() {
|
||||
const { peers, isLoading: isPeersLoading } = usePeers();
|
||||
const { users, isLoading: isUsersLoading } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
// The kind filter classifies peers by whether their owner is a real
|
||||
// user vs a service/no-user, so we must wait until both peers and
|
||||
// users have loaded before joining them — otherwise peers temporarily
|
||||
// render with peer.user === undefined and get misclassified.
|
||||
const isLoading = isPeersLoading || isUsersLoading;
|
||||
const peersWithUser = useMemo(() => {
|
||||
if (!peers || !users) return undefined;
|
||||
return peers.map((peer) => ({
|
||||
...peer,
|
||||
user: users.find((u) => u.id === peer.user_id),
|
||||
}));
|
||||
}, [peers, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers/servers"}
|
||||
label={"Servers"}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Servers</h1>
|
||||
<Paragraph>
|
||||
Servers, VMs, autonomous agents and other unattended machines with no
|
||||
user behind them, typically enrolled with a{" "}
|
||||
<InlineLink href={"/settings?tab=setup-keys"}>setup key.</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PeersTable
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
kind={"servers"}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServersBlockedView() {
|
||||
return (
|
||||
<div className={"flex items-center justify-center flex-col"}>
|
||||
<div className={"p-default py-6 max-w-3xl text-center"}>
|
||||
<h1>Add new server to your network</h1>
|
||||
<Paragraph className={"inline"}>
|
||||
To get started, install NetBird on the server and enroll it using a
|
||||
setup key. If you have further questions check out our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started#installation"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Installation Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} isUserDevice={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `User Devices - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function UserDevicesPage() {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{isRestricted ? (
|
||||
<UserDevicesBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
<UserDevicesView />
|
||||
</PeersProvider>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDevicesView() {
|
||||
const { peers, isLoading: isPeersLoading } = usePeers();
|
||||
const { users, isLoading: isUsersLoading } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
// The kind filter classifies peers by whether their owner is a real
|
||||
// user vs a service/no-user, so we must wait until both peers and
|
||||
// users have loaded before joining them — otherwise peers temporarily
|
||||
// render with peer.user === undefined and get misclassified.
|
||||
const isLoading = isPeersLoading || isUsersLoading;
|
||||
const peersWithUser = useMemo(() => {
|
||||
if (!peers || !users) return undefined;
|
||||
return peers.map((peer) => ({
|
||||
...peer,
|
||||
user: users.find((u) => u.id === peer.user_id),
|
||||
}));
|
||||
}, [peers, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers/users"}
|
||||
label={"User Devices"}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>User Devices</h1>
|
||||
<Paragraph>
|
||||
Laptops, phones and other personal devices with a user behind them,
|
||||
typically added when the user signs in with SSO.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PeersTable
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
kind={"users"}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDevicesBlockedView() {
|
||||
return (
|
||||
<div className={"flex items-center justify-center flex-col"}>
|
||||
<div className={"p-default py-6 max-w-3xl text-center"}>
|
||||
<h1>Add new device to your network</h1>
|
||||
<Paragraph className={"inline"}>
|
||||
To get started, install NetBird and log in using your email account.
|
||||
After that you should be connected. If you have further questions
|
||||
check out our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started#installation"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Installation Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} isUserDevice />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Clusters - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -1,66 +0,0 @@
|
||||
"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 { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const ClustersTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyClustersPage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Reverse Proxy"}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/clusters"}
|
||||
label={"Clusters"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Clusters</h1>
|
||||
<Paragraph>
|
||||
Proxy clusters that route inbound traffic to your services. Shared
|
||||
clusters are deployed at the server level; account clusters are
|
||||
self-hosted on your own infrastructure.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
|
||||
Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Clusters"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ClustersTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -24,7 +23,6 @@ import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
|
||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||
|
||||
export default function NetBirdSettings() {
|
||||
@@ -57,12 +55,6 @@ export default function NetBirdSettings() {
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
{permission.setup_keys.read && (
|
||||
<VerticalTabs.Trigger value="setup-keys">
|
||||
<SetupKeysIcon size={14} />
|
||||
Setup Keys
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission?.identity_providers?.read && (
|
||||
<VerticalTabs.Trigger value="identity-providers">
|
||||
@@ -97,7 +89,6 @@ export default function NetBirdSettings() {
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{permission.setup_keys.read && <SetupKeysTab />}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && <IdentityProvidersTab />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function SetupKeysIndex() {
|
||||
redirect("/settings?tab=setup-keys");
|
||||
}
|
||||
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, useMemo } from "react";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const SetupKeysTable = lazy(
|
||||
() => import("@/modules/setup-keys/SetupKeysTable"),
|
||||
);
|
||||
|
||||
export default function SetupKeys() {
|
||||
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
|
||||
const { permission } = usePermissions();
|
||||
const { groups } = useGroups();
|
||||
|
||||
const setupKeysWithGroups = useMemo(() => {
|
||||
if (!setupKeys) return [];
|
||||
return setupKeys?.map((setupKey) => {
|
||||
if (!setupKey.auto_groups) return setupKey;
|
||||
if (!groups) return setupKey;
|
||||
return {
|
||||
...setupKey,
|
||||
groups: setupKey.auto_groups
|
||||
?.map((group) => {
|
||||
return groups.find((g) => g.id === group) || undefined;
|
||||
})
|
||||
.filter((group) => group !== undefined) as Group[],
|
||||
};
|
||||
});
|
||||
}, [setupKeys, groups]);
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/setup-keys"}
|
||||
label={"Setup Keys"}
|
||||
icon={<SetupKeysIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Setup Keys</h1>
|
||||
<Paragraph>
|
||||
Setup keys are pre-authentication keys that allow to register new
|
||||
machines in your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Setup Keys
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Setup Keys"}
|
||||
hasAccess={permission.setup_keys.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<SetupKeysTable
|
||||
headingTarget={portalTarget}
|
||||
setupKeys={setupKeysWithGroups}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Tests for globals.css CSS variable definitions.
|
||||
*
|
||||
* The PR introduced a two-ramp CSS-variable system for nb-gray:
|
||||
* - :root defines the INVERTED ramp (light mode — high numbers = light,
|
||||
* low numbers = dark text)
|
||||
* - .dark defines the ORIGINAL ramp (dark mode — original values)
|
||||
*
|
||||
* These tests parse the raw CSS file as a string and verify:
|
||||
* 1. :root defines every nb-gray custom property.
|
||||
* 2. .dark defines every nb-gray custom property.
|
||||
* 3. The light and dark ramps are genuinely different (inverted) for key stops.
|
||||
* 4. All values are space-separated RGB triples (not hex, not rgb()).
|
||||
* 5. h1, h2, p now use theme-agnostic text classes (not dark:-prefixed).
|
||||
*
|
||||
* Run with: npx tsx src/app/globals-css.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
const css = readFileSync(join(__dir, "globals.css"), "utf-8");
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the value of a CSS custom property from a given block of CSS text.
|
||||
* Returns the trimmed raw value string, or null if not found.
|
||||
*/
|
||||
function extractVar(cssText: string, varName: string): string | null {
|
||||
// matches "--varName: VALUE;"
|
||||
const regex = new RegExp(`${varName}\\s*:\\s*([^;]+);`);
|
||||
const match = cssText.match(regex);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text content between the first occurrence of `selector {` and
|
||||
* its matching closing `}`. Naïve but sufficient for the flat blocks in
|
||||
* globals.css.
|
||||
*/
|
||||
function extractBlock(cssText: string, selector: string): string {
|
||||
const start = cssText.indexOf(selector);
|
||||
if (start === -1) return "";
|
||||
const brace = cssText.indexOf("{", start);
|
||||
if (brace === -1) return "";
|
||||
let depth = 1;
|
||||
let i = brace + 1;
|
||||
while (i < cssText.length && depth > 0) {
|
||||
if (cssText[i] === "{") depth++;
|
||||
else if (cssText[i] === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
return cssText.slice(brace + 1, i - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a string looks like a space-separated RGB triple, e.g. "255 255 255".
|
||||
*/
|
||||
function isRgbTriple(value: string): boolean {
|
||||
return /^\d{1,3}\s+\d{1,3}\s+\d{1,3}$/.test(value.trim());
|
||||
}
|
||||
|
||||
// ─── setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const rootBlock = extractBlock(css, ":root");
|
||||
const darkBlock = extractBlock(css, ".dark");
|
||||
|
||||
const STOPS = [
|
||||
"DEFAULT",
|
||||
"50",
|
||||
"100",
|
||||
"200",
|
||||
"250",
|
||||
"300",
|
||||
"350",
|
||||
"400",
|
||||
"500",
|
||||
"600",
|
||||
"700",
|
||||
"800",
|
||||
"850",
|
||||
"900",
|
||||
"910",
|
||||
"920",
|
||||
"925",
|
||||
"930",
|
||||
"935",
|
||||
"940",
|
||||
"950",
|
||||
"960",
|
||||
] as const;
|
||||
|
||||
type Check = { desc: string; ok: boolean };
|
||||
const checks: Check[] = [];
|
||||
|
||||
// ─── 1. :root block exists ─────────────────────────────────────────────────
|
||||
checks.push({
|
||||
desc: ":root block is present in globals.css",
|
||||
ok: rootBlock.length > 0,
|
||||
});
|
||||
|
||||
// ─── 2. .dark block exists ─────────────────────────────────────────────────
|
||||
checks.push({
|
||||
desc: ".dark block is present in globals.css",
|
||||
ok: darkBlock.length > 0,
|
||||
});
|
||||
|
||||
// ─── 3. :root defines all nb-gray stops as RGB triples ─────────────────────
|
||||
for (const stop of STOPS) {
|
||||
const varName =
|
||||
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
|
||||
const value = extractVar(rootBlock, varName);
|
||||
checks.push({
|
||||
desc: `[light/:root] ${varName} is defined as an RGB triple`,
|
||||
ok: value !== null && isRgbTriple(value),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 4. .dark defines all nb-gray stops as RGB triples ─────────────────────
|
||||
for (const stop of STOPS) {
|
||||
const varName =
|
||||
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
|
||||
const value = extractVar(darkBlock, varName);
|
||||
checks.push({
|
||||
desc: `[dark/.dark] ${varName} is defined as an RGB triple`,
|
||||
ok: value !== null && isRgbTriple(value),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 5. Ramps are inverted: light-mode high stops ≠ dark-mode high stops ───
|
||||
// e.g. in light mode --nb-gray-900 should be near-white (high channels),
|
||||
// in dark mode --nb-gray-900 should be near-dark (low channels).
|
||||
{
|
||||
const lightDefault = extractVar(rootBlock, "--nb-gray-DEFAULT");
|
||||
const darkDefault = extractVar(darkBlock, "--nb-gray-DEFAULT");
|
||||
checks.push({
|
||||
desc: "DEFAULT stop differs between light and dark mode",
|
||||
ok: lightDefault !== null && darkDefault !== null && lightDefault !== darkDefault,
|
||||
});
|
||||
|
||||
// Light mode DEFAULT should be near-white (all channels high)
|
||||
if (lightDefault) {
|
||||
const channels = lightDefault.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "light mode DEFAULT is near-white (all channels >= 240)",
|
||||
ok: channels.every((c) => c >= 240),
|
||||
});
|
||||
}
|
||||
|
||||
// Dark mode DEFAULT should be near-black (all channels low)
|
||||
if (darkDefault) {
|
||||
const channels = darkDefault.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "dark mode DEFAULT is near-black (all channels <= 50)",
|
||||
ok: channels.every((c) => c <= 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. Light mode 50-stop is dark text (low channels) ─────────────────────
|
||||
{
|
||||
const light50 = extractVar(rootBlock, "--nb-gray-50");
|
||||
if (light50) {
|
||||
const channels = light50.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "light mode nb-gray-50 is near-black text (all channels <= 50)",
|
||||
ok: channels.every((c) => c <= 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 7. Dark mode 50-stop is bright text (high channels) ───────────────────
|
||||
{
|
||||
const dark50 = extractVar(darkBlock, "--nb-gray-50");
|
||||
if (dark50) {
|
||||
const channels = dark50.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "dark mode nb-gray-50 is near-white text (all channels >= 220)",
|
||||
ok: channels.every((c) => c >= 220),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 8. No hex values in the CSS variable declarations ─────────────────────
|
||||
{
|
||||
const hexInRoot = /#[0-9a-fA-F]{3,8}/.test(rootBlock);
|
||||
const hexInDark = /#[0-9a-fA-F]{3,8}/.test(darkBlock);
|
||||
checks.push({
|
||||
desc: ":root block contains no hard-coded hex colour values",
|
||||
ok: !hexInRoot,
|
||||
});
|
||||
checks.push({
|
||||
desc: ".dark block contains no hard-coded hex colour values",
|
||||
ok: !hexInDark,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 9. h1, h2, p use theme-agnostic nb-gray classes ─────────────────────
|
||||
{
|
||||
checks.push({
|
||||
desc: "h1 uses text-nb-gray-100 (not dark:text-nb-gray-100 or text-gray-700)",
|
||||
ok:
|
||||
css.includes("text-nb-gray-100") &&
|
||||
!css.includes("dark:text-nb-gray-100") &&
|
||||
!css.match(/h1[^}]*text-gray-700/),
|
||||
});
|
||||
checks.push({
|
||||
desc: "p uses text-nb-gray-200 (not dark:text-zinc-50 or dark:text-gray-700)",
|
||||
ok:
|
||||
css.includes("text-nb-gray-200") &&
|
||||
!css.includes("dark:text-zinc-50") &&
|
||||
!css.includes("text-gray-700"),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 10. timescape input focus uses text-nb-gray-50 ────────────────────────
|
||||
{
|
||||
checks.push({
|
||||
desc: ".timescape input:focus uses text-nb-gray-50 (not text-white)",
|
||||
ok: css.includes("text-nb-gray-50") && !css.match(/timescape[^}]*text-white/),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== globals.css CSS-variable definitions ===");
|
||||
let failures = 0;
|
||||
for (const { desc, ok } of checks) {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? "✓" : "✗"} ${desc}`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -3,103 +3,36 @@ import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
export default function LoadingIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="135"
|
||||
height="140"
|
||||
viewBox="0 0 135 140"
|
||||
viewBox="0 0 919 669"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fillOpacity="0.8"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<rect y="10" width="15" height="120" rx="6">
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.5s"
|
||||
dur="1s"
|
||||
values="120;110;100;90;80;70;60;50;40;140;120"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
<style>{`
|
||||
@keyframes nb-loader-pulse {
|
||||
0%, 100% { transform: scale(0.85); opacity: 0.35; }
|
||||
50% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.nb-loader-bird {
|
||||
animation: nb-loader-pulse 1.75s ease-in-out infinite;
|
||||
transform-origin: 50% 50%;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
`}</style>
|
||||
<g className={"nb-loader-bird"}>
|
||||
<path
|
||||
d="M653.25 0C542.297 10.255 487.216 74.132 466.346 106.359L456.959 122.609C456.311 123.963 455.886 124.865 455.886 124.865L455.774 124.685L142.056 668.021H533.256L918.947 0H653.25Z"
|
||||
fill="#F68330"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y"
|
||||
begin="0.5s"
|
||||
dur="1s"
|
||||
values="10;15;20;25;30;35;40;45;50;0;10"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
<path
|
||||
d="M533.256 668.02L0 102.296C0 102.296 603.149 -59.6172 661.827 445.893L533.256 668.02Z"
|
||||
fill="#F68330"
|
||||
/>
|
||||
</rect>
|
||||
<rect x="30" y="10" width="15" height="120" rx="6">
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.25s"
|
||||
dur="1s"
|
||||
values="120;110;100;90;80;70;60;50;40;140;120"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
<path
|
||||
d="M454.3 127.229L290.658 410.664L533.256 668.02L661.757 445.475C641.46 271.63 556.721 176.843 454.3 127.229Z"
|
||||
fill="#F35E32"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y"
|
||||
begin="0.25s"
|
||||
dur="1s"
|
||||
values="10;15;20;25;30;35;40;45;50;0;10"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect x="60" width="15" height="140" rx="6">
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0s"
|
||||
dur="1s"
|
||||
values="120;110;100;90;80;70;60;50;40;140;120"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y"
|
||||
begin="0s"
|
||||
dur="1s"
|
||||
values="10;15;20;25;30;35;40;45;50;0;10"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect x="90" y="10" width="15" height="120" rx="6">
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.25s"
|
||||
dur="1s"
|
||||
values="120;110;100;90;80;70;60;50;40;140;120"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y"
|
||||
begin="0.25s"
|
||||
dur="1s"
|
||||
values="10;15;20;25;30;35;40;45;50;0;10"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect x="120" y="10" width="15" height="120" rx="6">
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.5s"
|
||||
dur="1s"
|
||||
values="120;110;100;90;80;70;60;50;40;140;120"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y"
|
||||
begin="0.5s"
|
||||
dur="1s"
|
||||
values="10;15;20;25;30;35;40;45;50;0;10"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* Tests for buttonVariants — the cva variant helper exported from Button.tsx.
|
||||
*
|
||||
* These tests cover the light-mode additions introduced in the light-mode draft
|
||||
* PR. The key changes were:
|
||||
* - danger-outline: added explicit light-mode styles (bg-transparent, text-red-600, etc.)
|
||||
* - danger-text: made fully theme-agnostic (dropped the dark:-prefixed classes)
|
||||
* - default-outline: made fully theme-agnostic (dropped the dark:-prefixed classes)
|
||||
* - danger: added explicit light-mode enabled/disabled styles
|
||||
*
|
||||
* Run with: npx tsx src/components/Button.test.ts
|
||||
*/
|
||||
|
||||
import { buttonVariants } from "./Button.js";
|
||||
|
||||
type Case = { desc: string; classes: string[]; notClasses?: string[] };
|
||||
|
||||
function run(name: string, cases: Case[], fn: () => string): number {
|
||||
console.log(`\n=== ${name} ===`);
|
||||
let failures = 0;
|
||||
const result = fn();
|
||||
for (const { desc, classes, notClasses = [] } of cases) {
|
||||
for (const cls of classes) {
|
||||
const ok = result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] contains "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
for (const cls of notClasses) {
|
||||
const ok = !result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] does NOT contain "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger-outline variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger-outline",
|
||||
[
|
||||
{
|
||||
desc: "light-mode base",
|
||||
classes: [
|
||||
"bg-transparent",
|
||||
"text-red-600",
|
||||
"border-red-300",
|
||||
"hover:bg-red-50",
|
||||
"hover:border-red-400",
|
||||
"focus:ring-red-500/30",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "dark-mode overrides preserved",
|
||||
classes: [
|
||||
"dark:bg-transparent",
|
||||
"dark:text-red-500",
|
||||
"dark:border-transparent",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger-outline" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger-text variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger-text",
|
||||
[
|
||||
{
|
||||
desc: "theme-agnostic text",
|
||||
classes: ["text-red-500", "hover:text-red-600"],
|
||||
},
|
||||
{
|
||||
desc: "no padding / shadow",
|
||||
classes: ["border-transparent", "!px-0", "!shadow-none", "!py-0"],
|
||||
},
|
||||
{
|
||||
desc: "focus ring",
|
||||
classes: ["focus:ring-red-500/30"],
|
||||
},
|
||||
{
|
||||
desc: "ring offset is theme-agnostic (no dark: prefix)",
|
||||
classes: ["ring-offset-nb-gray-950/50"],
|
||||
notClasses: ["dark:ring-offset-neutral-950/50"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger-text" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// default-outline variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — default-outline",
|
||||
[
|
||||
{
|
||||
desc: "ring offset and focus ring are theme-agnostic",
|
||||
classes: ["ring-offset-nb-gray-950/50", "focus:ring-nb-gray-500/20"],
|
||||
},
|
||||
{
|
||||
desc: "light/dark-agnostic base styles",
|
||||
classes: [
|
||||
"bg-transparent",
|
||||
"text-nb-gray-400",
|
||||
"border-transparent",
|
||||
"hover:text-nb-gray-50",
|
||||
"hover:bg-nb-gray-900/30",
|
||||
"hover:border-nb-gray-800/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "data-state=open styles are theme-agnostic",
|
||||
classes: [
|
||||
"data-[state=open]:text-nb-gray-50",
|
||||
"data-[state=open]:bg-nb-gray-900/30",
|
||||
"data-[state=open]:border-nb-gray-800/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "no leftover dark:-only prefix variants for core styles",
|
||||
notClasses: [
|
||||
"dark:ring-offset-nb-gray-950/50",
|
||||
"dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent",
|
||||
"dark:text-nb-gray-400",
|
||||
"data-[state=open]:dark:text-nb-gray-50",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "default-outline" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger",
|
||||
[
|
||||
{
|
||||
desc: "light-mode enabled state",
|
||||
classes: [
|
||||
"enabled:bg-red-600",
|
||||
"enabled:text-white",
|
||||
"enabled:hover:bg-red-700",
|
||||
"enabled:focus:bg-red-700",
|
||||
"enabled:focus:ring-red-400/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "light-mode disabled state",
|
||||
classes: [
|
||||
"disabled:bg-red-300",
|
||||
"disabled:text-white",
|
||||
"border-red-600",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "dark-mode overrides preserved",
|
||||
classes: [
|
||||
"dark:bg-red-600",
|
||||
"dark:text-red-100",
|
||||
"hover:dark:bg-red-700",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Unchanged variants — regression check: ensure they still work
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — primary (regression)",
|
||||
[
|
||||
{
|
||||
desc: "primary still has netbird colour",
|
||||
classes: ["enabled:dark:bg-netbird", "enabled:bg-netbird"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "primary" }),
|
||||
);
|
||||
|
||||
failures += run(
|
||||
"buttonVariants — default (regression)",
|
||||
[
|
||||
{
|
||||
desc: "default variant still renders",
|
||||
classes: ["bg-white", "text-gray-900"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "default" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Size and rounding defaults
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — default size and rounding",
|
||||
[
|
||||
{
|
||||
desc: "default size is md",
|
||||
classes: ["py-2.5", "px-4"],
|
||||
},
|
||||
{
|
||||
desc: "default rounded is true",
|
||||
classes: ["rounded-md"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({}),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Edge: danger-outline with extra className override doesn't lose base classes
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const cls = buttonVariants({ variant: "danger-outline" });
|
||||
const ok =
|
||||
cls.includes("text-red-600") && cls.includes("border-red-300");
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`\n=== danger-outline boundary ===\n${ok ? "✓" : "✗"} light-mode base classes present when called without extra options`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -1,129 +0,0 @@
|
||||
import useCopyToClipboard from "@hooks/useCopyToClipboard";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type CardTableProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTable({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<table className={"w-full border-collapse text-sm"}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableHeader({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<thead>
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableHeaderCellProps = {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableHeaderCell({
|
||||
children,
|
||||
width,
|
||||
className,
|
||||
}: CardTableHeaderCellProps) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-left text-sm font-normal",
|
||||
className,
|
||||
)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableBody({ children, className }: CardTableProps) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
type CardTableRowProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableRow({ children, className }: CardTableRowProps) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableCellProps = {
|
||||
children: React.ReactNode;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableCell({
|
||||
children,
|
||||
copy = false,
|
||||
copyText,
|
||||
width,
|
||||
className,
|
||||
}: CardTableCellProps) {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
|
||||
return (
|
||||
<td
|
||||
className={cn("px-4 py-3", className)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(`${copyText} has been copied to clipboard.`)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
CardTable.Header = CardTableHeader;
|
||||
CardTable.HeaderCell = CardTableHeaderCell;
|
||||
CardTable.Body = CardTableBody;
|
||||
CardTable.Row = CardTableRow;
|
||||
CardTable.Cell = CardTableCell;
|
||||
|
||||
export default CardTable;
|
||||
@@ -9,11 +9,6 @@ type Props = {
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// Overrides the rendered innerText as the value written to the
|
||||
// clipboard. Use when the displayed text is an abbreviation of the
|
||||
// canonical value (e.g. the short DNS label) but the user should
|
||||
// still get the full string when they click.
|
||||
textToCopy?: string;
|
||||
};
|
||||
|
||||
export default function CopyToClipboardText({
|
||||
@@ -22,9 +17,8 @@ export default function CopyToClipboardText({
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
alwaysShowIcon = false,
|
||||
textToCopy,
|
||||
}: Props) {
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard(textToCopy);
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Tests for NetBirdLogo component source structure.
|
||||
*
|
||||
* The PR introduced dual-image theme switching so the logo automatically
|
||||
* shows the light-text SVG in light mode and the dark-text SVG in dark mode:
|
||||
*
|
||||
* - A <span> wrapping both <Image>s (one per theme) replaces the single
|
||||
* previous <Image>.
|
||||
* - The light-mode image has className="block dark:hidden".
|
||||
* - The dark-mode image has className="hidden dark:block".
|
||||
* - The mobile mark (logomark only) now always has className="md:hidden ml-4"
|
||||
* rather than a conditional cn() call.
|
||||
* - The netbird-full-light.svg asset is imported.
|
||||
*
|
||||
* Because this is a Next.js component that relies on next/image and SVG
|
||||
* asset imports, we verify the source text directly rather than rendering it
|
||||
* in Node.
|
||||
*
|
||||
* Run with: npx tsx src/components/NetBirdLogo.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
const source = readFileSync(join(__dir, "NetBirdLogo.tsx"), "utf-8");
|
||||
|
||||
type Check = { desc: string; ok: boolean };
|
||||
const checks: Check[] = [];
|
||||
|
||||
// 1. Light-mode SVG asset is imported
|
||||
checks.push({
|
||||
desc: "imports netbird-full-light.svg",
|
||||
ok: source.includes("netbird-full-light.svg"),
|
||||
});
|
||||
|
||||
// 2. The import alias is used in the JSX
|
||||
checks.push({
|
||||
desc: "NetBirdLogoFullLight is used as an Image src",
|
||||
ok: source.includes("NetBirdLogoFullLight") && source.includes("src={NetBirdLogoFullLight}"),
|
||||
});
|
||||
|
||||
// 3. Dark-mode logo is still imported and used
|
||||
checks.push({
|
||||
desc: "imports netbird-full.svg (dark logo)",
|
||||
ok: source.includes("netbird-full.svg"),
|
||||
});
|
||||
checks.push({
|
||||
desc: "NetBirdLogoFull is used as an Image src",
|
||||
ok: source.includes("src={NetBirdLogoFull}"),
|
||||
});
|
||||
|
||||
// 4. Light-mode image has the correct visibility toggle class
|
||||
checks.push({
|
||||
desc: "light-logo image has className=\"block dark:hidden\"",
|
||||
ok: source.includes("block dark:hidden"),
|
||||
});
|
||||
|
||||
// 5. Dark-mode image has the correct visibility toggle class
|
||||
checks.push({
|
||||
desc: "dark-logo image has className=\"hidden dark:block\"",
|
||||
ok: source.includes("hidden dark:block"),
|
||||
});
|
||||
|
||||
// 6. Both images are wrapped in a <span> (the new structure)
|
||||
checks.push({
|
||||
desc: "a wrapping <span> element is present",
|
||||
ok: source.includes("<span"),
|
||||
});
|
||||
|
||||
// 7. The wrapping span carries the conditional hidden class for mobile
|
||||
checks.push({
|
||||
desc: "wrapping span uses cn() for responsive hiding",
|
||||
ok: source.includes('cn("relative"') || source.includes("cn(\"relative\""),
|
||||
});
|
||||
|
||||
// 8. Mobile logomark uses simple className (no unnecessary conditional)
|
||||
checks.push({
|
||||
desc: "mobile mark className does not use unnecessary mobile-conditional cn",
|
||||
ok:
|
||||
source.includes('"md:hidden ml-4"') ||
|
||||
source.includes("'md:hidden ml-4'"),
|
||||
});
|
||||
|
||||
// 9. Mobile mark still imports NetBirdLogoMark
|
||||
checks.push({
|
||||
desc: "imports netbird.svg (logomark)",
|
||||
ok: source.includes("netbird.svg"),
|
||||
});
|
||||
|
||||
// 10. sizes object is still present for both size values
|
||||
checks.push({
|
||||
desc: "sizes.default is defined",
|
||||
ok: source.includes("default:"),
|
||||
});
|
||||
checks.push({
|
||||
desc: "sizes.large is defined",
|
||||
ok: source.includes("large:"),
|
||||
});
|
||||
|
||||
// 11. Props still have defaults (size and mobile)
|
||||
checks.push({
|
||||
desc: "size prop defaults to 'default'",
|
||||
ok: source.includes("size = \"default\""),
|
||||
});
|
||||
checks.push({
|
||||
desc: "mobile prop defaults to true",
|
||||
ok: source.includes("mobile = true"),
|
||||
});
|
||||
|
||||
// 12. Alt text is preserved on both images
|
||||
const altMatches = source.match(/alt=\{"NetBird Logo"\}/g);
|
||||
checks.push({
|
||||
desc: "at least two images have alt='NetBird Logo'",
|
||||
ok: altMatches !== null && altMatches.length >= 2,
|
||||
});
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== NetBirdLogo source checks ===");
|
||||
let failures = 0;
|
||||
for (const { desc, ok } of checks) {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? "✓" : "✗"} ${desc}`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -30,10 +30,8 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
@@ -49,15 +47,7 @@ import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources" | "clusters";
|
||||
|
||||
export type ClusterOption = {
|
||||
/** Cluster apex domain (e.g. "eu.proxy.netbird.io"); also the value
|
||||
* that downstream code stores in target_id / proxy_cluster. */
|
||||
domain: string;
|
||||
/** Human-friendly label; falls back to domain. */
|
||||
label?: string;
|
||||
};
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -84,16 +74,8 @@ interface MultiSelectProps {
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: PeerGroupSelectorTab[];
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
closeOnSelect?: boolean;
|
||||
/** Show a Clusters tab. Off by default; flip on with clusters list. */
|
||||
showClusters?: boolean;
|
||||
/** Clusters offered in the Clusters tab. When empty the tab is hidden. */
|
||||
clusters?: ClusterOption[];
|
||||
/** Currently-selected cluster (domain string), if any. */
|
||||
selectedCluster?: string;
|
||||
/** Called when the user picks (or clears) a cluster. */
|
||||
onClusterChange?: (cluster?: string) => void;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: React.ReactNode | string;
|
||||
@@ -138,10 +120,6 @@ export function PeerGroupSelector({
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
showClusters = false,
|
||||
clusters,
|
||||
selectedCluster,
|
||||
onClusterChange,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
@@ -313,29 +291,9 @@ export function PeerGroupSelector({
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer by name or ip...";
|
||||
if (tab === "clusters") return "Search cluster...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
const filteredClusters = useMemo(() => {
|
||||
if (!clusters || clusters.length === 0) return [];
|
||||
if (!search) return clusters;
|
||||
const q = search.toLowerCase();
|
||||
return clusters.filter(
|
||||
(c) =>
|
||||
c.domain.toLowerCase().includes(q) ||
|
||||
c.label?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [clusters, search]);
|
||||
|
||||
const selectCluster = (cluster?: ClusterOption) => {
|
||||
onClusterChange?.(cluster?.domain);
|
||||
onChange([]);
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
resource
|
||||
@@ -417,36 +375,6 @@ export function PeerGroupSelector({
|
||||
showX={true}
|
||||
/>
|
||||
)}
|
||||
{selectedCluster && (
|
||||
<Badge
|
||||
useHover={true}
|
||||
data-cy={"cluster-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={
|
||||
"py-[3px] transition-all group whitespace-nowrap"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClusterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
<ServerIcon size={12} className={"shrink-0"} />
|
||||
<TruncatedText
|
||||
text={
|
||||
(clusters ?? []).find((c) => c.domain === selectedCluster)
|
||||
?.label ?? selectedCluster
|
||||
}
|
||||
maxChars={20}
|
||||
/>
|
||||
<XIcon
|
||||
size={12}
|
||||
className={
|
||||
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
@@ -490,7 +418,7 @@ export function PeerGroupSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && !selectedCluster && (
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
@@ -569,7 +497,6 @@ export function PeerGroupSelector({
|
||||
searchRef={searchRef}
|
||||
showPeers={showPeers}
|
||||
showResources={showResources}
|
||||
showClusters={showClusters}
|
||||
hideGroupsTab={hideGroupsTab}
|
||||
tabOrder={tabOrder}
|
||||
/>
|
||||
@@ -719,15 +646,6 @@ export function PeerGroupSelector({
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{showClusters && (
|
||||
<TabsContent value={"clusters"} className={"p-0 my-0"}>
|
||||
<ClustersList
|
||||
clusters={filteredClusters}
|
||||
value={selectedCluster}
|
||||
onChange={selectCluster}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -740,22 +658,17 @@ const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showClusters = false,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showClusters?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: PeerGroupSelectorTab[];
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
}) => {
|
||||
const tabCount =
|
||||
(!hideGroupsTab ? 1 : 0) +
|
||||
(showResources ? 1 : 0) +
|
||||
(showPeers ? 1 : 0) +
|
||||
(showClusters ? 1 : 0);
|
||||
(!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0);
|
||||
if (tabCount <= 1) return null;
|
||||
|
||||
const groupsTab = !hideGroupsTab && (
|
||||
@@ -809,28 +722,10 @@ const TabTriggers = ({
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const clustersTab = showClusters && (
|
||||
<TabsTrigger
|
||||
key="clusters"
|
||||
value={"clusters"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<ServerIcon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Proxy Clusters
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const tabMap: Record<PeerGroupSelectorTab, React.ReactNode> = {
|
||||
const tabMap = {
|
||||
groups: groupsTab,
|
||||
peers: peersTab,
|
||||
resources: resourcesTab,
|
||||
clusters: clustersTab,
|
||||
};
|
||||
|
||||
if (tabOrder) {
|
||||
@@ -846,7 +741,6 @@ const TabTriggers = ({
|
||||
{groupsTab}
|
||||
{resourcesTab}
|
||||
{peersTab}
|
||||
{clustersTab}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -1068,69 +962,6 @@ const ResourcesList = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ClustersList = ({
|
||||
clusters,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
clusters: ClusterOption[];
|
||||
value?: string;
|
||||
onChange: (cluster?: ClusterOption) => void;
|
||||
}) => {
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
No proxy clusters available. Go to{" "}
|
||||
<InlineLink href={"/reverse-proxy/custom-domains"}>
|
||||
Custom Domains
|
||||
</InlineLink>{" "}
|
||||
to configure one that supports private services.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value} name={"cluster"} value={value}>
|
||||
<ScrollArea
|
||||
className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}
|
||||
>
|
||||
{clusters.map((c) => (
|
||||
<CommandItem
|
||||
key={c.domain}
|
||||
value={c.domain}
|
||||
onSelect={() => onChange(c)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
useHover={false}
|
||||
variant={"gray-ghost"}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap h-7 px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ServerIcon size={12} className={"shrink-0"} />
|
||||
<TextWithTooltip text={c.label ?? c.domain} maxChars={32} />
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{c.label && c.label !== c.domain ? c.domain : null}
|
||||
<RadioItem value={c.domain} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
const peersSearchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Tests for Separator component class string.
|
||||
*
|
||||
* The PR changed the background class from "bg-zinc-700/40" to
|
||||
* "bg-nb-gray-800/40" so the separator follows the new theme-aware
|
||||
* nb-gray colour system instead of the hard-coded zinc colour.
|
||||
*
|
||||
* Because Separator is a trivial presentational component (no logic), we
|
||||
* verify the component source text directly rather than rendering it.
|
||||
*
|
||||
* Run with: npx tsx src/components/Separator.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
const source = readFileSync(join(__dir, "Separator.tsx"), "utf-8");
|
||||
|
||||
type Check = { desc: string; pattern: RegExp | string; shouldMatch: boolean };
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
desc: "uses nb-gray-800/40 background",
|
||||
pattern: "bg-nb-gray-800/40",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use the old zinc-700/40 background",
|
||||
pattern: "bg-zinc-700/40",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "still renders a span element",
|
||||
pattern: "<span",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "still includes height and width classes",
|
||||
pattern: "h-[1px]",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "w-full present",
|
||||
pattern: "w-full",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "block display class present",
|
||||
pattern: /\bblock\b/,
|
||||
shouldMatch: true,
|
||||
},
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
console.log("=== Separator source checks ===");
|
||||
|
||||
for (const { desc, pattern, shouldMatch } of checks) {
|
||||
const matched =
|
||||
pattern instanceof RegExp ? pattern.test(source) : source.includes(pattern as string);
|
||||
const ok = matched === shouldMatch;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldMatch=${shouldMatch}]`),
|
||||
);
|
||||
}
|
||||
|
||||
// Boundary: the class attribute should contain exactly the expected string
|
||||
const classMatch = source.match(/className=\{["']([^"']+)["']\}/);
|
||||
if (classMatch) {
|
||||
const classValue = classMatch[1];
|
||||
const ok = classValue.includes("bg-nb-gray-800/40") && !classValue.includes("bg-zinc-700/40");
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} className attribute has bg-nb-gray-800/40 and not bg-zinc-700/40` +
|
||||
(ok ? "" : ` (className: "${classValue}")`),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -13,7 +13,6 @@ type SettingCardItemProps = {
|
||||
description: React.ReactNode;
|
||||
enabled: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function SettingCardItem({
|
||||
@@ -21,31 +20,21 @@ function SettingCardItem({
|
||||
description,
|
||||
enabled,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: Readonly<SettingCardItemProps>) {
|
||||
const handleClick = () => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={handleClick}
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 transition-colors",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-nb-gray-935 cursor-pointer",
|
||||
)}
|
||||
className={
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
|
||||
}
|
||||
>
|
||||
<div className={"max-w-sm"}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -67,8 +56,7 @@ function SettingCardItem({
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<SquarePen size={12} />
|
||||
Edit
|
||||
@@ -78,8 +66,7 @@ function SettingCardItem({
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* Source-level regression tests for the light-mode class-name changes
|
||||
* introduced in this PR.
|
||||
*
|
||||
* These components had "dark:" prefixes removed, "text-white" replaced with
|
||||
* "text-nb-gray-100", or similar theme-agnostic rewrites. Because the
|
||||
* components import React and Next.js modules (preventing plain-Node
|
||||
* execution), we verify the source files directly to ensure the correct
|
||||
* class strings are present.
|
||||
*
|
||||
* Files covered:
|
||||
* - HelpText.tsx (dark: prefix removed from text colour)
|
||||
* - Input.tsx (hover:text-white → hover:text-nb-gray-100, dark: removed from prefix/suffix)
|
||||
* - PinCodeInput.tsx (dark: prefix removed from bg and border)
|
||||
* - Code.tsx (dark: prefix removed from text colour)
|
||||
* - FullTooltip.tsx (hover:text-neutral-100 → hover:text-nb-gray-100)
|
||||
* - Notification.tsx (text-white → text-nb-gray-100)
|
||||
* - PeerSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - NetworkRouteSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - PortSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - DatePickerWithRange.tsx (text-white → text-nb-gray-100 in CalendarButton)
|
||||
* - DataTableFilter.tsx (text-white → text-nb-gray-100)
|
||||
* - Modal.tsx (border-zinc-700/40 → border-neutral-200)
|
||||
*
|
||||
* Run with: npx tsx src/components/light-mode-class-changes.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
|
||||
function readSource(file: string): string {
|
||||
return readFileSync(join(__dir, file), "utf-8");
|
||||
}
|
||||
|
||||
type FileCheck = {
|
||||
file: string;
|
||||
checks: Array<{ desc: string; pattern: string | RegExp; shouldContain: boolean }>;
|
||||
};
|
||||
|
||||
const fileChecks: FileCheck[] = [
|
||||
// ── HelpText.tsx ───────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "HelpText.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "uses theme-agnostic text-nb-gray-300",
|
||||
pattern: "text-nb-gray-300",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT have dark:text-nb-gray-300",
|
||||
pattern: "dark:text-nb-gray-300",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Input.tsx ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Input.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "password toggle uses hover:text-nb-gray-100",
|
||||
pattern: "hover:text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "password toggle does NOT use hover:text-white",
|
||||
pattern: "hover:text-white",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "prefix div uses theme-agnostic text-nb-gray-300",
|
||||
pattern: /absolute left-0[^"]*text-xs text-nb-gray-300/,
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "prefix div does NOT use dark:text-nb-gray-300",
|
||||
pattern: "dark:text-nb-gray-300",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "suffix div uses theme-agnostic text-nb-gray-300",
|
||||
pattern: /absolute right-0[^"]*text-xs text-nb-gray-300/,
|
||||
shouldContain: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PinCodeInput.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PinCodeInput.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "input cell uses bg-nb-gray-900 without dark: prefix",
|
||||
pattern: /"bg-nb-gray-900 border border-nb-gray-700"/,
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use dark:bg-nb-gray-900 (old format)",
|
||||
pattern: "dark:bg-nb-gray-900 border dark:border-nb-gray-700",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Code.tsx ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Code.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "light (non-dark prop) text uses theme-agnostic text-nb-gray-200",
|
||||
pattern: '"text-nb-gray-200 hover:text-nb-gray-200"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use dark:text-nb-gray-200 for the non-dark prop path",
|
||||
pattern: "dark:text-nb-gray-200 hover:dark:text-nb-gray-200",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── FullTooltip.tsx ────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "FullTooltip.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "action wrapper uses hover:text-nb-gray-100",
|
||||
pattern: "hover:text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use hover:text-neutral-100",
|
||||
pattern: "hover:text-neutral-100",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notification.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Notification.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "icon wrapper uses text-nb-gray-100",
|
||||
pattern: "text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use bare text-white for icon",
|
||||
pattern: /shadow-sm text-white flex/,
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PeerSelector.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PeerSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "selected-value div uses text-nb-gray-100",
|
||||
pattern: "text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "selected-value wrapper string uses text-nb-gray-100",
|
||||
pattern: '"flex items-center justify-between text-sm text-nb-gray-100',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use bare text-white in selected value wrapper",
|
||||
pattern: '"flex items-center justify-between text-sm text-white',
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white anywhere for option/value selection",
|
||||
pattern: '"text-white"',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── NetworkRouteSelector.tsx ───────────────────────────────────────────────
|
||||
{
|
||||
file: "NetworkRouteSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "selected value text uses text-nb-gray-100",
|
||||
pattern: '"flex items-center justify-between text-sm text-nb-gray-100',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white in selected value wrapper",
|
||||
pattern: '"flex items-center justify-between text-sm text-white',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PortSelector.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PortSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "version callout uses text-nb-gray-100",
|
||||
pattern: '"text-nb-gray-100 font-normal"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white in version callout",
|
||||
pattern: '"text-white font-normal"',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Tooltip.tsx ────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Tooltip.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "uses text-nb-gray-50 (not text-neutral-50)",
|
||||
pattern: "text-nb-gray-50",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-neutral-50",
|
||||
pattern: "text-neutral-50",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "default variant does not include border-neutral-200",
|
||||
pattern: "border-neutral-200",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let failures = 0;
|
||||
for (const { file, checks } of fileChecks) {
|
||||
console.log(`\n=== ${file} ===`);
|
||||
let source: string;
|
||||
try {
|
||||
source = readSource(file);
|
||||
} catch {
|
||||
console.log(`✗ COULD NOT READ ${file}`);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { desc, pattern, shouldContain } of checks) {
|
||||
const matches =
|
||||
pattern instanceof RegExp ? pattern.test(source) : source.includes(pattern as string);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal.tsx is in a subdirectory ────────────────────────────────────────
|
||||
{
|
||||
console.log("\n=== modal/Modal.tsx ===");
|
||||
const modalSource = readFileSync(join(__dir, "modal", "Modal.tsx"), "utf-8");
|
||||
const modalChecks = [
|
||||
{
|
||||
desc: "SidebarModalContent uses border-neutral-200 in light mode",
|
||||
pattern: "border-neutral-200",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use border-zinc-700/40 as the primary border",
|
||||
// The old value was "border border-zinc-700/40"; it's now "border border-neutral-200"
|
||||
pattern: "border border-zinc-700/40",
|
||||
shouldContain: false,
|
||||
},
|
||||
];
|
||||
for (const { desc, pattern, shouldContain } of modalChecks) {
|
||||
const matches = modalSource.includes(pattern);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── table/DataTableFilter.tsx ─────────────────────────────────────────────
|
||||
{
|
||||
console.log("\n=== table/DataTableFilter.tsx ===");
|
||||
const dtfSource = readFileSync(
|
||||
join(__dir, "table", "DataTableFilter.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
const dtfChecks = [
|
||||
{
|
||||
desc: "filter count span uses text-nb-gray-100",
|
||||
pattern: '"text-nb-gray-100"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white for filter count span",
|
||||
pattern: '"text-white"',
|
||||
shouldContain: false,
|
||||
},
|
||||
];
|
||||
for (const { desc, pattern, shouldContain } of dtfChecks) {
|
||||
const matches = dtfSource.includes(pattern);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
Loaded 20 of 74 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user