mirror of
https://github.com/tale/headplane.git
synced 2026-10-25 22:20:30 +00:00
feat(ui): redesign machines list and detail pages
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
|
||||
- Headplane has a new color palette built from its logo: warm greys, black primary buttons, and a muted blue that marks links, focus, and the current page. Dark mode now separates the page, menus, and dialogs by shade so overlays stand out, and secondary text is easier to read in both themes (via [#651](https://github.com/tale/headplane/issues/651)).
|
||||
- Dialogs can now be closed by clicking outside them, except the ones that confirm a destructive action like removing a machine.
|
||||
- The navigation bar is slimmer, and on small screens the pages now live in a menu instead of a second row of tabs.
|
||||
- The machines list is easier to scan: each machine shows its status next to its name, its tags and badges on one line, and both of its addresses. Badges only use color when something needs attention, like an expired key, a key expiring within a week, or routes waiting for approval. Click an address to copy it (via [#654](https://github.com/tale/headplane/issues/654)).
|
||||
- With the Headplane agent enabled, the machines list marks clients running an older Tailscale release than the latest stable one. Headplane checks pkgs.tailscale.com for the latest version once a day in the background.
|
||||
- The machine page has a simpler layout, with routing and network details shown as plain lists instead of boxed cards.
|
||||
|
||||
# 0.8.0 (October 13, 2026)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Check, Copy, Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
import { copyToClipboard } from "~/utils/copy";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
@@ -15,14 +14,8 @@ export interface AttributeProps {
|
||||
|
||||
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
|
||||
return (
|
||||
<dl className="group/attr flex items-baseline gap-1 text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
|
||||
"text-muted",
|
||||
tooltip ? "flex items-baseline gap-1" : undefined,
|
||||
)}
|
||||
>
|
||||
<div className="group/attr flex items-baseline gap-4 py-2 text-sm">
|
||||
<dt className="text-muted flex w-1/3 min-w-0 shrink-0 items-baseline gap-1 sm:w-40">
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip content={tooltip}>
|
||||
@@ -30,18 +23,11 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
|
||||
...(isCopyable
|
||||
? ["cursor-pointer hover:shadow-xs", "hover:bg-hover", "hover:border-line"]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
<dd className="min-w-0 flex-1">
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex w-full min-w-0 items-center gap-1.5"
|
||||
className="focus-ring hover:bg-hover relative -mx-1 flex max-w-full min-w-0 items-center gap-1.5 rounded-sm px-1"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll("svg");
|
||||
const isCopied = await copyToClipboard(value);
|
||||
@@ -54,7 +40,7 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
|
||||
svg.toggleAttribute("data-copied", true);
|
||||
}
|
||||
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
toast(`Copied ${name}`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
@@ -85,6 +71,6 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-5
@@ -2,21 +2,33 @@ import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
neutral: "bg-subtle text-muted border-line",
|
||||
warn: "bg-warn-subtle text-warn border-transparent",
|
||||
bad: "bg-bad-subtle text-bad border-transparent",
|
||||
};
|
||||
|
||||
export interface ChipProps {
|
||||
text: string;
|
||||
variant?: keyof typeof variants;
|
||||
className?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) {
|
||||
export default function Chip({
|
||||
text,
|
||||
variant = "neutral",
|
||||
className,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
|
||||
"text-fg",
|
||||
"bg-subtle",
|
||||
"inline-flex items-center gap-x-1",
|
||||
"inline-flex h-[18px] items-center gap-x-1 rounded-sm border px-1.5",
|
||||
"text-[11px] font-medium text-nowrap",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,18 +2,22 @@ import cn from "~/utils/cn";
|
||||
|
||||
export interface StatusCircleProps {
|
||||
isOnline: boolean;
|
||||
isExpired?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
|
||||
export default function StatusCircle({ isOnline, isExpired, className }: StatusCircleProps) {
|
||||
const label = isExpired ? "Expired" : isOnline ? "Online" : "Offline";
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={cn(isOnline ? "text-ok" : "text-faint", className)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>{isOnline ? "Online" : "Offline"}</title>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
<span
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-block size-[7px] shrink-0 rounded-full",
|
||||
isExpired ? "bg-bad" : isOnline ? "bg-ok" : "bg-faint",
|
||||
className,
|
||||
)}
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
@@ -17,17 +13,13 @@ export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||
<>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.
|
||||
This machine is requesting to be used as an exit node. Review it from "Edit route
|
||||
settings" in the machine's menu.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn("bg-accent-subtle text-accent")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Chip text="Exit node" variant={isEnabled ? "neutral" : "warn"} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
import { formatTimeUntil } from "~/utils/time";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExpiryTagProps {
|
||||
variant: "expired" | "no-expiry";
|
||||
variant: "expired" | "expiring" | "no-expiry";
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<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.</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text={
|
||||
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
|
||||
}
|
||||
className="bg-hover text-fg"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||
const date = expiry ? formatter.format(new Date(expiry)) : undefined;
|
||||
|
||||
switch (variant) {
|
||||
case "expired":
|
||||
return (
|
||||
<Tooltip
|
||||
content={`This machine's key expired on ${date}. Re-authenticate with Tailscale on the machine to reconnect it.`}
|
||||
>
|
||||
<Chip text="Expired" variant="bad" />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case "expiring":
|
||||
return (
|
||||
<Tooltip
|
||||
content={`This machine's key expires on ${date}. Re-authenticate with Tailscale on the machine before then to stay connected.`}
|
||||
>
|
||||
<Chip text={`Key expires ${formatTimeUntil(new Date(expiry!))}`} variant="warn" />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case "no-expiry":
|
||||
return (
|
||||
<Tooltip content="This machine has key expiry disabled and never needs to re-authenticate.">
|
||||
<Chip text="No expiry" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function HeadplaneAgentTag() {
|
||||
return (
|
||||
<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-accent-subtle text-accent")} />
|
||||
<Chip text="Agent" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface SubnetTagProps {
|
||||
isEnabled?: boolean;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
export function SubnetTag({ isEnabled, count }: SubnetTagProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -17,16 +14,15 @@ export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
This machine has subnet routes waiting for approval. Review them from "Edit route
|
||||
settings" in the machine's menu.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn("bg-accent-subtle text-accent")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
text={`${count} ${count === 1 ? "subnet" : "subnets"}`}
|
||||
variant={isEnabled ? "neutral" : "warn"}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function TailscaleSSHTag() {
|
||||
return (
|
||||
<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-ok-subtle text-ok")} />
|
||||
<Chip text="SSH" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
import type { User } from "~/types/User";
|
||||
import cn from "~/utils/cn";
|
||||
@@ -38,16 +39,20 @@ function FilterDropdown({
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium",
|
||||
"border transition-colors",
|
||||
"flex items-center gap-1.5",
|
||||
"h-8 gap-1 rounded-md border px-2.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
? "border-accent/30 bg-accent-subtle text-accent"
|
||||
: "border-line text-fg hover:border-line-strong",
|
||||
? "bg-accent-subtle text-fg border-transparent"
|
||||
: "border-line-strong text-muted hover:text-fg border-dashed",
|
||||
)}
|
||||
>
|
||||
{activeOption?.label ?? label}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
{isActive ? (
|
||||
<>
|
||||
{label}: <span className="text-accent font-medium">{activeOption.label}</span>
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
<ChevronDown className="size-3" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{options.map((option) => (
|
||||
@@ -131,19 +136,10 @@ export function MachineFilters({ users, populatedNodes }: MachineFiltersProps):
|
||||
value={filterRoute}
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium",
|
||||
"border border-line",
|
||||
"text-muted",
|
||||
"hover:border-line-strong",
|
||||
)}
|
||||
onClick={clearFilters}
|
||||
type="button"
|
||||
>
|
||||
<Button onClick={clearFilters} variant="ghost">
|
||||
Clear filters
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronDown, Copy } from "lucide-react";
|
||||
import { ArrowUp, Copy } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import Chip from "~/components/chip";
|
||||
import Link from "~/components/link";
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
@@ -11,14 +10,14 @@ import { ExpiryTag } from "~/components/tags/Expiry";
|
||||
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
|
||||
import { SubnetTag } from "~/components/tags/Subnet";
|
||||
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import type { User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
import { copyToClipboard } from "~/utils/copy";
|
||||
import * as hinfo from "~/utils/host-info";
|
||||
import { isNoExpiry, type PopulatedNode } from "~/utils/node-info";
|
||||
import { formatTimeDelta } from "~/utils/time";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
import { getUserDisplayName, isTaggedDevicesUser } from "~/utils/user";
|
||||
|
||||
import MenuOptions from "./menu";
|
||||
|
||||
@@ -26,6 +25,7 @@ interface Props {
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
isAgent?: boolean;
|
||||
latestVersion?: string;
|
||||
magic?: string;
|
||||
isDisabled?: boolean;
|
||||
existingTags?: string[];
|
||||
@@ -38,6 +38,7 @@ export default function MachineRow({
|
||||
node,
|
||||
users,
|
||||
isAgent,
|
||||
latestVersion,
|
||||
magic,
|
||||
isDisabled,
|
||||
existingTags,
|
||||
@@ -47,66 +48,98 @@ export default function MachineRow({
|
||||
}: Props) {
|
||||
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
const addresses = useMemo(() => {
|
||||
const list = node.ipAddresses.map((ip) => ({
|
||||
label: ip.includes(":") ? "IPv6" : "IPv4",
|
||||
value: ip,
|
||||
}));
|
||||
|
||||
list.sort((a, b) => a.label.localeCompare(b.label));
|
||||
if (magic) {
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
list.push({ label: "MagicDNS", value: `${node.givenName}.${magic}` });
|
||||
}
|
||||
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
return list;
|
||||
}, [magic, node.givenName, node.ipAddresses]);
|
||||
|
||||
const isConnected = node.online && !node.expired;
|
||||
const [ipv4, ipv6] = [
|
||||
addresses.find((a) => a.label === "IPv4"),
|
||||
addresses.find((a) => a.label === "IPv6"),
|
||||
];
|
||||
|
||||
return (
|
||||
<tr className="group" key={node.id}>
|
||||
<tr className="group">
|
||||
<TableCell>
|
||||
<Link className="group/link focus-ring rounded-sm" to={`/machines/${node.id}`}>
|
||||
<p className="group-hover/link:text-accent font-medium">{node.givenName}</p>
|
||||
<p className="text-muted">{node.user ? getUserDisplayName(node.user) : "Tag-owned"}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.tags?.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
<span className="group-hover/link:text-accent flex items-center gap-2 font-medium">
|
||||
<StatusCircle isExpired={node.expired} isOnline={isConnected} />
|
||||
{node.givenName}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="text-muted mt-0.5 ml-[15px] flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{node.user && !(isTaggedDevicesUser(node.user) && node.tags?.length) ? (
|
||||
<span>{getUserDisplayName(node.user)}</span>
|
||||
) : undefined}
|
||||
{node.tags?.map((tag) => (
|
||||
<span className="font-mono" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-1 font-mono">
|
||||
{node.ipAddresses[0]}
|
||||
<Menu>
|
||||
<MenuTrigger className="text-muted hover:bg-hover hover:text-fg w-auto rounded-sm p-0.5">
|
||||
<ChevronDown className="size-3.5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
{ipOptions.map((ip) => (
|
||||
<MenuItem
|
||||
key={ip}
|
||||
onClick={async () => {
|
||||
const isCopied = await copyToClipboard(ip);
|
||||
toast(
|
||||
isCopied
|
||||
? "Copied IP address to clipboard"
|
||||
: "Copy failed. Please copy the address manually.",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
|
||||
>
|
||||
{ip}
|
||||
<Copy className="h-3 w-3" />
|
||||
<Menu>
|
||||
<MenuTrigger className="hover:bg-hover -mx-1 -my-0.5 cursor-pointer flex-col items-start rounded-sm px-1 py-0.5 font-mono text-xs leading-5">
|
||||
{ipv4 ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{ipv4.value}
|
||||
<Copy className="text-muted size-3 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100" />
|
||||
</span>
|
||||
) : undefined}
|
||||
{ipv6 ? <span className="text-muted">{ipv6.value}</span> : undefined}
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{addresses.map(({ label, value }) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
onClick={async () => {
|
||||
const isCopied = await copyToClipboard(value);
|
||||
toast(isCopied ? `Copied ${value}` : "Copy failed. Copy the address manually.");
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-x-6">
|
||||
<div>
|
||||
<p className="text-muted text-xs">{label}</p>
|
||||
<p className="font-mono text-sm">{value}</p>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
<Copy className="text-muted size-3.5" />
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</TableCell>
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<TableCell>
|
||||
<TableCell className="text-xs leading-5">
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p>{hinfo.getTSVersion(node.hostInfo)}</p>
|
||||
<p className="flex items-center gap-x-1 font-mono">
|
||||
{hinfo.getTSVersion(node.hostInfo)}
|
||||
{latestVersion && hinfo.isUpdateAvailable(node.hostInfo, latestVersion) ? (
|
||||
<Tooltip content={`Tailscale ${latestVersion} is available.`}>
|
||||
<span className="bg-warn-subtle text-warn inline-flex size-4 items-center justify-center rounded-full">
|
||||
<ArrowUp
|
||||
aria-label="Update available"
|
||||
className="size-3"
|
||||
strokeWidth={2.75}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</p>
|
||||
<p className="text-muted max-w-48 truncate">{hinfo.getOSInfo(node.hostInfo)}</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -114,24 +147,20 @@ export default function MachineRow({
|
||||
)}
|
||||
</TableCell>
|
||||
) : undefined}
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-x-1">
|
||||
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
||||
<div>
|
||||
<p className="text-muted" suppressHydrationWarning>
|
||||
{node.online && !node.expired
|
||||
? "Connected"
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
{!(node.online && !node.expired) && (
|
||||
<p className="text-muted text-xs" suppressHydrationWarning>
|
||||
{formatTimeDelta(new Date(node.lastSeen))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{isConnected ? (
|
||||
<span className="text-ok">Connected</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-muted"
|
||||
suppressHydrationWarning
|
||||
title={new Date(node.lastSeen).toLocaleString()}
|
||||
>
|
||||
{formatTimeDelta(new Date(node.lastSeen))}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-0">
|
||||
<TableCell className="pr-0 align-middle">
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
policyTags={policyTags}
|
||||
@@ -147,6 +176,8 @@ export default function MachineRow({
|
||||
);
|
||||
}
|
||||
|
||||
const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
@@ -157,6 +188,13 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
uiTags.push("no-expiry");
|
||||
}
|
||||
|
||||
if (!node.expired && !isNoExpiry(node.expiry)) {
|
||||
const remaining = new Date(node.expiry!).getTime() - Date.now();
|
||||
if (remaining < EXPIRING_SOON_MS) {
|
||||
uiTags.push("expiring");
|
||||
}
|
||||
}
|
||||
|
||||
if (node.customRouting.exitRoutes.length > 0) {
|
||||
if (node.customRouting.exitApproved) {
|
||||
uiTags.push("exit-approved");
|
||||
@@ -192,10 +230,20 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||
|
||||
case "subnet-approved":
|
||||
case "subnet-waiting": {
|
||||
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
|
||||
return (
|
||||
<SubnetTag
|
||||
count={
|
||||
node.customRouting.subnetApprovedRoutes.length +
|
||||
node.customRouting.subnetWaitingRoutes.length
|
||||
}
|
||||
isEnabled={tag === "subnet-approved"}
|
||||
key={tag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "expired":
|
||||
case "expiring":
|
||||
case "no-expiry": {
|
||||
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
|
||||
import { ChevronDown, Ellipsis, SquareTerminal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
@@ -44,7 +44,7 @@ export default function MachineMenu({
|
||||
const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 px-4">
|
||||
<div className={cn("flex items-center justify-end gap-1.5", !isFullButton && "px-4")}>
|
||||
{modal === "remove" && (
|
||||
<Delete
|
||||
isOpen={modal === "remove"}
|
||||
@@ -128,7 +128,7 @@ export default function MachineMenu({
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
<SquareTerminal className="size-4" />
|
||||
<SquareTerminal className="size-3.5" />
|
||||
SSH
|
||||
</Button>
|
||||
) : (
|
||||
@@ -155,17 +155,17 @@ export default function MachineMenu({
|
||||
<MenuTrigger
|
||||
className={
|
||||
isFullButton
|
||||
? "border-line-strong bg-surface hover:bg-hover gap-x-2 rounded-md border px-3.5 py-2 text-sm font-medium"
|
||||
: "hover:bg-hover w-10 rounded-full bg-transparent p-1"
|
||||
? "border-line-strong bg-surface hover:bg-hover h-8 gap-x-1.5 rounded-md border px-3 text-sm font-medium"
|
||||
: "text-muted hover:bg-hover hover:text-fg size-7 rounded-md"
|
||||
}
|
||||
>
|
||||
{isFullButton ? (
|
||||
<>
|
||||
<Cog className="h-5" />
|
||||
<p>Machine Settings</p>
|
||||
Manage
|
||||
<ChevronDown className="size-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<Ellipsis className="h-5" />
|
||||
<Ellipsis className="size-4" />
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Computer, FileKey2 } from "lucide-react";
|
||||
import { Computer, FileKey2, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
@@ -59,8 +59,9 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
/>
|
||||
</ActionDialog>
|
||||
<Menu disabled={data.isDisabled}>
|
||||
<MenuTrigger className="bg-ink text-ink-fg hover:bg-ink/90 rounded-md px-3.5 py-2 text-sm font-medium">
|
||||
Add Device
|
||||
<MenuTrigger className="bg-ink text-ink-fg hover:bg-ink/90 h-8 gap-1.5 rounded-md px-3 text-sm font-medium">
|
||||
<Plus className="size-3.5" />
|
||||
Add machine
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem
|
||||
|
||||
+174
-229
@@ -1,14 +1,11 @@
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { data } from "react-router";
|
||||
|
||||
import Attribute from "~/components/attribute";
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import Chip from "~/components/chip";
|
||||
import Link from "~/components/link";
|
||||
import PageHeader from "~/components/page-header";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import {
|
||||
agentsContext,
|
||||
headscaleConfigContext,
|
||||
@@ -17,11 +14,10 @@ import {
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import cn from "~/utils/cn";
|
||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||
import { extractTagOwnerTags, isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
|
||||
import { toastClientAction } from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
import { getUserDisplayName, isTaggedDevicesUser } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
|
||||
@@ -117,242 +113,191 @@ export default function Page({
|
||||
return tags;
|
||||
}, [node, agent]);
|
||||
|
||||
const { subnetApprovedRoutes, subnetWaitingRoutes, exitRoutes, exitApproved } =
|
||||
node.customRouting;
|
||||
const exitStatus =
|
||||
exitRoutes.length === 0 ? "Not advertised" : exitApproved ? "Approved" : "Waiting for approval";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-8">
|
||||
<Link className="font-medium" to="/machines">
|
||||
All Machines
|
||||
<>
|
||||
<nav aria-label="Breadcrumb" className="text-muted mb-3 text-sm">
|
||||
<Link className="hover:text-fg" to="/machines">
|
||||
Machines
|
||||
</Link>
|
||||
<span className="mx-2">/</span>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div className={cn("flex justify-between items-center pb-2", "border-b border-line")}>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-xl font-semibold">{node.givenName}</h1>
|
||||
<StatusCircle className="h-4 w-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
policyTags={policyTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||
supportsDisablingKeyExpiry={supportsDisablingKeyExpiry}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex gap-1">
|
||||
<div className="border-line border-r p-2 pr-4">
|
||||
<span className="text-muted flex items-center gap-x-1 text-sm">
|
||||
Managed by
|
||||
<Tooltip content="By default, a machine’s permissions match its creator’s.">
|
||||
<Info className="p-1" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-x-2.5">
|
||||
<UserCircle />
|
||||
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-muted text-sm">Status</p>
|
||||
<div className="mt-1 mb-8 flex gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
<span className="mx-1.5">/</span>
|
||||
<span className="text-fg">{node.givenName}</span>
|
||||
</nav>
|
||||
<PageHeader
|
||||
actions={
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
policyTags={policyTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||
supportsDisablingKeyExpiry={supportsDisablingKeyExpiry}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{node.user && !(isTaggedDevicesUser(node.user) && tags.length) ? (
|
||||
<span>{getUserDisplayName(node.user)}</span>
|
||||
) : undefined}
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
<span className="font-mono" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusCircle isExpired={node.expired} isOnline={node.online && !node.expired} />
|
||||
{node.givenName}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{" "}
|
||||
<Link external styled to="https://tailscale.com/kb/1019/subnets">
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button onClick={() => setShowRouting(true)}>Review</Button>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full max-w-full grid sm:grid-cols-2",
|
||||
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<span className="text-muted flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip content="Traffic to these routes are being routed through this machine.">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<section className="mb-8">
|
||||
<div className="mb-2 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold">Routing</h2>
|
||||
<p className="text-muted text-sm">
|
||||
Expose subnets or route all traffic through this machine.{" "}
|
||||
<Link external styled to="https://tailscale.com/kb/1019/subnets">
|
||||
Docs
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Button className="mt-1.5" onClick={() => setShowRouting(true)} size="sm" variant="ghost">
|
||||
Edit
|
||||
</Button>
|
||||
<Button onClick={() => setShowRouting(true)}>Edit routes</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<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>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button className="mt-1.5" onClick={() => setShowRouting(true)} size="sm" variant="ghost">
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip content="Whether this machine can act as an exit node for your tailnet.">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="text-ok h-3.5 w-3.5" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CircleSlash className="text-bad h-3.5 w-3.5" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button className="mt-1.5" onClick={() => setShowRouting(true)} size="sm" variant="ghost">
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection issues.
|
||||
</p>
|
||||
<Card className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dl className="divide-line border-line divide-y border-t">
|
||||
<Attribute
|
||||
name="Creator"
|
||||
value={node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||
name="Subnet routes"
|
||||
tooltip="Traffic to these routes is sent through this machine."
|
||||
value={subnetApprovedRoutes.length > 0 ? subnetApprovedRoutes.join("\n") : "None"}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
name="OS hostname"
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="ID"
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={!isNoExpiry(node.expiry) ? new Date(node.expiry!).toLocaleString() : "Never"}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted text-sm font-semibold uppercase">Addresses</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Short domain"
|
||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||
value={node.givenName}
|
||||
/>
|
||||
{magic ? (
|
||||
{subnetWaitingRoutes.length > 0 ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Full domain"
|
||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||
value={`${node.givenName}.${magic}`}
|
||||
name="Waiting for approval"
|
||||
tooltip="This machine advertises these routes, but they must be approved before traffic is sent to them."
|
||||
value={subnetWaitingRoutes.join("\n")}
|
||||
/>
|
||||
) : undefined}
|
||||
{stats?.Endpoints ? (
|
||||
<Attribute name="Endpoints" value={stats?.Endpoints?.join("\n") ?? "—"} />
|
||||
) : undefined}
|
||||
{stats ? (
|
||||
<>
|
||||
<p className="text-muted mt-4 text-sm font-semibold uppercase">Client Connectivity</p>
|
||||
<Attribute
|
||||
name="Varies"
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||
value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
|
||||
/>
|
||||
<Attribute
|
||||
name="Hairpinning"
|
||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||
value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
|
||||
/>
|
||||
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
|
||||
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
|
||||
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
|
||||
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
|
||||
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="Exit node"
|
||||
tooltip="Whether this machine can route all internet traffic for your tailnet."
|
||||
value={exitStatus}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2">
|
||||
<h2 className="font-semibold">Details</h2>
|
||||
<p className="text-muted text-sm">
|
||||
Information about this machine's network. Useful when debugging connections.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-12 lg:grid-cols-2">
|
||||
<dl className="divide-line border-line divide-y border-t">
|
||||
<Attribute
|
||||
name="Owner"
|
||||
value={node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
name="OS hostname"
|
||||
tooltip="Published by the machine's operating system and used as the default machine name."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="ID"
|
||||
tooltip="Used to refer to this machine in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
|
||||
<Attribute
|
||||
name="Last seen"
|
||||
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={!isNoExpiry(node.expiry) ? new Date(node.expiry!).toLocaleString() : "Never"}
|
||||
/>
|
||||
</dl>
|
||||
<div>
|
||||
<dl className="divide-line border-line divide-y border-t">
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine's IPv4 address within your tailnet."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine's IPv6 address within your tailnet. IPv6 works inside the tailnet even if your ISP does not support it."
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Short domain"
|
||||
tooltip="Machines in your tailnet can reach this one by this name."
|
||||
value={node.givenName}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Full domain"
|
||||
tooltip="The fully qualified MagicDNS name for this machine."
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
{stats?.Endpoints ? (
|
||||
<Attribute name="Endpoints" value={stats.Endpoints.join("\n")} />
|
||||
) : undefined}
|
||||
</dl>
|
||||
{stats ? (
|
||||
<>
|
||||
<h3 className="text-muted mt-6 mb-1 text-xs font-medium">Connectivity</h3>
|
||||
<dl className="divide-line border-line divide-y border-t">
|
||||
<Attribute
|
||||
name="Varies"
|
||||
tooltip="Whether the machine is behind a difficult NAT that changes its IP address depending on the destination."
|
||||
value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
|
||||
/>
|
||||
<Attribute
|
||||
name="Hairpinning"
|
||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||
value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
|
||||
/>
|
||||
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
|
||||
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
|
||||
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
|
||||
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
|
||||
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
|
||||
</dl>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Info, X } from "lucide-react";
|
||||
import { Info, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import { latestTailscaleVersion } from "~/server/tailscale-release";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
@@ -89,6 +90,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
: undefined,
|
||||
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||
existingTags: sortAssignableTags(nodes, policy),
|
||||
latestVersion: agents ? latestTailscaleVersion() : undefined,
|
||||
// `undefined` keeps the tag dialog from flagging every tag as undeclared.
|
||||
policyTags: extractTagOwnerTags(policy),
|
||||
magic,
|
||||
@@ -259,22 +261,24 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
}
|
||||
description={
|
||||
<>
|
||||
Manage the devices connected to your Tailnet.{" "}
|
||||
Devices connected to your tailnet.{" "}
|
||||
<Link external styled to="https://tailscale.com/kb/1372/manage-devices">
|
||||
Learn more
|
||||
Docs
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
title="Machines"
|
||||
/>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-64 max-w-full">
|
||||
<Search className="text-faint pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
className="[&_input]:pl-8"
|
||||
label="Search machines"
|
||||
labelHidden
|
||||
maxLength={100}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search by name or IP address..."
|
||||
placeholder="Search by name or IP"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -294,7 +298,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
)}
|
||||
</div>
|
||||
<MachineFilters users={loaderData.users} populatedNodes={loaderData.populatedNodes} />
|
||||
<span className="text-muted ml-auto text-sm whitespace-nowrap">
|
||||
<span className="text-muted ml-auto text-xs whitespace-nowrap tabular-nums">
|
||||
{searchQuery || hasActiveFilters
|
||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||
: `${loaderData.populatedNodes.length} machines`}
|
||||
@@ -368,6 +372,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
: node.user?.id !== loaderData.headscaleUserId
|
||||
}
|
||||
key={node.id}
|
||||
latestVersion={loaderData.latestVersion}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
|
||||
<StatusCircle isOnline={!hasError && !isPending} className="size-2" />
|
||||
<span className="text-lg font-medium">
|
||||
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
|
||||
</span>
|
||||
|
||||
@@ -18,13 +18,15 @@ export default function AuthKeyRow({ authKey, user }: Props) {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} />
|
||||
<Attribute name="User" value={userDisplay} />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
|
||||
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<dl>
|
||||
<Attribute name="Key" value={authKey.key} />
|
||||
<Attribute name="User" value={userDisplay} />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
|
||||
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
</dl>
|
||||
{!isExpired && user && (
|
||||
<div className="mt-2" suppressHydrationWarning>
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
|
||||
@@ -76,8 +76,8 @@ export default function HeadplaneUserRow({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.machines.length > 0 ? (
|
||||
<span className={cn("flex items-center gap-x-1 text-sm", "text-muted")}>
|
||||
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
||||
<span className={cn("flex items-center gap-x-2 text-sm", "text-muted")}>
|
||||
<StatusCircle isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
|
||||
@@ -62,8 +62,8 @@ export default function HeadscaleUserRow({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.machines.length > 0 ? (
|
||||
<span className={cn("flex items-center gap-x-1 text-sm", "text-muted")}>
|
||||
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
||||
<span className={cn("flex items-center gap-x-2 text-sm", "text-muted")}>
|
||||
<StatusCircle isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import log from "~/utils/log";
|
||||
|
||||
const RELEASE_URL = "https://pkgs.tailscale.com/stable/?mode=json";
|
||||
const REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||
const RETRY_MS = 60 * 60 * 1000;
|
||||
|
||||
let latest: string | undefined;
|
||||
let nextFetchAt = 0;
|
||||
|
||||
/**
|
||||
* Returns the latest stable Tailscale version, if known. The lookup runs in
|
||||
* the background so loaders never wait on pkgs.tailscale.com.
|
||||
*/
|
||||
export function latestTailscaleVersion() {
|
||||
if (Date.now() >= nextFetchAt) {
|
||||
nextFetchAt = Date.now() + RETRY_MS;
|
||||
void refresh();
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch(RELEASE_URL, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as { TarballsVersion?: unknown };
|
||||
if (typeof body.TarballsVersion !== "string") {
|
||||
throw new Error("Missing TarballsVersion");
|
||||
}
|
||||
|
||||
latest = body.TarballsVersion;
|
||||
nextFetchAt = Date.now() + REFRESH_MS;
|
||||
} catch (error) {
|
||||
log.debug("server", "Unable to fetch latest Tailscale version: %s", String(error));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,23 @@ export function getTSVersion(host: HostInfo) {
|
||||
return IPNVersion.split("-")[0];
|
||||
}
|
||||
|
||||
// Only major.minor is compared since platforms ship patch releases at
|
||||
// different times (e.g. the macOS app often trails the Linux tarballs).
|
||||
export function isUpdateAvailable(host: HostInfo, latest: string) {
|
||||
const current = getTSVersion(host);
|
||||
if (current === "Unknown") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [curMajor = 0, curMinor = 0] = current.split(".").map(Number);
|
||||
const [newMajor = 0, newMinor = 0] = latest.split(".").map(Number);
|
||||
if (curMajor !== newMajor) {
|
||||
return curMajor < newMajor;
|
||||
}
|
||||
|
||||
return curMinor < newMinor;
|
||||
}
|
||||
|
||||
export function getOSInfo(host: HostInfo) {
|
||||
const { OS, OSVersion } = host;
|
||||
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
|
||||
|
||||
+30
-35
@@ -1,42 +1,37 @@
|
||||
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 365 * 24 * 60 * 60],
|
||||
["month", 30 * 24 * 60 * 60],
|
||||
["day", 24 * 60 * 60],
|
||||
["hour", 60 * 60],
|
||||
["minute", 60],
|
||||
];
|
||||
|
||||
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
/**
|
||||
* Formats the time delta since a given date into a human-readable string.
|
||||
* - Under 1 hour: "X minutes ago"
|
||||
* - Under 1 day: "X hours, Y minutes ago"
|
||||
* - Under 1 month: "X days, Y hours ago"
|
||||
* - Over 1 month: "X months, Y days ago"
|
||||
* Formats the time since a date in its largest unit, e.g. "3 hours ago".
|
||||
*/
|
||||
export function formatTimeDelta(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diffMs / (1000 * 60));
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const months = Math.floor(days / 30);
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
if (hours < 24) {
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes === 0) {
|
||||
return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
|
||||
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
|
||||
for (const [unit, size] of UNITS) {
|
||||
if (seconds >= size) {
|
||||
return relative.format(-Math.floor(seconds / size), unit);
|
||||
}
|
||||
return `${hours} hour${hours !== 1 ? "s" : ""}, ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
if (days < 30) {
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours === 0) {
|
||||
return `${days} day${days !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
return `${days} day${days !== 1 ? "s" : ""}, ${remainingHours} hour${remainingHours !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
const remainingDays = days % 30;
|
||||
if (remainingDays === 0) {
|
||||
return `${months} month${months !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
return `${months} month${months !== 1 ? "s" : ""}, ${remainingDays} day${remainingDays !== 1 ? "s" : ""} ago`;
|
||||
return "just now";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the time until a date in its largest unit, e.g. "in 4 days".
|
||||
*/
|
||||
export function formatTimeUntil(date: Date): string {
|
||||
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
||||
for (const [unit, size] of UNITS) {
|
||||
if (seconds >= size) {
|
||||
return relative.format(Math.floor(seconds / size), unit);
|
||||
}
|
||||
}
|
||||
|
||||
return "now";
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
import type { User } from "~/types/User";
|
||||
|
||||
// Headscale 0.28+ assigns tagged nodes to this placeholder user.
|
||||
export function isTaggedDevicesUser(user: User): boolean {
|
||||
return user.name === "tagged-devices";
|
||||
}
|
||||
|
||||
export function getUserDisplayName(user: User): string {
|
||||
if (user.name === "tagged-devices") {
|
||||
if (isTaggedDevicesUser(user)) {
|
||||
return "Tag-owned";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user