feat: update some components

This commit is contained in:
Aarnav Tale
2026-03-19 14:33:12 -04:00
parent 873e6005d9
commit 54f236a332
14 changed files with 176 additions and 227 deletions
+1 -2
View File
@@ -24,9 +24,8 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
>
{name}
{tooltip ? (
<Tooltip>
<Tooltip content={tooltip}>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
<button
className="absolute right-0 bottom-0"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join("") : children;
const text = Array.isArray(children) ? children.join("") : String(children);
const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) {
+55 -52
View File
@@ -1,86 +1,89 @@
import { useRef } from "react";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import type { ComponentProps, ReactNode } from "react";
import cn from "~/utils/cn";
export interface TabsProps extends AriaTabListProps<object> {
export interface TabsProps {
label: string;
className?: string;
defaultValue?: string | number;
value?: string | number;
onValueChange?: (value: string | number) => void;
children: ReactNode;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
function Tabs({ label, className, children, ...props }: TabsProps) {
return (
<div className={cn("flex flex-col", className)}>
<div
{...tabListProps}
className={cn(
"flex items-center rounded-t-lg w-fit max-w-full overflow-x-auto",
"border-mist-100 dark:border-mist-800",
"border-t border-x",
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
<BaseTabs.Root
{...props}
defaultValue={props.defaultValue ?? 0}
aria-label={label}
className={cn("flex flex-col", className)}
>
{children}
</BaseTabs.Root>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
function TabList({ children, className }: { children: ReactNode; className?: string }) {
return (
<BaseTabs.List
className={cn(
"flex items-center rounded-t-lg w-fit max-w-full",
"border-mist-100 dark:border-mist-800",
"border-t border-x",
className,
)}
>
{children}
</BaseTabs.List>
);
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
function Tab({
value,
children,
className,
...props
}: ComponentProps<typeof BaseTabs.Tab> & { className?: string }) {
return (
<div
{...tabProps}
<BaseTabs.Tab
value={value}
{...props}
className={cn(
"pl-2 pr-3 py-2.5",
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
"data-[selected]:bg-mist-100 dark:data-[selected]:bg-mist-950",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"border-r border-mist-100 dark:border-mist-800",
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
className,
)}
ref={ref}
>
{rendered}
</div>
{children}
</BaseTabs.Tab>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
function Panel({
value,
children,
className,
...props
}: ComponentProps<typeof BaseTabs.Panel> & { className?: string }) {
return (
<div
{...tabPanelProps}
<BaseTabs.Panel
value={value}
{...props}
className={cn(
"w-full overflow-clip rounded-b-lg rounded-r-lg",
"border border-mist-100 dark:border-mist-800",
className,
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
{children}
</BaseTabs.Panel>
);
}
export default Object.assign(Tabs, { Item });
export { Tabs, TabList as TabsList, Tab as TabsTab, Panel as TabsPanel };
+11 -10
View File
@@ -11,22 +11,23 @@ export interface ExitNodeTagProps {
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<Tooltip
content={
isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this from the "Edit route
settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
)
}
>
<Chip
text="Exit Node"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
</Tooltip>
);
}
+12 -11
View File
@@ -14,23 +14,24 @@ export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
});
return (
<Tooltip>
<Chip
text={
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
}
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
/>
<Tooltip.Body>
{variant === "expired" ? (
<Tooltip
content={
variant === "expired" ? (
<>
This machine is expired and will not be able to connect to the network. Re-authenticate
with Tailscale on the machine to re-enable it.
</>
) : (
<>This machine has key expiry disabled and will never need to re-authenticate.</>
)}
</Tooltip.Body>
)
}
>
<Chip
text={
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
}
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
/>
</Tooltip>
);
}
+1 -5
View File
@@ -5,15 +5,11 @@ import Tooltip from "../tooltip";
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Tooltip content="This machine is running the Headplane agent, which allows it to provide host information in the web UI.">
<Chip
text="Headplane Agent"
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide host information in
the web UI.
</Tooltip.Body>
</Tooltip>
);
}
+11 -10
View File
@@ -11,22 +11,23 @@ export interface SubnetTagProps {
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<Tooltip
content={
isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the "Edit route
settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
)
}
>
<Chip
text="Subnets"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
</Tooltip>
);
}
+1 -5
View File
@@ -5,15 +5,11 @@ import Tooltip from "../tooltip";
export function TailscaleSSHTag() {
return (
<Tooltip>
<Tooltip content="This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials using your Tailscale account and via the Headplane web UI.">
<Chip
text="Tailscale SSH"
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials
using your Tailscale account and via the Headplane web UI.
</Tooltip.Body>
</Tooltip>
);
}
+36 -71
View File
@@ -1,80 +1,45 @@
import React, { cloneElement, useRef } from "react";
import { AriaTooltipProps, mergeProps, useTooltip, useTooltipTrigger } from "react-aria";
import { TooltipTriggerState, useTooltipTriggerState } from "react-stately";
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import type { ReactNode } from "react";
import cn from "~/utils/cn";
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
"flex items-center justify-center",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 rounded-md",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
export default function Tooltip({ children, content, className }: TooltipProps) {
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
"absolute z-50 p-3 top-full mt-1",
"outline-hidden rounded-lg text-sm w-48",
"bg-white dark:bg-mist-950",
"text-black dark:text-white",
"shadow-overlay",
"border border-mist-100 dark:border-mist-800",
className,
)}
>
{props.children}
</span>
<BaseTooltip.Root>
<BaseTooltip.Trigger
delay={0}
closeDelay={0}
className={cn(
"inline-flex items-center justify-center rounded-md",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
>
{children}
</BaseTooltip.Trigger>
<BaseTooltip.Portal>
<BaseTooltip.Positioner sideOffset={4}>
<BaseTooltip.Popup
className={cn(
"z-50 rounded-lg p-3 text-sm w-48",
"outline-hidden",
"bg-white dark:bg-mist-950",
"text-black dark:text-white",
"shadow-overlay",
"border border-mist-100 dark:border-mist-800",
className,
)}
>
{content}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
</BaseTooltip.Root>
);
}
export default Object.assign(Tooltip, {
Body,
});
+18 -22
View File
@@ -8,7 +8,7 @@ import Code from "~/components/code";
import Link from "~/components/link";
import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import Tabs from "~/components/tabs";
import { Tabs, TabsList, TabsPanel, TabsTab } from "~/components/tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast";
@@ -79,38 +79,34 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
"An unknown error occurred while trying to update the ACL policy."}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<Tabs className="mb-4" label="ACL Editor" defaultValue="edit">
<TabsList>
<TabsTab value="edit">
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
</TabsTab>
<TabsTab value="diff">
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
</TabsTab>
<TabsTab value="preview">
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</TabsPanel>
<TabsPanel value="diff">
<Differ left={policy} right={codePolicy} />
</TabsPanel>
<TabsPanel value="preview">
<div className="flex flex-col items-center py-8">
<Construction />
<p className="mt-4 w-1/2 text-center">
@@ -118,7 +114,7 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
pretty complicated to implement. Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</TabsPanel>
</Tabs>
<Button
className="mr-2"
+12 -9
View File
@@ -67,16 +67,19 @@ function NameserverList({ isGlobal, isDisabled, nameservers, overrideLocalDns, n
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-md font-medium opacity-80">Global Nameservers</h2>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Tooltip
content={
<>
When enabled, use the DNS servers listed below to resolve names outside the
tailnet. When disabled (default), devices will prefer their local DNS
configuration.{" "}
<Link external styled to="https://tailscale.com/kb/1054/dns#global-nameservers">
Learn More
</Link>
</>
}
>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve names outside the
tailnet. When disabled (default), devices will prefer their local DNS
configuration.
<Link external styled to="https://tailscale.com/kb/1054/dns#global-nameservers">
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
+1 -5
View File
@@ -61,16 +61,12 @@ export default function AddNameserver({ nameservers }: Props) {
<div className="block">
<div className="inline-flex items-center gap-2">
<Text className="font-semibold">Restrict to domain</Text>
<Tooltip>
<Tooltip content="Only clients that support split DNS (Tailscale v1.8 or later for most platforms) will use this nameserver. Older clients will ignore it.">
<Chip
className={cn("inline-flex items-center")}
leftIcon={<Split className="mr-0.5 h-3 w-3" />}
text="Split DNS"
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Text className="text-sm">This nameserver will only be used for some domains.</Text>
+4 -15
View File
@@ -113,9 +113,8 @@ export default function Page({
<div className="border-r border-mist-100 p-2 pr-4 dark:border-mist-800">
<span className="flex items-center gap-x-1 text-sm text-mist-600 dark:text-mist-300">
Managed by
<Tooltip>
<Tooltip content="By default, a machines permissions match its creators.">
<Info className="p-1" />
<Tooltip.Body>By default, a machines permissions match its creators.</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1 flex items-center gap-x-2.5">
@@ -154,11 +153,8 @@ export default function Page({
<div>
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Approved
<Tooltip>
<Tooltip content="Traffic to these routes are being routed through this machine.">
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
@@ -183,12 +179,8 @@ export default function Page({
<div>
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Awaiting Approval
<Tooltip>
<Tooltip content="This machine is advertising these routes, but they must be approved before traffic will be routed to them.">
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
This machine is advertising these routes, but they must be approved before traffic
will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
@@ -213,11 +205,8 @@ export default function Page({
<div>
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Exit Node
<Tooltip>
<Tooltip content="Whether this machine can act as an exit node for your tailnet.">
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
+12 -9
View File
@@ -271,16 +271,19 @@ export default function Page({ loaderData }: Route.ComponentProps) {
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Tooltip
content={
<span className="font-normal">
Since MagicDNS is enabled, you can access devices based on their name and
also at{" "}
<Code>
[name].
{loaderData.magic}
</Code>
</span>
}
>
<Info className="h-4 w-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on their name and
also at{" "}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>