mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
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
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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({
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-x-1">
|
||||
<StatusCircle
|
||||
className="w-4 h-4"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
isOnline={node.online && !node.expired}
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
<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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
@@ -66,7 +68,93 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
export const action = machineAction;
|
||||
|
||||
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('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 (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -89,13 +177,98 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<Input
|
||||
label="Search machines"
|
||||
labelHidden
|
||||
maxLength={100}
|
||||
onChange={(value) => setSearchQuery(value.slice(0, 100))}
|
||||
placeholder="Search by name or IP address..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className={cn(
|
||||
'absolute right-2 top-1/2 -translate-y-1/2',
|
||||
'p-1 rounded-full',
|
||||
'text-headplane-400 hover:text-headplane-600',
|
||||
'dark:text-headplane-500 dark:hover:text-headplane-300',
|
||||
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
|
||||
)}
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-headplane-500 whitespace-nowrap">
|
||||
{searchQuery
|
||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||
: `${loaderData.populatedNodes.length} machines`}
|
||||
</span>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
||||
<th className="pb-2 w-1/4">
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'name'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by name"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('name')}
|
||||
type="button"
|
||||
>
|
||||
Name
|
||||
{sortField === 'name' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'ip'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="pb-2 w-1/4"
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
<button
|
||||
aria-label="Sort by IP address"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('ip')}
|
||||
type="button"
|
||||
>
|
||||
Addresses
|
||||
{sortField === 'ip' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
@@ -113,9 +286,63 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'version'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by version"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('version')}
|
||||
type="button"
|
||||
>
|
||||
Version
|
||||
{sortField === 'version' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'lastSeen'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by last seen"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('lastSeen')}
|
||||
type="button"
|
||||
>
|
||||
Last Seen
|
||||
{sortField === 'lastSeen' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
@@ -124,24 +351,37 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{loaderData.populatedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
|
||||
}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))}
|
||||
{filteredAndSortedNodes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="py-8 text-center text-headplane-500"
|
||||
colSpan={loaderData.agent !== undefined ? 5 : 4}
|
||||
>
|
||||
No machines found matching "{searchQuery}"
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredAndSortedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent
|
||||
? loaderData.agent === node.nodeKey
|
||||
: undefined
|
||||
}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user