Compare commits

..
Author SHA1 Message Date
braginini 5be79b9955 Add new loading indicator 2026-05-08 16:11:51 +02:00
74 changed files with 523 additions and 4732 deletions

No files matched your search

+1 -4
View File
@@ -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>
+114 -3
View File
@@ -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;
-115
View File
@@ -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;
-122
View File
@@ -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>
);
}
-9
View File
@@ -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} />}
+89 -4
View File
@@ -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>
);
}
-238
View File
@@ -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);
+25 -92
View File
@@ -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>
);
}
}
-237
View File
@@ -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);
-129
View File
@@ -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;
+1 -7
View File
@@ -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
-130
View File
@@ -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);
+6 -175
View File
@@ -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;
-84
View File
@@ -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);
+7 -20
View File
@@ -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