Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-11 01:51:17 -04:00
commit 488cca2dd5
3 changed files with 343 additions and 222 deletions

View 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&hellip;
</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;

View file

@ -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';

View file

@ -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 */}