mirror of
https://github.com/SpecterOps/Nemesis
synced 2026-06-08 12:36:42 +00:00
Merge pull request #73 from SpecterOps/frontend_modifications
Sorting by columns for Files and Findings
This commit is contained in:
@@ -671,7 +671,9 @@ const StatsOverview = () => {
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Status: {detail.status}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-blue-500">{detail.runtime_seconds.toFixed(2)}s</div>
|
||||
<div className="text-sm font-medium text-blue-500">
|
||||
{detail.runtime_seconds ? detail.runtime_seconds.toFixed(2) : '0.00'}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -704,7 +706,9 @@ const StatsOverview = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-red-500">{workflow.runtime_seconds?.toFixed(2)}s</div>
|
||||
<div className="text-sm font-medium text-red-500">
|
||||
{workflow.runtime_seconds ? workflow.runtime_seconds.toFixed(2) : '0.00'}s
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(workflow.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import Tooltip from '@/components/shared/Tooltip2';
|
||||
import { useTriageMode } from '@/contexts/TriageModeContext';
|
||||
import { useUser } from '@/contexts/UserContext';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import { AlertTriangle, ChevronDown, Clock, Eye, Search, Tag, X } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Clock, Eye, Search, Tag, X } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -120,6 +120,32 @@ const Row = React.memo(({ index, style, data }) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Sortable header component
|
||||
const SortableHeader = ({ children, column, currentSort, currentDirection, onSort, className = "" }) => {
|
||||
const isActive = currentSort === column;
|
||||
const nextDirection = isActive && currentDirection === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-2 ${className}`}
|
||||
onClick={() => onSort(column, nextDirection)}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">{children}</span>
|
||||
<div className="ml-1 flex flex-col">
|
||||
{isActive ? (
|
||||
currentDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3 text-gray-600 dark:text-gray-300" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-gray-600 dark:text-gray-300" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const FileList = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -138,7 +164,8 @@ const FileList = () => {
|
||||
const [agentIdFilter, setAgentIdFilter] = useState(() => searchParams.get('agent_id') || '');
|
||||
const [pathFilter, setPathFilter] = useState(() => searchParams.get('path') || '');
|
||||
const [objectIdFilter, setObjectIdFilter] = useState(() => searchParams.get('object_id') || '');
|
||||
const [sortNewestFirst, setSortNewestFirst] = useState(true);
|
||||
const [sortColumn, setSortColumn] = useState(() => searchParams.get('sort_column') || 'timestamp');
|
||||
const [sortDirection, setSortDirection] = useState(() => searchParams.get('sort_direction') || 'desc');
|
||||
const [viewFilter, setViewFilter] = useState(() => searchParams.get('view_state') || 'unviewed_by_me');
|
||||
const [showOnlyWithFindings, setShowOnlyWithFindings] = useState(false);
|
||||
|
||||
@@ -147,6 +174,12 @@ const FileList = () => {
|
||||
const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false);
|
||||
const tagDropdownRef = useRef(null);
|
||||
|
||||
// Handle column sorting
|
||||
const handleSort = (column, direction) => {
|
||||
setSortColumn(column);
|
||||
setSortDirection(direction);
|
||||
};
|
||||
|
||||
const handleRowClick = (e, file, index) => {
|
||||
if (isTriageMode) {
|
||||
if (e.shiftKey && selectedIndex !== -1) {
|
||||
@@ -269,8 +302,9 @@ const FileList = () => {
|
||||
params.set('tag', selectedTag);
|
||||
}
|
||||
|
||||
// Add sort order to URL params
|
||||
params.set('sort', sortNewestFirst ? 'newest' : 'oldest');
|
||||
// Add sort column and direction to URL params
|
||||
params.set('sort_column', sortColumn);
|
||||
params.set('sort_direction', sortDirection);
|
||||
|
||||
// Add findings filter to URL params
|
||||
if (showOnlyWithFindings) {
|
||||
@@ -279,7 +313,7 @@ const FileList = () => {
|
||||
|
||||
// Use replace: true to avoid adding to browser history for every filter change
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [fileTypeFilter, agentIdFilter, pathFilter, viewFilter, objectIdFilter, selectedTag, sortNewestFirst, showOnlyWithFindings]);
|
||||
}, [fileTypeFilter, agentIdFilter, pathFilter, viewFilter, objectIdFilter, selectedTag, sortColumn, sortDirection, showOnlyWithFindings]);
|
||||
|
||||
// Watch for URL changes and update the state
|
||||
useEffect(() => {
|
||||
@@ -290,7 +324,8 @@ const FileList = () => {
|
||||
const viewStateParam = searchParams.get('view_state');
|
||||
const objectIdParam = searchParams.get('object_id');
|
||||
const tagParam = searchParams.get('tag');
|
||||
const sortParam = searchParams.get('sort');
|
||||
const sortColumnParam = searchParams.get('sort_column');
|
||||
const sortDirectionParam = searchParams.get('sort_direction');
|
||||
const findingsParam = searchParams.get('findings');
|
||||
|
||||
// Update component state based on URL params
|
||||
@@ -301,9 +336,12 @@ const FileList = () => {
|
||||
setObjectIdFilter(objectIdParam || '');
|
||||
setSelectedTag(tagParam || '');
|
||||
|
||||
// Update sort order from URL
|
||||
if (sortParam !== null) {
|
||||
setSortNewestFirst(sortParam === 'newest');
|
||||
// Update sort column and direction from URL
|
||||
if (sortColumnParam !== null) {
|
||||
setSortColumn(sortColumnParam);
|
||||
}
|
||||
if (sortDirectionParam !== null) {
|
||||
setSortDirection(sortDirectionParam);
|
||||
}
|
||||
|
||||
// Update findings filter from URL
|
||||
@@ -422,9 +460,32 @@ const FileList = () => {
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.timestamp);
|
||||
const dateB = new Date(b.timestamp);
|
||||
return sortNewestFirst ? dateB - dateA : dateA - dateB;
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'agent_id':
|
||||
comparison = a.agent_id.toString().localeCompare(b.agent_id.toString());
|
||||
break;
|
||||
case 'size':
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
case 'timestamp':
|
||||
comparison = new Date(a.timestamp) - new Date(b.timestamp);
|
||||
break;
|
||||
case 'magic_type':
|
||||
comparison = (a.magic_type || '').localeCompare(b.magic_type || '');
|
||||
break;
|
||||
case 'findings':
|
||||
comparison = a.findingsByObjectId_aggregate.aggregate.count - b.findingsByObjectId_aggregate.aggregate.count;
|
||||
break;
|
||||
case 'path':
|
||||
comparison = a.path.localeCompare(b.path);
|
||||
break;
|
||||
default:
|
||||
comparison = new Date(a.timestamp) - new Date(b.timestamp);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
|
||||
@@ -806,17 +867,6 @@ const FileList = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip content={sortNewestFirst ? "Showing newest first" : "Showing oldest first"}>
|
||||
<button
|
||||
className="flex items-center space-x-2 px-3 py-2 border dark:border-gray-700 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setSortNewestFirst(!sortNewestFirst)}
|
||||
>
|
||||
<Clock className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{sortNewestFirst ? "Newest First" : "Oldest First"}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className="relative" ref={tagDropdownRef}>
|
||||
<button
|
||||
@@ -894,12 +944,60 @@ const FileList = () => {
|
||||
{isTriageMode && (
|
||||
<div className="w-8 px-2 py-2"></div>
|
||||
)}
|
||||
<div className="px-2 flex-shrink-0 w-32 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">Agent ID</div>
|
||||
<div className="px-2 flex-shrink-0 w-24 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">Size</div>
|
||||
<div className="px-2 flex-shrink-0 w-44 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">Time Uploaded</div>
|
||||
<div className="px-2 flex-shrink-0 w-48 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">Magic Type</div>
|
||||
<div className="px-2 flex-shrink-0 w-12 text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center justify-center">Findings</div>
|
||||
<div className="px-6 flex-grow text-sm font-medium text-gray-500 dark:text-gray-400 text-left">Path</div>
|
||||
<SortableHeader
|
||||
column="agent_id"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="flex-shrink-0 w-32"
|
||||
>
|
||||
Agent ID
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="size"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="flex-shrink-0 w-24"
|
||||
>
|
||||
Size
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="timestamp"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="flex-shrink-0 w-44"
|
||||
>
|
||||
Time Uploaded
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="magic_type"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="flex-shrink-0 w-48"
|
||||
>
|
||||
Magic Type
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="findings"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="flex-shrink-0 w-12 justify-center"
|
||||
>
|
||||
Findings
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="path"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="flex-grow px-4"
|
||||
>
|
||||
Path
|
||||
</SortableHeader>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
const FindingsFilters = ({
|
||||
findings,
|
||||
onFilteredDataChange
|
||||
onFilteredDataChange,
|
||||
sortColumn,
|
||||
sortDirection
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -121,9 +123,9 @@ const FindingsFilters = ({
|
||||
}
|
||||
}, [categoryFilter, severityFilter, originFilter, triageFilter, triageSourceFilter, objectIdFilter, setSearchParams, searchParams]);
|
||||
|
||||
// Memoized filtered findings calculation
|
||||
// Memoized filtered and sorted findings calculation
|
||||
const filteredFindings = useMemo(() => {
|
||||
return findings.filter(finding => {
|
||||
const filtered = findings.filter(finding => {
|
||||
// Category filter
|
||||
if (categoryFilter !== 'all' && finding.category !== categoryFilter) return false;
|
||||
|
||||
@@ -173,7 +175,44 @@ const FindingsFilters = ({
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [findings, categoryFilter, severityFilter, originFilter, triageFilter, triageSourceFilter, objectIdFilter]);
|
||||
|
||||
// Apply sorting
|
||||
return filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'severity':
|
||||
comparison = a.severity - b.severity;
|
||||
break;
|
||||
case 'created_at':
|
||||
comparison = new Date(a.created_at) - new Date(b.created_at);
|
||||
break;
|
||||
case 'finding_name':
|
||||
comparison = a.finding_name.localeCompare(b.finding_name);
|
||||
break;
|
||||
case 'category':
|
||||
comparison = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'origin_name':
|
||||
comparison = a.origin_name.localeCompare(b.origin_name);
|
||||
break;
|
||||
case 'file_path':
|
||||
const pathA = a.files_enriched?.path || '';
|
||||
const pathB = b.files_enriched?.path || '';
|
||||
comparison = pathA.localeCompare(pathB);
|
||||
break;
|
||||
case 'triage_value':
|
||||
const triageA = a.finding_triage_histories.length > 0 ? a.finding_triage_histories[0].value : '';
|
||||
const triageB = b.finding_triage_histories.length > 0 ? b.finding_triage_histories[0].value : '';
|
||||
comparison = triageA.localeCompare(triageB);
|
||||
break;
|
||||
default:
|
||||
comparison = new Date(a.created_at) - new Date(b.created_at);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [findings, categoryFilter, severityFilter, originFilter, triageFilter, triageSourceFilter, objectIdFilter, sortColumn, sortDirection]);
|
||||
|
||||
// Notify parent component of filtered data changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -43,6 +43,40 @@ const FindingsList = () => {
|
||||
const lastDirection = useRef('down');
|
||||
const listRef = useRef();
|
||||
|
||||
// Sorting state
|
||||
const [sortColumn, setSortColumn] = useState(() => searchParams.get('sort_column') || 'created_at');
|
||||
const [sortDirection, setSortDirection] = useState(() => searchParams.get('sort_direction') || 'desc');
|
||||
|
||||
// Handle column sorting
|
||||
const handleSort = (column, direction) => {
|
||||
setSortColumn(column);
|
||||
setSortDirection(direction);
|
||||
};
|
||||
|
||||
// Update URL parameters when sort changes
|
||||
useEffect(() => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('sort_column', sortColumn);
|
||||
newParams.set('sort_direction', sortDirection);
|
||||
|
||||
if (newParams.toString() !== searchParams.toString()) {
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [sortColumn, sortDirection, searchParams, setSearchParams]);
|
||||
|
||||
// Update sort state when URL parameters change
|
||||
useEffect(() => {
|
||||
const urlSortColumn = searchParams.get('sort_column');
|
||||
const urlSortDirection = searchParams.get('sort_direction');
|
||||
|
||||
if (urlSortColumn && urlSortColumn !== sortColumn) {
|
||||
setSortColumn(urlSortColumn);
|
||||
}
|
||||
if (urlSortDirection && urlSortDirection !== sortDirection) {
|
||||
setSortDirection(urlSortDirection);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Function to handle multi-selection triage
|
||||
const handleBulkTriage = (value) => {
|
||||
selectedFindings.forEach(findingId => {
|
||||
@@ -494,12 +528,19 @@ const FindingsList = () => {
|
||||
<FindingsFilters
|
||||
findings={findings}
|
||||
onFilteredDataChange={setFilteredFindings}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
/>
|
||||
|
||||
{/* Findings Table */}
|
||||
<div className="overflow-x-auto">
|
||||
{/* Headers - Keep these outside the virtualized area */}
|
||||
<TableHeaders isTriageMode={isTriageMode} />
|
||||
<TableHeaders
|
||||
isTriageMode={isTriageMode}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
{/* Virtualized List or No Findings Message */}
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,36 +1,104 @@
|
||||
import Tooltip from '@/components/shared/Tooltip2';
|
||||
import { Bot, HelpCircle, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { Bot, ChevronDown, ChevronUp, HelpCircle, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useFileNavigation } from './navigation';
|
||||
|
||||
// Sortable header component
|
||||
const SortableHeader = ({ children, column, currentSort, currentDirection, onSort, className = "" }) => {
|
||||
const isActive = currentSort === column;
|
||||
const nextDirection = isActive && currentDirection === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-2 ${className}`}
|
||||
onClick={() => onSort(column, nextDirection)}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">{children}</span>
|
||||
<div className="ml-1 flex flex-col">
|
||||
{isActive ? (
|
||||
currentDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3 text-gray-600 dark:text-gray-300" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-gray-600 dark:text-gray-300" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TableHeaders = ({ isTriageMode }) => (
|
||||
|
||||
export const TableHeaders = ({ isTriageMode, sortColumn, sortDirection, onSort }) => (
|
||||
<div className="min-w-full">
|
||||
<div className="flex bg-gray-50 dark:bg-gray-800 border-b dark:border-gray-700">
|
||||
{isTriageMode && (
|
||||
<div className="flex-shrink-0 w-8 text-sm font-medium text-gray-500 dark:text-gray-400 text-center" />
|
||||
)}
|
||||
<div className="flex-shrink-0 w-16 text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
|
||||
<SortableHeader
|
||||
column="severity"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-shrink-0 w-16 justify-center"
|
||||
>
|
||||
Severity
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-40 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="created_at"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-shrink-0 w-40"
|
||||
>
|
||||
Timestamp
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-48 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="finding_name"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-shrink-0 w-48"
|
||||
>
|
||||
Name
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-40 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="category"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-shrink-0 w-40"
|
||||
>
|
||||
Category
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-40 text-sm font-medium text-gray-500 dark:text-gray-400 text-left">
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="origin_name"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-shrink-0 w-40"
|
||||
>
|
||||
Origin
|
||||
</div>
|
||||
<div className="flex-grow min-w-[300px] text-sm font-medium text-gray-500 dark:text-gray-400 text-left">
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="file_path"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-grow min-w-[300px]"
|
||||
>
|
||||
File Path
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-[140px] text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
|
||||
<div className="mr-6">Actions</div>
|
||||
</div>
|
||||
</SortableHeader>
|
||||
<SortableHeader
|
||||
column="triage_value"
|
||||
currentSort={sortColumn}
|
||||
currentDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
className="flex-shrink-0 w-[140px] justify-center"
|
||||
>
|
||||
Actions
|
||||
</SortableHeader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user