Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-11 17:38:08 -04:00
commit a4d585c79c
14 changed files with 1109 additions and 401 deletions

View file

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

View 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();
});
});

View file

@ -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.'
}

View 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
});
});

View file

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