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} {name}
{tooltip ? ( {tooltip ? (
<Tooltip> <Tooltip content={tooltip}>
<Info className="size-4" /> <Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip> </Tooltip>
) : undefined} ) : undefined}
</dt> </dt>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
<button <button
className="absolute right-0 bottom-0" className="absolute right-0 bottom-0"
onClick={async (event) => { 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"); const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) { for (const svg of svgs) {
+55 -52
View File
@@ -1,86 +1,89 @@
import { useRef } from "react"; import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria"; import type { ComponentProps, ReactNode } from "react";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
export interface TabsProps extends AriaTabListProps<object> { export interface TabsProps {
label: string; label: string;
className?: string; className?: string;
defaultValue?: string | number;
value?: string | number;
onValueChange?: (value: string | number) => void;
children: ReactNode;
} }
function Tabs({ label, className, ...props }: TabsProps) { function Tabs({ label, className, children, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return ( return (
<div className={cn("flex flex-col", className)}> <BaseTabs.Root
<div {...props}
{...tabListProps} defaultValue={props.defaultValue ?? 0}
className={cn( aria-label={label}
"flex items-center rounded-t-lg w-fit max-w-full overflow-x-auto", className={cn("flex flex-col", className)}
"border-mist-100 dark:border-mist-800", >
"border-t border-x", {children}
)} </BaseTabs.Root>
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
); );
} }
export interface TabsTabProps { function TabList({ children, className }: { children: ReactNode; className?: string }) {
item: Node<object>; return (
state: TabListState<object>; <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) { function Tab({
const { key, rendered } = item; value,
const ref = useRef<HTMLDivElement | null>(null); children,
className,
const { tabProps } = useTab({ key }, state, ref); ...props
}: ComponentProps<typeof BaseTabs.Tab> & { className?: string }) {
return ( return (
<div <BaseTabs.Tab
{...tabProps} value={value}
{...props}
className={cn( className={cn(
"pl-2 pr-3 py-2.5", "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", "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", "dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"border-r border-mist-100 dark:border-mist-800", "border-r border-mist-100 dark:border-mist-800",
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0", "first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
className,
)} )}
ref={ref}
> >
{rendered} {children}
</div> </BaseTabs.Tab>
); );
} }
export interface TabsPanelProps extends AriaTabPanelProps { function Panel({
state: TabListState<object>; value,
} children,
className,
function TabsPanel({ state, ...props }: TabsPanelProps) { ...props
const ref = useRef<HTMLDivElement | null>(null); }: ComponentProps<typeof BaseTabs.Panel> & { className?: string }) {
const { tabPanelProps } = useTabPanel(props, state, ref);
return ( return (
<div <BaseTabs.Panel
{...tabPanelProps} value={value}
{...props}
className={cn( className={cn(
"w-full overflow-clip rounded-b-lg rounded-r-lg", "w-full overflow-clip rounded-b-lg rounded-r-lg",
"border border-mist-100 dark:border-mist-800", "border border-mist-100 dark:border-mist-800",
className,
)} )}
ref={ref}
> >
{state.selectedItem?.props.children} {children}
</div> </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) { export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return ( return (
<Tooltip> <Tooltip
<Chip content={
text="Exit Node" isEnabled ? (
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 ? (
<>This machine is acting as an exit node.</> <>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 This machine is requesting to be used as an exit node. Review this from the "Edit route
settings..." option in the machine's menu. 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> </Tooltip>
); );
} }
+12 -11
View File
@@ -14,23 +14,24 @@ export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
}); });
return ( return (
<Tooltip> <Tooltip
<Chip content={
text={ variant === "expired" ? (
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" ? (
<> <>
This machine is expired and will not be able to connect to the network. Re-authenticate 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. with Tailscale on the machine to re-enable it.
</> </>
) : ( ) : (
<>This machine has key expiry disabled and will never need to re-authenticate.</> <>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> </Tooltip>
); );
} }
+1 -5
View File
@@ -5,15 +5,11 @@ import Tooltip from "../tooltip";
export function HeadplaneAgentTag() { export function HeadplaneAgentTag() {
return ( return (
<Tooltip> <Tooltip content="This machine is running the Headplane agent, which allows it to provide host information in the web UI.">
<Chip <Chip
text="Headplane Agent" text="Headplane Agent"
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")} 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> </Tooltip>
); );
} }
+11 -10
View File
@@ -11,22 +11,23 @@ export interface SubnetTagProps {
export function SubnetTag({ isEnabled }: SubnetTagProps) { export function SubnetTag({ isEnabled }: SubnetTagProps) {
return ( return (
<Tooltip> <Tooltip
<Chip content={
text="Subnets" isEnabled ? (
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 ? (
<>This machine advertises subnet routes.</> <>This machine advertises subnet routes.</>
) : ( ) : (
<> <>
This machine has unadvertised subnet routes. Review this from the "Edit route This machine has unadvertised subnet routes. Review this from the "Edit route
settings..." option in the machine's menu. 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> </Tooltip>
); );
} }
+1 -5
View File
@@ -5,15 +5,11 @@ import Tooltip from "../tooltip";
export function TailscaleSSHTag() { export function TailscaleSSHTag() {
return ( 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 <Chip
text="Tailscale SSH" text="Tailscale SSH"
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")} 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> </Tooltip>
); );
} }
+36 -71
View File
@@ -1,80 +1,45 @@
import React, { cloneElement, useRef } from "react"; import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import { AriaTooltipProps, mergeProps, useTooltip, useTooltipTrigger } from "react-aria"; import type { ReactNode } from "react";
import { TooltipTriggerState, useTooltipTriggerState } from "react-stately";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
export interface TooltipProps extends AriaTooltipProps { export interface TooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>]; children: ReactNode;
} content: ReactNode;
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;
className?: string; className?: string;
} }
function Body({ state, className, ...props }: TooltipBodyProps) { export default function Tooltip({ children, content, className }: TooltipProps) {
const { tooltipProps } = useTooltip(props, state);
return ( return (
<span <BaseTooltip.Root>
{...mergeProps(props, tooltipProps)} <BaseTooltip.Trigger
className={cn( delay={0}
"absolute z-50 p-3 top-full mt-1", closeDelay={0}
"outline-hidden rounded-lg text-sm w-48", className={cn(
"bg-white dark:bg-mist-950", "inline-flex items-center justify-center rounded-md",
"text-black dark:text-white", "focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"shadow-overlay", "dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"border border-mist-100 dark:border-mist-800", )}
className, >
)} {children}
> </BaseTooltip.Trigger>
{props.children} <BaseTooltip.Portal>
</span> <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 Link from "~/components/link";
import Notice from "~/components/notice"; import Notice from "~/components/notice";
import PageError from "~/components/page-error"; 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 { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast"; 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."} "An unknown error occurred while trying to update the ACL policy."}
</Notice> </Notice>
) : undefined} ) : undefined}
<Tabs className="mb-4" label="ACL Editor"> <Tabs className="mb-4" label="ACL Editor" defaultValue="edit">
<Tabs.Item <TabsList>
key="edit" <TabsTab value="edit">
title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="p-1" /> <Pencil className="p-1" />
<span>Edit file</span> <span>Edit file</span>
</div> </div>
} </TabsTab>
> <TabsTab value="diff">
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Eye className="p-1" /> <Eye className="p-1" />
<span>Preview changes</span> <span>Preview changes</span>
</div> </div>
} </TabsTab>
> <TabsTab value="preview">
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FlaskConical className="p-1" /> <FlaskConical className="p-1" />
<span>Preview rules</span> <span>Preview rules</span>
</div> </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"> <div className="flex flex-col items-center py-8">
<Construction /> <Construction />
<p className="mt-4 w-1/2 text-center"> <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. pretty complicated to implement. Hopefully I will be able to get to it soon.
</p> </p>
</div> </div>
</Tabs.Item> </TabsPanel>
</Tabs> </Tabs>
<Button <Button
className="mr-2" 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"> <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> <h2 className="text-md font-medium opacity-80">Global Nameservers</h2>
<div className="flex items-center gap-2 text-sm"> <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" /> <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> </Tooltip>
<p>Override DNS servers</p> <p>Override DNS servers</p>
<Switch <Switch
+1 -5
View File
@@ -61,16 +61,12 @@ export default function AddNameserver({ nameservers }: Props) {
<div className="block"> <div className="block">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<Text className="font-semibold">Restrict to domain</Text> <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 <Chip
className={cn("inline-flex items-center")} className={cn("inline-flex items-center")}
leftIcon={<Split className="mr-0.5 h-3 w-3" />} leftIcon={<Split className="mr-0.5 h-3 w-3" />}
text="Split DNS" 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> </Tooltip>
</div> </div>
<Text className="text-sm">This nameserver will only be used for some domains.</Text> <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"> <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"> <span className="flex items-center gap-x-1 text-sm text-mist-600 dark:text-mist-300">
Managed by Managed by
<Tooltip> <Tooltip content="By default, a machines permissions match its creators.">
<Info className="p-1" /> <Info className="p-1" />
<Tooltip.Body>By default, a machines permissions match its creators.</Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1 flex items-center gap-x-2.5"> <div className="mt-1 flex items-center gap-x-2.5">
@@ -154,11 +153,8 @@ export default function Page({
<div> <div>
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300"> <span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Approved Approved
<Tooltip> <Tooltip content="Traffic to these routes are being routed through this machine.">
<Info className="h-3.5 w-3.5" /> <Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
@@ -183,12 +179,8 @@ export default function Page({
<div> <div>
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300"> <span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Awaiting Approval 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" /> <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> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
@@ -213,11 +205,8 @@ export default function Page({
<div> <div>
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300"> <span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Exit Node 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" /> <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> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
+12 -9
View File
@@ -271,16 +271,19 @@ export default function Page({ loaderData }: Route.ComponentProps) {
))} ))}
</button> </button>
{loaderData.magic ? ( {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" /> <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> </Tooltip>
) : undefined} ) : undefined}
</div> </div>