mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 11:36:31 +00:00
Merge pull request #377 from The-Greg-O/feat/machines-search-sort
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
# 0.6.2 (Next)
|
# 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
|
- 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)).
|
- 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)).
|
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { User } from '~/types';
|
|||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import * as hinfo from '~/utils/host-info';
|
import * as hinfo from '~/utils/host-info';
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import { PopulatedNode } from '~/utils/node-info';
|
||||||
|
import { formatTimeDelta } from '~/utils/time';
|
||||||
import toast from '~/utils/toast';
|
import toast from '~/utils/toast';
|
||||||
import MenuOptions from './menu';
|
import MenuOptions from './menu';
|
||||||
|
|
||||||
@@ -33,10 +34,7 @@ export default function MachineRow({
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
existingTags,
|
existingTags,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const uiTags = useMemo(() => {
|
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||||
const tags = uiTagsForNode(node, isAgent);
|
|
||||||
return tags;
|
|
||||||
}, [node, isAgent]);
|
|
||||||
|
|
||||||
const ipOptions = useMemo(() => {
|
const ipOptions = useMemo(() => {
|
||||||
if (magic) {
|
if (magic) {
|
||||||
@@ -129,22 +127,30 @@ export default function MachineRow({
|
|||||||
</td>
|
</td>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<span
|
<div className="flex items-start gap-x-1">
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-x-1 text-sm',
|
|
||||||
'text-headplane-600 dark:text-headplane-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<StatusCircle
|
<StatusCircle
|
||||||
className="w-4 h-4"
|
className="w-4 h-4 mt-0.5"
|
||||||
isOnline={node.online && !node.expired}
|
isOnline={node.online && !node.expired}
|
||||||
/>
|
/>
|
||||||
<p suppressHydrationWarning>
|
<div>
|
||||||
{node.online && !node.expired
|
<p
|
||||||
? 'Connected'
|
className={cn(
|
||||||
: new Date(node.lastSeen).toLocaleString()}
|
'text-sm',
|
||||||
</p>
|
'text-headplane-600 dark:text-headplane-300',
|
||||||
</span>
|
)}
|
||||||
|
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>
|
||||||
<td className="py-2 pr-0.5">
|
<td className="py-2 pr-0.5">
|
||||||
<MenuOptions
|
<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 Code from '~/components/Code';
|
||||||
|
import Input from '~/components/Input';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Tooltip from '~/components/Tooltip';
|
import Tooltip from '~/components/Tooltip';
|
||||||
import { Capabilities } from '~/server/web/roles';
|
import { Capabilities } from '~/server/web/roles';
|
||||||
@@ -66,7 +68,93 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
export const action = machineAction;
|
export const action = machineAction;
|
||||||
|
|
||||||
|
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
|
||||||
|
|
||||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
@@ -89,13 +177,98 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
users={loaderData.users}
|
users={loaderData.users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<table className="table-auto w-full rounded-lg">
|
||||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||||
<tr className="text-left px-0.5">
|
<tr className="text-left px-0.5">
|
||||||
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
<th
|
||||||
<th className="pb-2 w-1/4">
|
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">
|
<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 ? (
|
{loaderData.magic ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Info className="w-4 h-4" />
|
<Info className="w-4 h-4" />
|
||||||
@@ -113,9 +286,63 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
</th>
|
</th>
|
||||||
{/* We only want to show the version column if there are agents */}
|
{/* We only want to show the version column if there are agents */}
|
||||||
{loaderData.agent !== undefined ? (
|
{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}
|
) : 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
@@ -124,24 +351,37 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
'border-t border-headplane-100 dark:border-headplane-800',
|
'border-t border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{loaderData.populatedNodes.map((node) => (
|
{filteredAndSortedNodes.length === 0 ? (
|
||||||
<MachineRow
|
<tr>
|
||||||
existingTags={sortNodeTags(loaderData.nodes)}
|
<td
|
||||||
isAgent={
|
className="py-8 text-center text-headplane-500"
|
||||||
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
|
colSpan={loaderData.agent !== undefined ? 5 : 4}
|
||||||
}
|
>
|
||||||
isDisabled={
|
No machines found matching "{searchQuery}"
|
||||||
loaderData.writable
|
</td>
|
||||||
? false // If the user has write permissions, they can edit all machines
|
</tr>
|
||||||
: node.user.providerId?.split('/').pop() !==
|
) : (
|
||||||
loaderData.subject
|
filteredAndSortedNodes.map((node) => (
|
||||||
}
|
<MachineRow
|
||||||
key={node.id}
|
existingTags={sortNodeTags(loaderData.nodes)}
|
||||||
magic={loaderData.magic}
|
isAgent={
|
||||||
node={node}
|
loaderData.agent
|
||||||
users={loaderData.users}
|
? 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
*/
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user