From b938642da7f397b0ec6cdbee8b29502a35df4a35 Mon Sep 17 00:00:00 2001 From: The-Greg-O <123033552+The-Greg-O@users.noreply.github.com> Date: Wed, 3 Dec 2025 02:07:29 -0800 Subject: [PATCH] feat: add search and sortable columns to machines list - 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 --- CHANGELOG.md | 1 + .../machines/components/machine-row.tsx | 82 +++-- app/routes/machines/overview.tsx | 288 ++++++++++++++++-- 3 files changed, 330 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e5ebb..bdf8619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # 0.6.2 (Next) +- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)). - Added support for Headscale 0.27.0 and 0.27.1 - Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)). - Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)). diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index aa4dea9..40c23a5 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -16,6 +16,49 @@ 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[]; @@ -33,10 +76,7 @@ export default function MachineRow({ isDisabled, existingTags, }: Props) { - const uiTags = useMemo(() => { - const tags = uiTagsForNode(node, isAgent); - return tags; - }, [node, isAgent]); + const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); const ipOptions = useMemo(() => { if (magic) { @@ -129,22 +169,30 @@ export default function MachineRow({ ) : undefined} - +
-

- {node.online && !node.expired - ? 'Connected' - : new Date(node.lastSeen).toLocaleString()} -

- +
+

+ {node.online && !node.expired + ? 'Connected' + : new Date(node.lastSeen).toLocaleString()} +

+ {!(node.online && !node.expired) && ( +

+ {formatTimeDelta(new Date(node.lastSeen))} +

+ )} +
+
('name'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const filteredAndSortedNodes = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); + + let nodes = loaderData.populatedNodes.filter((node) => { + if (!query) return true; + if (node.givenName.toLowerCase().includes(query)) return true; + if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) + return true; + return false; + }); + + nodes = [...nodes].sort((a, b) => { + let comparison = 0; + + switch (sortField) { + case 'name': + comparison = a.givenName.localeCompare(b.givenName); + break; + case 'ip': { + const getIPv4 = (addresses: string[]) => + addresses.find((ip) => !ip.includes(':')) || addresses[0] || ''; + const ipA = getIPv4(a.ipAddresses); + const ipB = getIPv4(b.ipAddresses); + + if (!ipA.includes(':') && !ipB.includes(':')) { + const octetsA = ipA.split('.').map(Number); + const octetsB = ipB.split('.').map(Number); + for (let i = 0; i < 4; i++) { + if (octetsA[i] !== octetsB[i]) { + comparison = octetsA[i] - octetsB[i]; + break; + } + } + } else { + comparison = ipA.localeCompare(ipB); + } + break; + } + case 'version': { + const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0'; + const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0'; + const partsA = versionA.split('.').map(Number); + const partsB = versionB.split('.').map(Number); + const maxLen = Math.max(partsA.length, partsB.length); + + for (let i = 0; i < maxLen; i++) { + const segA = partsA[i] || 0; + const segB = partsB[i] || 0; + if (segA !== segB) { + comparison = segA - segB; + break; + } + } + break; + } + case 'lastSeen': + if (a.online !== b.online) { + comparison = a.online ? 1 : -1; + break; + } + comparison = + new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime(); + break; + } + + return sortDirection === 'asc' ? comparison : -comparison; + }); + + return nodes; + }, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; + return ( <>
@@ -89,13 +177,98 @@ export default function Page({ loaderData }: Route.ComponentProps) { users={loaderData.users} />
+
+
+ setSearchQuery(value.slice(0, 100))} + placeholder="Search by name or IP address..." + value={searchQuery} + /> + {searchQuery && ( + + )} +
+ + {searchQuery + ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` + : `${loaderData.populatedNodes.length} machines`} + +
- - + {/* We only want to show the version column if there are agents */} {loaderData.agent !== undefined ? ( - + ) : undefined} - + - {loaderData.populatedNodes.map((node) => ( - - ))} + {filteredAndSortedNodes.length === 0 ? ( + + + + ) : ( + filteredAndSortedNodes.map((node) => ( + + )) + )}
Name + + +
-

Addresses

+ {loaderData.magic ? ( @@ -113,9 +286,63 @@ export default function Page({ loaderData }: Route.ComponentProps) {
Version + + Last Seen + +
+ No machines found matching "{searchQuery}" +