refactor: extract SortableTable primitive from Agents page
Column-driven table component under primitives/ — sortable headers, checkbox selection, empty/loading states, pagination slot. Agents.tsx rewired as proof-of-integration; ~220 lines of raw table markup collapsed into column definitions + <SortableTable />. Same output, same behavior.
This commit is contained in:
parent
c71fc093db
commit
488cca2dd5
3 changed files with 343 additions and 222 deletions
196
web/src/components/primitives/SortableTable.tsx
Normal file
196
web/src/components/primitives/SortableTable.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import React from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Pagination from './Pagination';
|
||||
|
||||
/**
|
||||
* Column definition for SortableTable.
|
||||
*
|
||||
* `key` doubles as the sort key unless `sortKey` overrides it.
|
||||
* Set `sortable: false` to render a static header (e.g. Actions column).
|
||||
*/
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean; // default true (set false for Actions columns)
|
||||
sortKey?: string; // override sort key passed to onSort (defaults to key)
|
||||
className?: string;
|
||||
render: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
function SortIcon({ columnKey, sortBy, sortOrder }: { columnKey: string; sortBy: string; sortOrder: 'asc' | 'desc' }) {
|
||||
if (sortBy !== columnKey) return <ArrowUpDown className="h-4 w-4 ml-1 text-gray-400" />;
|
||||
return sortOrder === 'asc'
|
||||
? <ArrowUp className="h-4 w-4 ml-1 text-primary-600" />
|
||||
: <ArrowDown className="h-4 w-4 ml-1 text-primary-600" />;
|
||||
}
|
||||
|
||||
interface SortableTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
getKey: (item: T) => string;
|
||||
/** Current sort column (controlled). */
|
||||
sortBy: string;
|
||||
/** Current sort direction (controlled). */
|
||||
sortOrder: 'asc' | 'desc';
|
||||
/** Called when a sortable column header is clicked. */
|
||||
onSort: (column: string) => void;
|
||||
|
||||
// Selection
|
||||
selectable?: boolean;
|
||||
selected?: string[];
|
||||
onSelectAll?: (all: boolean) => void;
|
||||
onSelectOne?: (key: string, checked: boolean) => void;
|
||||
|
||||
// Row interaction
|
||||
onRowClick?: (item: T) => void;
|
||||
|
||||
// Empty / loading
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
|
||||
// Pagination (optional — if omitted the table fills its container)
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
total?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SortableTable — reusable data table with sortable headers, optional row
|
||||
* selection, pagination, and standard RedFlag styling. Column sorting is
|
||||
* controlled externally — wire `useColumnSort` or manage state directly.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({ defaultSortBy: 'hostname' });
|
||||
* const sorted = applySort(data, (item) => colSortValue(item, sortBy));
|
||||
* <SortableTable columns={cols} data={sorted} sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
|
||||
* ```
|
||||
*/
|
||||
function SortableTable<T>({
|
||||
columns,
|
||||
data,
|
||||
getKey,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
|
||||
selectable,
|
||||
selected = [],
|
||||
onSelectAll,
|
||||
onSelectOne,
|
||||
|
||||
onRowClick,
|
||||
|
||||
emptyMessage = 'No data.',
|
||||
loading = false,
|
||||
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
|
||||
className,
|
||||
}: SortableTableProps<T>) {
|
||||
const allSelected = data.length > 0 && selected.length === data.length;
|
||||
|
||||
return (
|
||||
<div className={cn('bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden', className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th className="table-header w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) => onSelectAll?.(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => {
|
||||
const sortable = col.sortable !== false;
|
||||
const sortKey = col.sortKey || col.key;
|
||||
return (
|
||||
<th key={col.key} className={cn('table-header', col.className)}>
|
||||
{sortable ? (
|
||||
<button
|
||||
onClick={() => onSort(sortKey)}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
{col.label}
|
||||
<SortIcon columnKey={sortKey} sortBy={sortBy} sortOrder={sortOrder} />
|
||||
</button>
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selectable ? 1 : 0)}
|
||||
className="px-6 py-12 text-center text-sm text-gray-400"
|
||||
>
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selectable ? 1 : 0)}
|
||||
className="px-6 py-12 text-center text-sm text-gray-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => {
|
||||
const key = getKey(item);
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className={cn('hover:bg-gray-50 group', onRowClick && 'cursor-pointer')}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="table-cell w-10" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(key)}
|
||||
onChange={(e) => onSelectOne?.(key, e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={cn('table-cell', col.className)}>
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{page && pageSize && total && onPageChange ? (
|
||||
<div className="border-t border-gray-200 px-4 py-3">
|
||||
<Pagination page={page} total={total} pageSize={pageSize} onChange={onPageChange} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortableTable;
|
||||
|
|
@ -11,3 +11,5 @@ export { default as MetricItem } from './MetricItem';
|
|||
export { default as ProcessTable } from './ProcessTable';
|
||||
export { default as CommandCard } from './CommandCard';
|
||||
export { default as CommandStatusBadge, getCommandStatus } from './CommandStatusBadge';
|
||||
export { default as SortableTable } from './SortableTable';
|
||||
export type { Column } from './SortableTable';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
|
|
@ -29,7 +29,9 @@ import {
|
|||
MetricItem,
|
||||
ProcessTable,
|
||||
CommandCard,
|
||||
SortableTable,
|
||||
} from '@/components/primitives';
|
||||
import type { Column } from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useColumnSort } from '@/hooks/useColumnSort';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
|
|
@ -67,7 +69,7 @@ const Agents: React.FC = () => {
|
|||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || '');
|
||||
const [osFilter, setOsFilter] = useState<string>('');
|
||||
const { sortBy, handleSort, renderSortIcon, applySort } = useColumnSort({
|
||||
const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({
|
||||
defaultSortBy: 'last_seen',
|
||||
defaultOrder: 'asc',
|
||||
});
|
||||
|
|
@ -415,6 +417,134 @@ const Agents: React.FC = () => {
|
|||
// Get unique OS types for filter
|
||||
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
|
||||
|
||||
// Column definitions for the fleet table
|
||||
const agentColumns: Column<typeof sortedAgents[number]>[] = useMemo(() => [
|
||||
{
|
||||
key: 'hostname', label: 'Agent', sortKey: 'hostname',
|
||||
render: (agent) => (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Computer className="h-4 w-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<button onClick={() => navigate(`/agents/${agent.id}`)} className="hover:text-primary-600">
|
||||
{agent.hostname}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.metadata && (() => {
|
||||
const meta = getSystemMetadata(agent);
|
||||
const parts = [];
|
||||
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
|
||||
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
|
||||
if (parts.length > 0) return parts.join(' • ');
|
||||
return 'System info available';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status', label: 'Status', sortKey: 'status',
|
||||
render: (agent) => (
|
||||
<div className="flex flex-col space-y-1 items-start">
|
||||
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
{agent.reboot_required && (
|
||||
<span className="inline-flex items-center text-xs text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded-full w-fit">
|
||||
<Power className="h-3 w-3 mr-1" />
|
||||
Restart
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'version', label: 'Version', sortKey: 'version',
|
||||
render: (agent) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-900">{agent.current_version || 'Initial Registration'}</span>
|
||||
{agent.update_available === true && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSingleAgentUpdate(agent.id); setShowUpdateModal(true); }}
|
||||
className="inline-flex items-center text-xs text-amber-600 bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 rounded-full cursor-pointer transition-colors"
|
||||
title="Click to update agent"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />Update
|
||||
</button>
|
||||
)}
|
||||
{agent.update_available === false && agent.current_version && (
|
||||
<span className="badge text-green-600 bg-green-50"><CheckCircle className="h-3 w-3 mr-1" />Current</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'os', label: 'OS', sortKey: 'os',
|
||||
render: (agent) => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">{osInfo.distribution || agent.os_type}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{osInfo.version ? `${osInfo.version} • ${agent.os_architecture || agent.architecture}` : (agent.os_architecture || agent.architecture)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'last_seen', label: 'Last Check-in', sortKey: 'last_seen',
|
||||
render: (agent) => (
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">{formatRelativeTime(agent.last_seen)}</div>
|
||||
<div className="text-xs text-gray-500">{isOnline(agent.last_seen) ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'last_scan', label: 'Last Scan', sortKey: 'last_scan',
|
||||
render: (agent) => (
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.last_scan ? formatRelativeTime(agent.last_scan) : 'Never'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions', label: 'Actions', sortable: false,
|
||||
render: (agent) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => { setSelectedAgents([agent.id]); setShowUpdateModal(true); }}
|
||||
disabled={agent.is_updating}
|
||||
className={cn("text-gray-400 hover:text-primary-600", agent.is_updating && "text-amber-600 animate-pulse")}
|
||||
title={agent.is_updating ? "Agent is updating..." : "Update agent"}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="Remove agent"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="View details"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] as Column<typeof sortedAgents[number]>[], [navigate, handleRemoveAgent, unregisterAgentMutation.isPending]);
|
||||
|
||||
// Agent detail view
|
||||
if (id && selectedAgent) {
|
||||
return (
|
||||
|
|
@ -1038,226 +1168,19 @@ const Agents: React.FC = () => {
|
|||
: 'No agents have registered with the server yet.'
|
||||
}
|
||||
>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.length === sortedAgents.length && sortedAgents.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('hostname')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Agent
|
||||
{renderSortIcon('hostname')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('status')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Status
|
||||
{renderSortIcon('status')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('version')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Version
|
||||
{renderSortIcon('version')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('os')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
OS
|
||||
{renderSortIcon('os')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('last_seen')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Last Check-in
|
||||
{renderSortIcon('last_seen')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('last_scan')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Last Scan
|
||||
{renderSortIcon('last_scan')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedAgents.map((agent) => (
|
||||
<tr key={agent.id} className="hover:bg-gray-50 group">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes(agent.id)}
|
||||
onChange={(e) => handleSelectAgent(agent.id, e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Computer className="h-4 w-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{agent.hostname}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.metadata && (() => {
|
||||
const meta = getSystemMetadata(agent);
|
||||
const parts = [];
|
||||
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
|
||||
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
|
||||
if (parts.length > 0) return parts.join(' • ');
|
||||
return 'System info available';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex flex-col space-y-1 items-start">
|
||||
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
{agent.reboot_required && (
|
||||
<span className="inline-flex items-center text-xs text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded-full w-fit">
|
||||
<Power className="h-3 w-3 mr-1" />
|
||||
Restart
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-900">
|
||||
{agent.current_version || 'Initial Registration'}
|
||||
</span>
|
||||
{agent.update_available === true && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Open update modal with this single agent
|
||||
setSingleAgentUpdate(agent.id);
|
||||
setShowUpdateModal(true);
|
||||
}}
|
||||
className="inline-flex items-center text-xs text-amber-600 bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 rounded-full cursor-pointer transition-colors"
|
||||
title="Click to update agent"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{agent.update_available === false && agent.current_version && (
|
||||
<span className="badge text-green-600 bg-green-50">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
return osInfo.distribution || agent.os_type;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
if (osInfo.version) {
|
||||
return `${osInfo.version} • ${agent.os_architecture || agent.architecture}`;
|
||||
}
|
||||
return `${agent.os_architecture || agent.architecture}`;
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(agent.last_seen)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.last_scan
|
||||
? formatRelativeTime(agent.last_scan)
|
||||
: 'Never'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedAgents([agent.id]);
|
||||
setShowUpdateModal(true);
|
||||
}}
|
||||
disabled={agent.is_updating}
|
||||
className={cn(
|
||||
"text-gray-400 hover:text-primary-600",
|
||||
agent.is_updating && "text-amber-600 animate-pulse"
|
||||
)}
|
||||
title={agent.is_updating ? "Agent is updating..." : "Update agent"}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="Remove agent"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="View details"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SortableTable
|
||||
columns={agentColumns}
|
||||
data={sortedAgents}
|
||||
getKey={(a) => a.id}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
selectable
|
||||
selected={selectedAgents}
|
||||
onSelectAll={(all) => handleSelectAll(all)}
|
||||
onSelectOne={(id, checked) => handleSelectAgent(id, checked)}
|
||||
emptyMessage="No agents match the current filters."
|
||||
/>
|
||||
</PageState>
|
||||
|
||||
{/* Agent Updates Modal */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue