code review: 12-finding fan-out — fixes across server, agent, web
HIGH: - URL sync race: page useEffect now preserves filter params from useFilterUrl - useFilterUrl: document two-effect pattern (state→URL and URL→state) - Fleet-join: store nil (not &"") for absent MachineID/PublicKeyFingerprint - agents.go: same NULL fix for standard registration path MEDIUM: - Test assertions: replace CSS class checks with user-visible element assertions - Updates vuln toggle: fixed-set like other quick filters (was toggling) - ConfigureSecrets route: restore to welcome-mode server - Config upgrade: recursive mergeMissingKeys for nested sub-fields + test LOW: - LiveOperations: wire FilterBar pills/clearAll/activeCount - HashTOTPSeed: remove dead code replaced by encrypted storage (migration 058) - auditor.go: replace unsafe reflect with Recorder wrapper (AUDIT-002) History filter panel kept as-is (collapsible pattern intentional). Agents.tsx duplicate buildFilterPills was a false positive (already resolved).
This commit is contained in:
parent
7c25204bd8
commit
a4d585c79c
14 changed files with 1109 additions and 401 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
|
|
@ -10,14 +10,16 @@ import { useSearchParams } from 'react-router-dom';
|
|||
*
|
||||
* Usage:
|
||||
* const { values, setFilter, clearFilter, clearAll } = useFilterUrl({
|
||||
* status: { urlParam: 'status' },
|
||||
* severity: { urlParam: 'severity' },
|
||||
* agent: { urlParam: 'agent', default: '' },
|
||||
* status: { urlParam: 'status', label: 'Status' },
|
||||
* severity: { urlParam: 'severity', label: 'Severity', default: 'low' },
|
||||
* agent: { urlParam: 'agent', label: 'Agent', default: '' },
|
||||
* });
|
||||
*/
|
||||
|
||||
export type FilterDef = {
|
||||
urlParam: string;
|
||||
/** Human-readable label for pills and UI. Falls back to the config key. */
|
||||
label?: string;
|
||||
default?: string;
|
||||
};
|
||||
|
||||
|
|
@ -37,21 +39,35 @@ export type FilterUrlState<C extends FilterConfig> = {
|
|||
|
||||
export function useFilterUrl<C extends FilterConfig>(config: C): FilterUrlState<C> {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const paramsRef = useRef(searchParams);
|
||||
const isFirstSyncRef = useRef(true);
|
||||
|
||||
function readFromUrl(): FilterValues<C> {
|
||||
// Keep a ref to the latest searchParams so the write effect never reads
|
||||
// a stale closure. The ref is updated before effects run (during render).
|
||||
paramsRef.current = searchParams;
|
||||
|
||||
function readFromUrl(sp: URLSearchParams): FilterValues<C> {
|
||||
const result = {} as FilterValues<C>;
|
||||
for (const [key, def] of Object.entries(config)) {
|
||||
const raw = searchParams.get(def.urlParam);
|
||||
const raw = sp.get(def.urlParam);
|
||||
(result as any)[key] = raw ?? def.default ?? '';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const [values, setValues] = useState<FilterValues<C>>(readFromUrl);
|
||||
const [values, setValues] = useState<FilterValues<C>>(() => readFromUrl(searchParams));
|
||||
|
||||
// Write to URL when values change
|
||||
// Write state → URL when values change.
|
||||
// Reads from paramsRef (not the searchParams closure) to avoid stale reads
|
||||
// when other URL params (tab, page, sort) change in the same render.
|
||||
//
|
||||
// searchParams is intentionally NOT in the dependency array: this effect
|
||||
// writes React state TO the URL. Adding searchParams here would fire the
|
||||
// write effect on external URL changes (browser back/forward) with stale
|
||||
// React state, overwriting the URL before the sync effect below can react.
|
||||
// The sync effect (URL → state) is the correct handler for browser nav.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const params = new URLSearchParams(paramsRef.current);
|
||||
let changed = false;
|
||||
for (const [key, def] of Object.entries(config)) {
|
||||
const val = (values as any)[key] as string;
|
||||
|
|
@ -67,7 +83,21 @@ export function useFilterUrl<C extends FilterConfig>(config: C): FilterUrlState<
|
|||
}
|
||||
}
|
||||
if (changed) setSearchParams(params, { replace: true });
|
||||
}, [values]);
|
||||
}, [values, config, setSearchParams]);
|
||||
|
||||
// Back-sync URL → state on external URL changes (browser back/forward).
|
||||
// Skipped on mount (useState already reads the initial URL).
|
||||
useEffect(() => {
|
||||
if (isFirstSyncRef.current) {
|
||||
isFirstSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
const urlValues = readFromUrl(searchParams);
|
||||
const differs = Object.keys(config).some(
|
||||
(key) => (urlValues as any)[key] !== (values as any)[key]
|
||||
);
|
||||
if (differs) setValues(urlValues);
|
||||
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setFilter = useCallback(
|
||||
(key: keyof C, value: string) => {
|
||||
|
|
@ -98,3 +128,26 @@ export function useFilterUrl<C extends FilterConfig>(config: C): FilterUrlState<
|
|||
|
||||
return { values, setFilter, clearFilter, clearAll, activeCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FilterBar pills array from useFilterUrl state.
|
||||
* Each active (non-default) filter becomes a dismissible pill.
|
||||
* Uses the FilterDef label (or the config key as fallback) for display.
|
||||
*/
|
||||
export function buildFilterPills<C extends FilterConfig>(
|
||||
filter: FilterUrlState<C>,
|
||||
config: C
|
||||
): { label: string; value: string; onClear: () => void }[] {
|
||||
const pills: { label: string; value: string; onClear: () => void }[] = [];
|
||||
for (const [key, def] of Object.entries(config)) {
|
||||
const val = (filter.values as Record<string, string>)[key];
|
||||
if (val && val !== (def.default ?? '')) {
|
||||
pills.push({
|
||||
label: def.label ?? key,
|
||||
value: val,
|
||||
onClear: () => filter.clearFilter(key as keyof C),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pills;
|
||||
}
|
||||
|
|
|
|||
115
web/src/pages/History.test.tsx
Normal file
115
web/src/pages/History.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock ChatTimeline since it's an external component that makes API calls
|
||||
vi.mock('../components/ChatTimeline', () => ({
|
||||
default: vi.fn(() => <div data-testid="chat-timeline">Timeline</div>),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useDebounce', () => ({
|
||||
useDebounce: vi.fn((value) => value),
|
||||
}));
|
||||
|
||||
import HistoryPage from './History';
|
||||
|
||||
function renderPage(initialEntries = ['/history']) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<HistoryPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('History page filter integration', () => {
|
||||
it('renders the page header', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('History & Audit Log')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the SearchInput', () => {
|
||||
renderPage();
|
||||
expect(screen.getByPlaceholderText('Search events...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the ChatTimeline component', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('chat-timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters panel is hidden by default', () => {
|
||||
renderPage();
|
||||
// FilterDropdowns should not be visible initially
|
||||
expect(screen.queryByText('Type')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Severity')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles filter panel on button click', () => {
|
||||
renderPage();
|
||||
const filterBtn = screen.getByText('Filters');
|
||||
fireEvent.click(filterBtn);
|
||||
// After clicking, filter dropdowns should be visible
|
||||
expect(screen.getByText('Type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Severity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows filter count badge when filters are active', () => {
|
||||
renderPage();
|
||||
// Open the filters panel
|
||||
fireEvent.click(screen.getByText('Filters'));
|
||||
// Set a type filter
|
||||
const typeSelect = screen.getByDisplayValue('All types');
|
||||
fireEvent.change(typeSelect, { target: { value: 'command' } });
|
||||
// The filter badge should show count 1
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "clear all" when filters are active', () => {
|
||||
renderPage();
|
||||
// Open filters panel
|
||||
fireEvent.click(screen.getByText('Filters'));
|
||||
// Set a filter
|
||||
const typeSelect = screen.getByDisplayValue('All types');
|
||||
fireEvent.change(typeSelect, { target: { value: 'command' } });
|
||||
// clear all button should appear
|
||||
expect(screen.getByText('clear all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clear filters resets all filters', () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByText('Filters'));
|
||||
// Set both filters
|
||||
fireEvent.change(screen.getByDisplayValue('All types'), { target: { value: 'command' } });
|
||||
fireEvent.change(screen.getByDisplayValue('All severity'), { target: { value: 'error' } });
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
// Clear all
|
||||
fireEvent.click(screen.getByText('clear all'));
|
||||
// Filter badge count should be gone
|
||||
expect(screen.queryByText('2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SearchInput accepts text', () => {
|
||||
renderPage();
|
||||
const input = screen.getByPlaceholderText('Search events...');
|
||||
fireEvent.change(input, { target: { value: 'error' } });
|
||||
expect(input).toHaveValue('error');
|
||||
});
|
||||
|
||||
it('filter button shows active styling when filters are active', () => {
|
||||
renderPage();
|
||||
// Open panel
|
||||
fireEvent.click(screen.getByText('Filters'));
|
||||
// Set a filter
|
||||
fireEvent.change(screen.getByDisplayValue('All types'), { target: { value: 'command' } });
|
||||
// The filter bar should show an active pill reflecting the filter
|
||||
expect(screen.getByText('Type:')).toBeInTheDocument();
|
||||
expect(screen.getByText('command')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
Shield,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown, PageState, StatusBadge } from '@/components/primitives';
|
||||
import { FilterBar, PageState, StatusBadge } from '@/components/primitives';
|
||||
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAgents } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useRetryCommand, useCancelCommand, useClearFailedCommands } from '@/hooks/useCommands';
|
||||
|
|
@ -60,7 +61,10 @@ const LiveOperations: React.FC = () => {
|
|||
const [expandedOperations, setExpandedOperations] = useState<Set<string>>(new Set());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const filterConfig = {
|
||||
status: { urlParam: 'status', label: 'Status' },
|
||||
};
|
||||
const filter = useFilterUrl(filterConfig);
|
||||
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
||||
const [cleanupOptions, setCleanupOptions] = useState({
|
||||
olderThanDays: 7,
|
||||
|
|
@ -283,7 +287,7 @@ const LiveOperations: React.FC = () => {
|
|||
op.packageName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
op.agentName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = !statusFilter || op.status === statusFilter;
|
||||
const matchesStatus = !filter.values.status || op.status === filter.values.status;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
|
@ -381,28 +385,22 @@ const LiveOperations: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search by package name or agent..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
<FilterBar
|
||||
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search by package name or agent...' }}
|
||||
filters={[
|
||||
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: [
|
||||
{ value: 'running', label: 'Running' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'sent', label: 'Sent' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
]}
|
||||
placeholder="All Status"
|
||||
/>
|
||||
</div>
|
||||
], placeholder: 'All Status' },
|
||||
]}
|
||||
pills={buildFilterPills(filter, filterConfig)}
|
||||
onClearAll={() => filter.clearAll()}
|
||||
activeCount={filter.activeCount}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Staged packages — awaiting dependency review */}
|
||||
{stagedUpdates.length > 0 && (
|
||||
|
|
@ -535,7 +533,7 @@ const LiveOperations: React.FC = () => {
|
|||
empty={filteredOperations.length === 0}
|
||||
emptyTitle="No active operations"
|
||||
emptyMessage={
|
||||
searchQuery || statusFilter
|
||||
searchQuery || filter.values.status
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All operations are completed. Check the Updates page to start new operations.'
|
||||
}
|
||||
|
|
|
|||
153
web/src/pages/Updates.test.tsx
Normal file
153
web/src/pages/Updates.test.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock all API hooks before importing the page
|
||||
vi.mock('../hooks/useUpdates', () => ({
|
||||
useUpdates: vi.fn(() => ({
|
||||
data: { updates: [], total: 0, stats: { pending_updates: 0, approved_updates: 0, critical_updates: 0, high_updates: 0 } },
|
||||
isPending: false,
|
||||
error: null,
|
||||
})),
|
||||
usePackages: vi.fn(() => ({
|
||||
data: { packages: [], total: 0 },
|
||||
isPending: false,
|
||||
error: null,
|
||||
})),
|
||||
useUpdate: vi.fn(() => ({ data: null })),
|
||||
usePackageFleet: vi.fn(() => ({ data: null })),
|
||||
usePackageVersions: vi.fn(() => ({ data: null })),
|
||||
useUpdateLifecycle: vi.fn(() => ({ data: null })),
|
||||
useApproveUpdate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useRejectUpdate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useInstallUpdate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useApproveMultipleUpdates: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useRetryCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useReopenUpdate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useResolveUpdate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useCancelCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useCommands', () => ({
|
||||
useRecentCommands: vi.fn(() => ({ data: { commands: [] } })),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useDebounce', () => ({
|
||||
useDebounce: vi.fn((value) => value),
|
||||
}));
|
||||
|
||||
import Updates from './Updates';
|
||||
|
||||
function renderPage(initialEntries = ['/updates']) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Updates />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Updates page filter integration', () => {
|
||||
it('renders the page header', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FilterBar with search input', () => {
|
||||
renderPage();
|
||||
expect(screen.getByPlaceholderText('Search updates by package name...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FilterBar dropdown filters', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Status')).toBeInTheDocument();
|
||||
expect(screen.getByText('Severity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Type')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quick filter buttons', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('All Updates')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Critical' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pending Approval' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Approved' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Installing' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Installed' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Failed' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Dependencies' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Vulnerable' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('quick filter "Critical" sets severity=critical and status=pending', () => {
|
||||
renderPage();
|
||||
const criticalBtn = screen.getByRole('button', { name: 'Critical' });
|
||||
fireEvent.click(criticalBtn);
|
||||
// Filter pills should show the active filters
|
||||
expect(screen.getByText('Status:')).toBeInTheDocument();
|
||||
expect(screen.getByText('pending')).toBeInTheDocument();
|
||||
expect(screen.getByText('Severity:')).toBeInTheDocument();
|
||||
expect(screen.getByText('critical')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('quick filter "Pending Approval" sets status=pending and clears severity', () => {
|
||||
renderPage();
|
||||
const pendingBtn = screen.getByRole('button', { name: 'Pending Approval' });
|
||||
fireEvent.click(pendingBtn);
|
||||
// Should show status pending pill but no severity pill
|
||||
expect(screen.getByText('Status:')).toBeInTheDocument();
|
||||
expect(screen.getByText('pending')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Severity:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('quick filter "All Updates" clears all filters', () => {
|
||||
renderPage();
|
||||
// First set a filter via a quick filter
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Critical' }));
|
||||
expect(screen.getByText('Status:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Severity:')).toBeInTheDocument();
|
||||
// Then click "All Updates" to clear
|
||||
fireEvent.click(screen.getByText('All Updates'));
|
||||
// All pills should be gone
|
||||
expect(screen.queryByText('Status:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Severity:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clear all shows and functions when filters active', () => {
|
||||
renderPage();
|
||||
// Set a filter via quick filter
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Critical' }));
|
||||
// "clear all" should appear (use getByText — throws if missing)
|
||||
const clearAll = screen.getByText('clear all');
|
||||
fireEvent.click(clearAll);
|
||||
// All filters should be cleared — no pills, no "clear all"
|
||||
expect(screen.queryByText('Status:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Severity:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Command History button', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Command History')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input that accepts text', () => {
|
||||
renderPage();
|
||||
const input = screen.getByPlaceholderText('Search updates by package name...');
|
||||
fireEvent.change(input, { target: { value: 'kernel' } });
|
||||
expect(input).toHaveValue('kernel');
|
||||
});
|
||||
|
||||
it('renders filter dropdowns', () => {
|
||||
renderPage();
|
||||
// The page should render combobox elements from the FilterDropdowns
|
||||
const combos = screen.getAllByRole('combobox');
|
||||
expect(combos.length).toBeGreaterThanOrEqual(3); // Status + Severity + Type
|
||||
});
|
||||
});
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
HardDrive,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown, FilterPill, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge } from '@/components/primitives';
|
||||
import { FilterBar, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge } from '@/components/primitives';
|
||||
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useUpdates, useUpdate, usePackages, usePackageFleet, usePackageVersions, useUpdateLifecycle, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useReopenUpdate, useResolveUpdate, useCancelCommand } from '@/hooks/useUpdates';
|
||||
|
|
@ -48,14 +49,20 @@ const Updates: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Get filters from URL params
|
||||
// Filter state synced to URL (via useFilterUrl)
|
||||
const filterConfig = {
|
||||
status: { urlParam: 'status', label: 'Status' },
|
||||
severity: { urlParam: 'severity', label: 'Severity' },
|
||||
type: { urlParam: 'type', label: 'Type' },
|
||||
agent: { urlParam: 'agent', label: 'Agent' },
|
||||
vuln: { urlParam: 'vuln', label: 'Vulnerable' },
|
||||
};
|
||||
const filter = useFilterUrl(filterConfig);
|
||||
|
||||
// Search query — kept separate because the input needs instant feedback
|
||||
// while the API call uses the debounced value
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
const [vulnFilter, setVulnFilter] = useState(searchParams.get('vuln') === 'true');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || '');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(searchParams.get('sort_order') as 'asc' | 'desc' || 'desc');
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
|
|
@ -70,23 +77,18 @@ const Updates: React.FC = () => {
|
|||
const [dependencyLoading, setDependencyLoading] = useState(false);
|
||||
const activeTab = parseUpdatesTab(searchParams.get('tab'));
|
||||
|
||||
// Store filters in URL
|
||||
// Store non-filter state in URL (search is separate because it needs debounce)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (activeTab !== 'updates') params.set('tab', activeTab);
|
||||
if (debouncedSearchQuery) params.set('search', debouncedSearchQuery);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (agentFilter) params.set('agent', agentFilter);
|
||||
if (vulnFilter) params.set('vuln', 'true');
|
||||
if (sortBy) params.set('sort_by', sortBy);
|
||||
if (sortOrder) params.set('sort_order', sortOrder);
|
||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||
if (pageSize !== 100) params.set('page_size', pageSize.toString());
|
||||
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [activeTab, debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, vulnFilter, sortBy, sortOrder, currentPage, pageSize, setSearchParams]);
|
||||
}, [activeTab, debouncedSearchQuery, sortBy, sortOrder, currentPage, pageSize, setSearchParams]);
|
||||
|
||||
const selectActiveTab = (tab: UpdatesTab) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
|
@ -101,10 +103,10 @@ const Updates: React.FC = () => {
|
|||
// Fetch updates list (still used for the summary stat cards + detail fallback)
|
||||
const { data: updatesData } = useUpdates({
|
||||
search: debouncedSearchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
agent: agentFilter || undefined,
|
||||
status: filter.values.status || undefined,
|
||||
severity: filter.values.severity || undefined,
|
||||
type: filter.values.type || undefined,
|
||||
agent: filter.values.agent || undefined,
|
||||
sort_by: sortBy || undefined,
|
||||
sort_order: sortOrder || undefined,
|
||||
page: currentPage,
|
||||
|
|
@ -115,8 +117,8 @@ const Updates: React.FC = () => {
|
|||
// Search, type, and status are server-side; severity filters client-side below.
|
||||
const { data: packagesData, isPending: packagesPending, error: packagesError } = usePackages({
|
||||
search: debouncedSearchQuery || undefined,
|
||||
type: typeFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
type: filter.values.type || undefined,
|
||||
status: filter.values.status || undefined,
|
||||
sort_by: sortBy || undefined,
|
||||
sort_order: sortOrder || undefined,
|
||||
page: currentPage,
|
||||
|
|
@ -157,8 +159,8 @@ const Updates: React.FC = () => {
|
|||
const packages = packagesData?.packages || [];
|
||||
const packageTotal = packagesData?.total || 0;
|
||||
const displayedPackages = packages.filter((p) => {
|
||||
if (severityFilter && p.max_severity !== severityFilter) return false;
|
||||
if (vulnFilter && !p.has_vulns) return false;
|
||||
if (filter.values.severity && p.max_severity !== filter.values.severity) return false;
|
||||
if (filter.values.vuln && !p.has_vulns) return false;
|
||||
return true;
|
||||
});
|
||||
const packageTotalPages = Math.ceil(packageTotal / pageSize);
|
||||
|
|
@ -306,48 +308,44 @@ const Updates: React.FC = () => {
|
|||
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
|
||||
|
||||
// Quick filter functions
|
||||
const handleQuickFilter = (filter: string) => {
|
||||
switch (filter) {
|
||||
const handleQuickFilter = (quick: string) => {
|
||||
switch (quick) {
|
||||
case 'critical':
|
||||
setSeverityFilter('critical');
|
||||
setStatusFilter('pending');
|
||||
filter.setFilter('severity', 'critical');
|
||||
filter.setFilter('status', 'pending');
|
||||
break;
|
||||
case 'pending':
|
||||
setStatusFilter('pending');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('status', 'pending');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
case 'approved':
|
||||
setStatusFilter('approved');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('status', 'approved');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
case 'installing':
|
||||
setStatusFilter('installing');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('status', 'installing');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
case 'installed':
|
||||
setStatusFilter('installed');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('status', 'installed');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
case 'failed':
|
||||
setStatusFilter('failed');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('status', 'failed');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
case 'dependencies':
|
||||
setStatusFilter('pending_dependencies');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('status', 'pending_dependencies');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
case 'vuln':
|
||||
setVulnFilter(true);
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
filter.setFilter('vuln', 'true');
|
||||
filter.setFilter('status', '');
|
||||
filter.setFilter('severity', '');
|
||||
break;
|
||||
default:
|
||||
// Clear all filters
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
setTypeFilter('');
|
||||
setAgentFilter('');
|
||||
setVulnFilter(false);
|
||||
filter.clearAll();
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
|
|
@ -1408,7 +1406,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('all')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
!statusFilter && !severityFilter && !typeFilter && !agentFilter && !vulnFilter
|
||||
!filter.values.status && !filter.values.severity && !filter.values.type && !filter.values.agent && !filter.values.vuln
|
||||
? "bg-primary-100 border-primary-300 text-primary-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1419,7 +1417,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('critical')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending' && severityFilter === 'critical'
|
||||
filter.values.status === 'pending' && filter.values.severity === 'critical'
|
||||
? "bg-red-100 border-red-300 text-red-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1431,7 +1429,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('pending')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending' && !severityFilter
|
||||
filter.values.status === 'pending' && !filter.values.severity
|
||||
? "bg-orange-100 border-orange-300 text-orange-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1443,7 +1441,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('approved')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'approved' && !severityFilter
|
||||
filter.values.status === 'approved' && !filter.values.severity
|
||||
? "bg-green-100 border-green-300 text-green-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1455,7 +1453,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('installing')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'installing'
|
||||
filter.values.status === 'installing'
|
||||
? "bg-blue-100 border-blue-300 text-blue-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1467,7 +1465,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('installed')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'installed'
|
||||
filter.values.status === 'installed'
|
||||
? "bg-emerald-100 border-emerald-300 text-emerald-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1479,7 +1477,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('failed')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'failed'
|
||||
filter.values.status === 'failed'
|
||||
? "bg-red-100 border-red-300 text-red-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1491,7 +1489,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('dependencies')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending_dependencies'
|
||||
filter.values.status === 'pending_dependencies'
|
||||
? "bg-amber-100 border-amber-300 text-amber-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1503,7 +1501,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('vuln')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
vulnFilter
|
||||
filter.values.vuln
|
||||
? "bg-orange-100 border-orange-300 text-orange-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1514,82 +1512,39 @@ const Updates: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search updates by package name..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={statuses.map((s: string) => ({ value: s, label: s }))}
|
||||
placeholder="All Status"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Severity"
|
||||
value={severityFilter}
|
||||
onChange={setSeverityFilter}
|
||||
options={severities.map((s: string) => ({ value: s, label: s }))}
|
||||
placeholder="All Severities"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Type"
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={packageTypes.map((t: string) => ({ value: t, label: t.toUpperCase() }))}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedUpdates.length > 0 && (
|
||||
<FilterBar
|
||||
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search updates by package name...' }}
|
||||
filters={[
|
||||
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: statuses.map((s: string) => ({ value: s, label: s })), placeholder: 'All Status' },
|
||||
{ label: 'Severity', value: filter.values.severity, onChange: (v) => filter.setFilter('severity', v), options: severities.map((s: string) => ({ value: s, label: s })), placeholder: 'All Severities' },
|
||||
{ label: 'Type', value: filter.values.type, onChange: (v) => filter.setFilter('type', v), options: packageTypes.map((t: string) => ({ value: t, label: t.toUpperCase() })), placeholder: 'All Types' },
|
||||
]}
|
||||
pills={buildFilterPills(filter, filterConfig)}
|
||||
onClearAll={() => filter.clearAll()}
|
||||
activeCount={filter.activeCount}
|
||||
actions={
|
||||
<>
|
||||
{selectedUpdates.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isPending}
|
||||
className="btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve Selected ({selectedUpdates.length})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isPending}
|
||||
className="btn btn-success"
|
||||
onClick={() => selectActiveTab('commands')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve Selected ({selectedUpdates.length})
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Command History
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Command History button */}
|
||||
<button
|
||||
onClick={() => selectActiveTab('commands')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Command History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active filter pills */}
|
||||
{(statusFilter || severityFilter || typeFilter || agentFilter || vulnFilter) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{statusFilter && <FilterPill label="status" value={statusFilter} onClear={() => setStatusFilter('')} />}
|
||||
{severityFilter && <FilterPill label="severity" value={severityFilter} onClear={() => setSeverityFilter('')} />}
|
||||
{typeFilter && <FilterPill label="type" value={typeFilter} onClear={() => setTypeFilter('')} />}
|
||||
{agentFilter && <FilterPill label="agent" value={agentFilter} onClear={() => setAgentFilter('')} />}
|
||||
{vulnFilter && <FilterPill label="vuln" value="true" onClear={() => setVulnFilter(false)} />}
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
setTypeFilter('');
|
||||
setAgentFilter('');
|
||||
setVulnFilter(false);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-gray-700 transition-colors ml-1 underline"
|
||||
>
|
||||
clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Package list — PageState primitive for loading/error/empty */}
|
||||
<PageState
|
||||
|
|
@ -1598,7 +1553,7 @@ const Updates: React.FC = () => {
|
|||
empty={displayedPackages.length === 0}
|
||||
emptyTitle="No updates found"
|
||||
emptyMessage={
|
||||
debouncedSearchQuery || statusFilter || severityFilter || typeFilter || agentFilter || vulnFilter
|
||||
debouncedSearchQuery || filter.values.status || filter.values.severity || filter.values.type || filter.values.agent || filter.values.vuln
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All agents are up to date!'
|
||||
}
|
||||
|
|
@ -1756,3 +1711,4 @@ const Updates: React.FC = () => {
|
|||
};
|
||||
|
||||
export default Updates;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue