mirror of
https://github.com/tale/headplane.git
synced 2026-08-29 00:17:13 +00:00
b938642da7
- Add search input to filter machines by name or IP address - Add sortable column headers for Name, IP, Version, and Last Seen - Sort IP addresses numerically by octets (not alphabetically) - Sort versions numerically by segments (1.10.0 > 1.9.0) - Sort Last Seen with online machines treated as most recent - Add time delta display for offline machines (e.g., "2 days ago") - Add machine count display showing filtered/total counts - Add clear button (X) to search input Closes #351
277 lines
7.1 KiB
TypeScript
277 lines
7.1 KiB
TypeScript
import { ChevronDown, Copy } from 'lucide-react';
|
|
import { useMemo } from 'react';
|
|
import { Link } from 'react-router';
|
|
import Chip from '~/components/Chip';
|
|
import Menu from '~/components/Menu';
|
|
import StatusCircle from '~/components/StatusCircle';
|
|
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
|
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 type { User } from '~/types';
|
|
import cn from '~/utils/cn';
|
|
import * as hinfo from '~/utils/host-info';
|
|
import { PopulatedNode } from '~/utils/node-info';
|
|
import toast from '~/utils/toast';
|
|
import MenuOptions from './menu';
|
|
|
|
/**
|
|
* Formats the time delta since last seen 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"
|
|
*/
|
|
function formatTimeDelta(lastSeen: Date): string {
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - lastSeen.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`;
|
|
}
|
|
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`;
|
|
}
|
|
|
|
interface Props {
|
|
node: PopulatedNode;
|
|
users: User[];
|
|
isAgent?: boolean;
|
|
magic?: string;
|
|
isDisabled?: boolean;
|
|
existingTags?: string[];
|
|
}
|
|
|
|
export default function MachineRow({
|
|
node,
|
|
users,
|
|
isAgent,
|
|
magic,
|
|
isDisabled,
|
|
existingTags,
|
|
}: Props) {
|
|
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
|
|
|
const ipOptions = useMemo(() => {
|
|
if (magic) {
|
|
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
|
}
|
|
|
|
return node.ipAddresses;
|
|
}, [magic, node.ipAddresses]);
|
|
|
|
return (
|
|
<tr
|
|
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
|
key={node.id}
|
|
>
|
|
<td className="pl-0.5 py-2 focus-within:ring-3">
|
|
<Link
|
|
className={cn('group/link h-full focus:outline-hidden')}
|
|
to={`/machines/${node.id}`}
|
|
>
|
|
<p
|
|
className={cn(
|
|
'font-semibold leading-snug',
|
|
'group-hover/link:text-blue-600',
|
|
'dark:group-hover/link:text-blue-400',
|
|
)}
|
|
>
|
|
{node.givenName}
|
|
</p>
|
|
<p className="text-sm opacity-50">
|
|
{node.user.name ||
|
|
node.user.displayName ||
|
|
node.user.email ||
|
|
node.user.id}
|
|
</p>
|
|
<div className="flex gap-1 flex-wrap mt-1.5">
|
|
{mapTagsToComponents(node, uiTags)}
|
|
{node.validTags.map((tag) => (
|
|
<Chip key={tag} text={tag} />
|
|
))}
|
|
</div>
|
|
</Link>
|
|
</td>
|
|
<td className="py-2">
|
|
<div className="flex items-center gap-x-1">
|
|
{node.ipAddresses[0]}
|
|
<Menu placement="bottom end">
|
|
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
|
<ChevronDown className="w-4 h-4" />
|
|
</Menu.IconButton>
|
|
<Menu.Panel
|
|
onAction={async (key) => {
|
|
await navigator.clipboard.writeText(key.toString());
|
|
toast('Copied IP address to clipboard');
|
|
}}
|
|
>
|
|
<Menu.Section>
|
|
{ipOptions.map((ip) => (
|
|
<Menu.Item key={ip} textValue={ip}>
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between',
|
|
'text-sm w-full gap-x-6',
|
|
)}
|
|
>
|
|
{ip}
|
|
<Copy className="w-3 h-3" />
|
|
</div>
|
|
</Menu.Item>
|
|
))}
|
|
</Menu.Section>
|
|
</Menu.Panel>
|
|
</Menu>
|
|
</div>
|
|
</td>
|
|
{/* We pass undefined when agents are not enabled */}
|
|
{isAgent !== undefined ? (
|
|
<td className="py-2">
|
|
{node.hostInfo !== undefined ? (
|
|
<>
|
|
<p className="leading-snug">
|
|
{hinfo.getTSVersion(node.hostInfo)}
|
|
</p>
|
|
<p className="text-sm opacity-50 max-w-48 truncate">
|
|
{hinfo.getOSInfo(node.hostInfo)}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm opacity-50">Unknown</p>
|
|
)}
|
|
</td>
|
|
) : undefined}
|
|
<td className="py-2">
|
|
<div className="flex items-start gap-x-1">
|
|
<StatusCircle
|
|
className="w-4 h-4 mt-0.5"
|
|
isOnline={node.online && !node.expired}
|
|
/>
|
|
<div>
|
|
<p
|
|
className={cn(
|
|
'text-sm',
|
|
'text-headplane-600 dark:text-headplane-300',
|
|
)}
|
|
suppressHydrationWarning
|
|
>
|
|
{node.online && !node.expired
|
|
? 'Connected'
|
|
: new Date(node.lastSeen).toLocaleString()}
|
|
</p>
|
|
{!(node.online && !node.expired) && (
|
|
<p className="text-xs opacity-50" suppressHydrationWarning>
|
|
{formatTimeDelta(new Date(node.lastSeen))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-2 pr-0.5">
|
|
<MenuOptions
|
|
existingTags={existingTags}
|
|
isDisabled={isDisabled}
|
|
magic={magic}
|
|
node={node}
|
|
users={users}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
|
const uiTags: string[] = [];
|
|
if (node.expired) {
|
|
uiTags.push('expired');
|
|
}
|
|
|
|
if (node.expiry === null) {
|
|
uiTags.push('no-expiry');
|
|
}
|
|
|
|
if (node.customRouting.exitRoutes.length > 0) {
|
|
if (node.customRouting.exitApproved) {
|
|
uiTags.push('exit-approved');
|
|
} else {
|
|
uiTags.push('exit-waiting');
|
|
}
|
|
}
|
|
|
|
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
|
uiTags.push('subnet-waiting');
|
|
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
|
uiTags.push('subnet-approved');
|
|
}
|
|
|
|
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
|
uiTags.push('tailscale-ssh');
|
|
}
|
|
|
|
if (isAgent === true) {
|
|
uiTags.push('headplane-agent');
|
|
}
|
|
|
|
return uiTags;
|
|
}
|
|
|
|
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
|
return uiTags.map((tag) => {
|
|
switch (tag) {
|
|
case 'exit-approved':
|
|
case 'exit-waiting':
|
|
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
|
|
|
case 'subnet-approved':
|
|
case 'subnet-waiting':
|
|
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
|
|
|
case 'expired':
|
|
case 'no-expiry':
|
|
return (
|
|
<ExpiryTag
|
|
expiry={node.expiry ?? undefined}
|
|
key={tag}
|
|
variant={tag}
|
|
/>
|
|
);
|
|
|
|
case 'tailscale-ssh':
|
|
return <TailscaleSSHTag key={tag} />;
|
|
|
|
case 'headplane-agent':
|
|
return <HeadplaneAgentTag key={tag} />;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
}
|