Watch
1
0
Fork
You've already forked RedFlag
0

web: wire FilterBar + useFilterUrl across pages

Agents and History pages now use the primitives FilterBar with
URL-synced filter state via useFilterUrl. Adds test setup infra
(vitest + jsdom) and page-level test shells.
This commit is contained in:
Fimeg 2026-06-11 17:47:39 -04:00
commit 7deca9bc25
12 changed files with 6954 additions and 3121 deletions

3098
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,19 +28,24 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"jsdom": "29.1.1",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "8.0.14"
"vite": "8.0.14",
"vitest": "2.1.9"
}
}

View file

@ -0,0 +1,174 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import FilterBar from './FilterBar';
describe('FilterBar', () => {
it('renders search input when search prop is provided', () => {
render(
<FilterBar
search={{ value: '', onChange: () => {} }}
/>
);
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
});
it('renders search with custom placeholder', () => {
render(
<FilterBar
search={{ value: '', onChange: () => {}, placeholder: 'Find stuff...' }}
/>
);
expect(screen.getByPlaceholderText('Find stuff...')).toBeInTheDocument();
});
it('calls search onChange when input value changes', () => {
const onChange = vi.fn();
render(
<FilterBar
search={{ value: '', onChange }}
/>
);
const input = screen.getByPlaceholderText('Search...');
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toHaveBeenCalledWith('foo');
});
it('renders dropdown filters from filters prop', () => {
const onChange = vi.fn();
render(
<FilterBar
filters={[
{ label: 'Status', value: '', onChange, options: [{ value: 'online', label: 'Online' }, { value: 'offline', label: 'Offline' }], placeholder: 'All Status' },
{ label: 'OS', value: '', onChange, options: [{ value: 'linux', label: 'Linux' }], placeholder: 'All OS' },
]}
/>
);
// Labels rendered
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('OS')).toBeInTheDocument();
// Selects rendered
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
expect(screen.getByDisplayValue('All OS')).toBeInTheDocument();
});
it('renders pills when pills array is non-empty', () => {
render(
<FilterBar
pills={[
{ label: 'status', value: 'pending', onClear: () => {} },
{ label: 'severity', value: 'critical', onClear: () => {} },
]}
activeCount={2}
/>
);
expect(screen.getByText('pending')).toBeInTheDocument();
expect(screen.getByText('critical')).toBeInTheDocument();
expect(screen.getByText('status:')).toBeInTheDocument();
expect(screen.getByText('severity:')).toBeInTheDocument();
});
it('shows clear all button when activeCount > 0 and onClearAll is provided', () => {
render(
<FilterBar
pills={[{ label: 'status', value: 'pending', onClear: () => {} }]}
onClearAll={() => {}}
activeCount={1}
/>
);
expect(screen.getByText('clear all')).toBeInTheDocument();
});
it('does not show clear all button when activeCount is 0', () => {
render(
<FilterBar
pills={[]}
onClearAll={() => {}}
activeCount={0}
/>
);
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
});
it('does not show clear all button when onClearAll is missing', () => {
render(
<FilterBar
pills={[{ label: 'status', value: 'pending', onClear: () => {} }]}
activeCount={1}
/>
);
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
});
it('calls pill onClear when X button is clicked', () => {
const onClear = vi.fn();
render(
<FilterBar
pills={[{ label: 'status', value: 'pending', onClear }]}
activeCount={1}
/>
);
const removeButton = screen.getByLabelText('Remove status filter');
fireEvent.click(removeButton);
expect(onClear).toHaveBeenCalledTimes(1);
});
it('calls onClearAll when clear all is clicked', () => {
const onClearAll = vi.fn();
render(
<FilterBar
pills={[{ label: 'status', value: 'pending', onClear: () => {} }]}
onClearAll={onClearAll}
activeCount={1}
/>
);
fireEvent.click(screen.getByText('clear all'));
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it('renders actions slot when provided', () => {
render(
<FilterBar
actions={<button>Bulk Action</button>}
/>
);
expect(screen.getByText('Bulk Action')).toBeInTheDocument();
});
it('does not render pill row when empty and inactive', () => {
const { container } = render(
<FilterBar pills={[]} activeCount={0} />
);
// The whole pill section shouldn't render
const pills = container.querySelector('.flex-wrap');
// If pills/activeCount are both empty, the conditional is false
expect(container.querySelector('.flex-wrap.items-center.gap-1\\.5')).toBeNull();
});
it('calls dropdown onChange when a selection is made', () => {
const onChange = vi.fn();
render(
<FilterBar
filters={[
{ label: 'Status', value: '', onChange, options: [{ value: 'online', label: 'Online' }], placeholder: 'All Status' },
]}
/>
);
const select = screen.getByDisplayValue('All Status');
fireEvent.change(select, { target: { value: 'online' } });
expect(onChange).toHaveBeenCalledWith('online');
});
it('renders dropdown with active styling when value is set', () => {
const onChange = vi.fn();
render(
<FilterBar
filters={[
{ label: 'Status', value: 'online', onChange, options: [{ value: 'online', label: 'Online' }] },
]}
/>
);
const select = screen.getByDisplayValue('Online');
expect(select).toBeInTheDocument();
});
});

View file

@ -0,0 +1,126 @@
import React from 'react';
import { SearchInput, FilterDropdown, FilterPill } from '@/components/primitives';
/**
* Composable filter bar search input + dropdown filters + active pill row.
*
* No state management, no URL sync. Just layout for the existing primitives.
* Compose with useFilterUrl at the page level.
*
* Usage:
* const filter = useFilterUrl({ status: { urlParam: 'status' } });
* const [searchQuery, setSearchQuery] = useState('');
* const debounced = useDebounce(searchQuery, 300);
*
* <FilterBar
* search={{ value: searchQuery, onChange: setSearchQuery, placeholder: '...' }}
* filters={[
* { label: 'Status', value: filter.values.status, onChange: v => filter.setFilter('status', v),
* options: statuses, placeholder: 'All Status' },
* ]}
* pills={filterPills(filter.values, filter.clearFilter)}
* onClearAll={filter.clearAll}
* activeCount={filter.activeCount}
* actions={<button ...>Bulk Action</button>}
* />
*/
interface FilterBarFilter {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
interface FilterBarPill {
label: string;
value: string;
onClear: () => void;
}
export interface FilterBarProps {
/** Optional search input — shown as the first element in the top row */
search?: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
};
/** Dropdown filters — shown inline after the search input */
filters?: FilterBarFilter[];
/** Active filter pills — shown below the top row */
pills?: FilterBarPill[];
/** Called when "clear all" is clicked. Only shown when activeCount > 0. */
onClearAll?: () => void;
/** Number of active filters (controls "clear all" visibility) */
activeCount?: number;
/** Extra elements shown at the right end of the top row (bulk actions, etc.) */
actions?: React.ReactNode;
className?: string;
}
const FilterBar: React.FC<FilterBarProps> = ({
search,
filters = [],
pills = [],
onClearAll,
activeCount = 0,
actions,
className,
}) => {
return (
<div className={className}>
{/* Top row: search + filters + actions */}
<div className="flex flex-col sm:flex-row gap-4">
{search && (
<SearchInput
value={search.value}
onChange={search.onChange}
placeholder={search.placeholder ?? 'Search...'}
className={search.className ?? 'flex-1'}
/>
)}
{filters.map((f, i) => (
<FilterDropdown
key={i}
label={f.label}
value={f.value}
onChange={f.onChange}
options={f.options}
placeholder={f.placeholder}
/>
))}
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
{/* Pill row */}
{(pills.length > 0 || activeCount > 0) && (
<div className="flex flex-wrap items-center gap-1.5 mt-3">
{pills.map((p, i) => (
<FilterPill
key={i}
label={p.label}
value={p.value}
onClear={p.onClear}
/>
))}
{onClearAll && activeCount > 0 && (
<button
onClick={onClearAll}
className="text-xs text-gray-400 hover:text-gray-700 transition-colors ml-1 underline"
>
clear all
</button>
)}
</div>
)}
</div>
);
};
export default FilterBar;

View file

@ -1,3 +1,5 @@
export { default as FilterBar } from './FilterBar';
export type { FilterBarProps } from './FilterBar';
export { default as SearchInput } from './SearchInput';
export { default as FilterPill } from './FilterPill';
export { default as FilterDropdown } from './FilterDropdown';

View file

@ -0,0 +1,218 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { describe, it, expect } from 'vitest';
import { useFilterUrl, type FilterConfig } from './useFilterUrl';
/**
* Helper component that exercises useFilterUrl and exposes its state
* in data-testid attributes so tests can inspect it.
*/
function TestComponent({ config }: { config: FilterConfig }) {
const filter = useFilterUrl(config);
return (
<div>
<div data-testid="activeCount">{filter.activeCount}</div>
<div data-testid="values">{JSON.stringify(filter.values)}</div>
{Object.keys(filter.values).map((key) => (
<div key={key} data-testid={`value-${key}`}>
{(filter.values as Record<string, string>)[key]}
</div>
))}
<button data-testid="setFilter-status" onClick={() => filter.setFilter('status', 'pending')}>
Set Status
</button>
<button data-testid="setFilter-severity" onClick={() => filter.setFilter('severity', 'critical')}>
Set Severity
</button>
<button data-testid="clearFilter-status" onClick={() => filter.clearFilter('status')}>
Clear Status
</button>
<button data-testid="clearAll" onClick={() => filter.clearAll()}>
Clear All
</button>
</div>
);
}
/**
* Helper that also exposes the current URL search string.
*/
function TestComponentWithLocation({ config }: { config: FilterConfig }) {
const filter = useFilterUrl(config);
const location = useLocation();
return (
<div>
<div data-testid="search">{location.search}</div>
<div data-testid="values">{JSON.stringify(filter.values)}</div>
<button data-testid="setFilter-status" onClick={() => filter.setFilter('status', 'pending')}>
Set Status
</button>
<button data-testid="clearAll" onClick={() => filter.clearAll()}>
Clear All
</button>
</div>
);
}
function renderWithRouter(ui: React.ReactElement, initialEntries = ['/']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
);
}
const config: FilterConfig = {
status: { urlParam: 'status' },
severity: { urlParam: 'severity', default: 'low' },
};
describe('useFilterUrl', () => {
it('reads initial state from URL params on mount', () => {
renderWithRouter(
<TestComponent config={config} />,
['/?status=pending&severity=critical']
);
expect(screen.getByTestId('value-status')).toHaveTextContent('pending');
expect(screen.getByTestId('value-severity')).toHaveTextContent('critical');
});
it('uses defaults when URL params are missing', () => {
renderWithRouter(
<TestComponent config={config} />,
['/']
);
expect(screen.getByTestId('value-status')).toHaveTextContent('');
// severity defaults to 'low'
expect(screen.getByTestId('value-severity')).toHaveTextContent('low');
});
it('returns zero activeCount when all values are defaults', () => {
renderWithRouter(
<TestComponent config={config} />,
['/']
);
expect(screen.getByTestId('activeCount')).toHaveTextContent('0');
});
it('returns correct activeCount when filters differ from defaults', () => {
renderWithRouter(
<TestComponent config={config} />,
['/?status=approved']
);
// status is non-default -> count 1, severity is default -> not counted
expect(screen.getByTestId('activeCount')).toHaveTextContent('1');
});
it('setFilter changes a value', () => {
renderWithRouter(<TestComponent config={config} />, ['/']);
fireEvent.click(screen.getByTestId('setFilter-status'));
expect(screen.getByTestId('value-status')).toHaveTextContent('pending');
});
it('setFilter increments activeCount', () => {
renderWithRouter(<TestComponent config={config} />, ['/']);
fireEvent.click(screen.getByTestId('setFilter-status'));
expect(screen.getByTestId('activeCount')).toHaveTextContent('1');
});
it('setFilter increments activeCount for multiple filters', () => {
renderWithRouter(<TestComponent config={config} />, ['/']);
fireEvent.click(screen.getByTestId('setFilter-status'));
fireEvent.click(screen.getByTestId('setFilter-severity'));
expect(screen.getByTestId('activeCount')).toHaveTextContent('2');
});
it('clearFilter resets a single filter to default', () => {
renderWithRouter(<TestComponent config={config} />, ['/?status=approved']);
fireEvent.click(screen.getByTestId('clearFilter-status'));
expect(screen.getByTestId('value-status')).toHaveTextContent('');
});
it('clearFilter decrements activeCount', () => {
renderWithRouter(<TestComponent config={config} />, ['/?status=approved&severity=critical']);
expect(screen.getByTestId('activeCount')).toHaveTextContent('2');
fireEvent.click(screen.getByTestId('clearFilter-status'));
expect(screen.getByTestId('activeCount')).toHaveTextContent('1');
});
it('clearAll resets all filters to defaults', () => {
renderWithRouter(
<TestComponent config={config} />,
['/?status=approved&severity=critical']
);
fireEvent.click(screen.getByTestId('clearAll'));
expect(screen.getByTestId('value-status')).toHaveTextContent('');
expect(screen.getByTestId('value-severity')).toHaveTextContent('low');
});
it('clearAll sets activeCount to 0', () => {
renderWithRouter(
<TestComponent config={config} />,
['/?status=approved&severity=critical']
);
fireEvent.click(screen.getByTestId('clearAll'));
expect(screen.getByTestId('activeCount')).toHaveTextContent('0');
});
it('clearAll does not affect defaults-only state', () => {
renderWithRouter(<TestComponent config={config} />, ['/']);
fireEvent.click(screen.getByTestId('clearAll'));
expect(screen.getByTestId('value-status')).toHaveTextContent('');
expect(screen.getByTestId('value-severity')).toHaveTextContent('low');
expect(screen.getByTestId('activeCount')).toHaveTextContent('0');
});
it('setFilter writes the value to the URL', () => {
renderWithRouter(<TestComponentWithLocation config={config} />, ['/']);
fireEvent.click(screen.getByTestId('setFilter-status'));
expect(screen.getByTestId('search').textContent).toContain('status=pending');
});
it('clearAll removes filter params from the URL', () => {
renderWithRouter(<TestComponentWithLocation config={config} />, ['/?status=pending&severity=critical']);
fireEvent.click(screen.getByTestId('clearAll'));
// Both non-default values cleared — URL should have no filter params
const search = screen.getByTestId('search').textContent || '';
expect(search).not.toContain('status=');
expect(search).not.toContain('severity=');
});
});
import { buildFilterPills } from './useFilterUrl';
import type { FilterUrlState } from './useFilterUrl';
describe('buildFilterPills', () => {
it('returns pills for non-default values', () => {
const filter = {
values: { status: 'pending', severity: '' },
clearFilter: () => {},
} as unknown as FilterUrlState<typeof config>;
const pills = buildFilterPills(filter, config);
expect(pills).toHaveLength(1);
expect(pills[0].label).toBe('status');
expect(pills[0].value).toBe('pending');
});
it('uses config key as label fallback when label is not set', () => {
const noLabelConfig = {
foo: { urlParam: 'foo' },
};
const filter = {
values: { foo: 'bar' },
clearFilter: () => {},
} as unknown as FilterUrlState<typeof noLabelConfig>;
const pills = buildFilterPills(filter, noLabelConfig);
expect(pills[0].label).toBe('foo');
});
it('skips default values', () => {
const filter = {
values: { status: '', severity: 'low' },
clearFilter: () => {},
} as unknown as FilterUrlState<typeof config>;
const pills = buildFilterPills(filter, config);
expect(pills).toHaveLength(0);
});
});

View file

@ -0,0 +1,128 @@
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
vi.mock('../hooks/useAgents', () => ({
useAgents: vi.fn(() => ({
data: { agents: [
{ id: '1', hostname: 'waystation', os_type: 'linux', status: 'online', version: '1.0', ip_address: '10.0.0.1', last_seen: new Date().toISOString(), metadata: {}, capabilities: [] },
{ id: '2', hostname: 'webserver', os_type: 'linux', status: 'online', version: '1.0', ip_address: '10.0.0.2', last_seen: new Date().toISOString(), metadata: {}, capabilities: [] },
] },
isPending: false,
error: null,
refetch: vi.fn(),
})),
useAgent: vi.fn(() => ({ data: null })),
useScanMultipleAgents: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useUnregisterAgent: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}));
vi.mock('../hooks/useCommands', () => ({
useActiveCommands: vi.fn(() => ({ data: { commands: [] }, refetch: vi.fn() })),
useCancelCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useCaptureScreenshot: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useCommand: vi.fn(() => ({ data: null })),
}));
vi.mock('../hooks/useHeartbeat', () => ({
useHeartbeatStatus: vi.fn(() => ({ enabled: false, active: false })),
}));
vi.mock('../hooks/useDebounce', () => ({
useDebounce: vi.fn((value) => value),
}));
import Agents from './Agents';
function renderPage(initialEntries = ['/agents']) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={initialEntries}>
<Agents />
</MemoryRouter>
</QueryClientProvider>
);
}
describe('Agents page filter integration', () => {
it('renders the page header', () => {
renderPage();
expect(screen.getByText('Agents')).toBeInTheDocument();
});
it('renders FilterBar with search input', () => {
renderPage();
expect(screen.getByPlaceholderText('Search agents by hostname...')).toBeInTheDocument();
});
it('renders Status filter dropdown', () => {
renderPage();
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
});
it('renders OS filter dropdown', () => {
renderPage();
expect(screen.getByDisplayValue('All OS')).toBeInTheDocument();
});
it('changing Status filter updates the URL and shows pills', () => {
renderPage();
const statusSelect = screen.getByDisplayValue('All Status');
fireEvent.change(statusSelect, { target: { value: 'online' } });
// After selecting a filter, pills should appear
expect(screen.getByText(/online/)).toBeInTheDocument();
// "clear all" should appear when filters are active
expect(screen.getByText('clear all')).toBeInTheDocument();
});
it('changing OS filter works', () => {
renderPage();
const osSelect = screen.getByDisplayValue('All OS');
fireEvent.change(osSelect, { target: { value: 'linux' } });
expect(screen.getAllByRole('option', { name: 'linux' }).length).toBeGreaterThan(0);
});
it('clear all resets filter pills', () => {
renderPage();
// Set a filter
const statusSelect = screen.getByDisplayValue('All Status');
fireEvent.change(statusSelect, { target: { value: 'online' } });
expect(screen.getByText(/online/)).toBeInTheDocument();
// Clear all
fireEvent.click(screen.getByText('clear all'));
expect(screen.queryByText(/online/)).not.toBeInTheDocument();
});
it('pill X button removes individual filter', () => {
renderPage();
// Set a filter
const statusSelect = screen.getByDisplayValue('All Status');
fireEvent.change(statusSelect, { target: { value: 'online' } });
expect(screen.getByText(/online/)).toBeInTheDocument();
// Remove the pill
const removeBtn = screen.getByLabelText('Remove Status filter');
fireEvent.click(removeBtn);
expect(screen.queryByText(/online/)).not.toBeInTheDocument();
});
it('renders search input that accepts text', () => {
renderPage();
const input = screen.getByPlaceholderText('Search agents by hostname...');
fireEvent.change(input, { target: { value: 'server' } });
expect(input).toHaveValue('server');
});
it('does not show pills on initial load', () => {
renderPage();
// No filter pills should be present initially (no "clear all" text)
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
});
});

View file

@ -21,9 +21,9 @@ import {
MonitorPlay,
Upload,
} from 'lucide-react';
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
import {
SearchInput,
FilterDropdown,
FilterBar,
PageState,
ScreenshotCard,
MetricItem,
@ -68,8 +68,11 @@ const Agents: React.FC = () => {
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || '');
const [osFilter, setOsFilter] = useState<string>('');
const filterConfig = {
status: { urlParam: 'status', label: 'Status' },
os: { urlParam: 'os', label: 'OS' },
};
const filter = useFilterUrl(filterConfig);
const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({
defaultSortBy: 'last_seen',
defaultOrder: 'asc',
@ -236,7 +239,7 @@ const Agents: React.FC = () => {
// Fetch agents list
const { data: agentsData, isPending, error, refetch: _refetch } = useAgents({
search: debouncedSearchQuery || undefined,
status: statusFilter || undefined,
status: filter.values.status || undefined,
});
// Fetch single agent if ID is provided
@ -263,8 +266,8 @@ const Agents: React.FC = () => {
// Filter agents based on OS
const filteredAgents = agents.filter(agent => {
if (!osFilter) return true;
return agent.os_type.toLowerCase().includes(osFilter.toLowerCase());
if (!filter.values.os) return true;
return agent.os_type.toLowerCase().includes(filter.values.os.toLowerCase());
});
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
@ -1106,35 +1109,20 @@ const Agents: React.FC = () => {
</p>
</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 agents by hostname..."
className="flex-1"
/>
<FilterDropdown
label="Status"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'online', label: 'Online' },
{ value: 'offline', label: 'Offline' },
]}
placeholder="All Status"
/>
<FilterDropdown
label="OS"
value={osFilter}
onChange={setOsFilter}
options={osTypes.map(os => ({ value: os, label: os }))}
placeholder="All OS"
/>
{/* Bulk actions */}
{selectedAgents.length > 0 && (
<FilterBar
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search agents by hostname...' }}
filters={[
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: [
{ value: 'online', label: 'Online' },
{ value: 'offline', label: 'Offline' },
], placeholder: 'All Status' },
{ label: 'OS', value: filter.values.os, onChange: (v) => filter.setFilter('os', v), options: osTypes.map(os => ({ value: os, label: os })), placeholder: 'All OS' },
]}
pills={buildFilterPills(filter, filterConfig)}
onClearAll={() => filter.clearAll()}
activeCount={filter.activeCount}
actions={
selectedAgents.length > 0 ? (
<>
<button
onClick={handleScanSelected}
@ -1155,9 +1143,10 @@ const Agents: React.FC = () => {
}}
/>
</>
)}
</div>
</div>
) : undefined
}
className="mb-6"
/>
{/* Agents table — PageState primitive */}
<PageState
@ -1166,7 +1155,7 @@ const Agents: React.FC = () => {
empty={sortedAgents.length === 0}
emptyTitle="No agents found"
emptyMessage={
debouncedSearchQuery || statusFilter || osFilter
debouncedSearchQuery || filter.values.status || filter.values.os
? 'Try adjusting your search or filters.'
: 'No agents have registered with the server yet.'
}
@ -1207,3 +1196,4 @@ const Agents: React.FC = () => {
};
export default Agents;

View file

@ -1,8 +1,10 @@
import React, { useState } from 'react';
import { History, Filter } from 'lucide-react';
import ChatTimeline from '@/components/ChatTimeline';
import { SearchInput } from '@/components/primitives';
import { SearchInput, FilterBar } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
const EVENT_TYPES = [
{ value: '', label: 'All types' },
@ -23,12 +25,13 @@ const SEVERITIES = [
const HistoryPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [eventType, setEventType] = useState('');
const [severity, setSeverity] = useState('');
const [showFilters, setShowFilters] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 300);
const activeFilterCount = [eventType, severity].filter(Boolean).length;
const filterConfig = {
type: { urlParam: 'type', label: 'Type' },
severity: { urlParam: 'severity', label: 'Severity' },
};
const filter = useFilterUrl(filterConfig);
return (
<div className="mb-6">
@ -41,16 +44,16 @@ const HistoryPage: React.FC = () => {
<button
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-md transition-colors ${
showFilters || activeFilterCount > 0
showFilters || filter.activeCount > 0
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<Filter className="h-3.5 w-3.5" />
Filters
{activeFilterCount > 0 && (
{filter.activeCount > 0 && (
<span className="ml-1 inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium bg-indigo-600 text-white rounded-full">
{activeFilterCount}
{filter.activeCount}
</span>
)}
</button>
@ -66,53 +69,25 @@ const HistoryPage: React.FC = () => {
Command history, package events, system activity, and client errors across all agents
</p>
{/* Filter bar — Novell-style dense row */}
{/* Filter bar — toggled via the Filters button */}
{showFilters && (
<div className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-md">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Type</label>
<select
value={eventType}
onChange={(e) => setEventType(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
>
{EVENT_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Severity</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
>
{SEVERITIES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{activeFilterCount > 0 && (
<button
onClick={() => { setEventType(''); setSeverity(''); }}
className="text-xs text-indigo-600 hover:text-indigo-800 underline"
>
Clear filters
</button>
)}
</div>
</div>
<FilterBar
filters={[
{ label: 'Type', value: filter.values.type, onChange: (v) => filter.setFilter('type', v), options: EVENT_TYPES, placeholder: 'All types' },
{ label: 'Severity', value: filter.values.severity, onChange: (v) => filter.setFilter('severity', v), options: SEVERITIES, placeholder: 'All severity' },
]}
pills={buildFilterPills(filter, filterConfig)}
onClearAll={() => filter.clearAll()}
activeCount={filter.activeCount}
className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-md"
/>
)}
{/* Timeline */}
<ChatTimeline
externalSearch={debouncedSearch}
externalType={eventType}
externalSeverity={severity}
externalType={filter.values.type}
externalSeverity={filter.values.severity}
/>
</div>
);

View file

@ -0,0 +1,100 @@
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
vi.mock('../hooks/useCommands', () => ({
useActiveCommands: vi.fn(() => ({
data: { commands: [] },
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
useRetryCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useCancelCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useClearFailedCommands: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}));
vi.mock('../hooks/useAgents', () => ({
useAgents: vi.fn(() => ({
data: { agents: [] },
isPending: false,
})),
}));
vi.mock('../hooks/useUpdates', () => ({
useUpdates: vi.fn(() => ({
data: { updates: [] },
isPending: false,
})),
}));
vi.mock('../lib/api', () => ({
logApi: {
getActiveOperations: vi.fn(() => Promise.resolve({ operations: [] })),
},
}));
import LiveOperations from './LiveOperations';
function renderPage(initialEntries = ['/live-operations']) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={initialEntries}>
<LiveOperations />
</MemoryRouter>
</QueryClientProvider>
);
}
describe('LiveOperations page filter integration', () => {
it('renders the page header', () => {
renderPage();
expect(screen.getByText('Staging')).toBeInTheDocument();
});
it('renders FilterBar with search input', () => {
renderPage();
expect(screen.getByPlaceholderText('Search by package name or agent...')).toBeInTheDocument();
});
it('renders Status filter dropdown', () => {
renderPage();
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
});
it('changing Status filter updates the dropdown', () => {
renderPage();
const statusSelect = screen.getByDisplayValue('All Status');
fireEvent.change(statusSelect, { target: { value: 'running' } });
expect(statusSelect).toHaveValue('running');
});
it('renders auto-refresh toggle', () => {
renderPage();
expect(screen.getByText('Auto Refresh')).toBeInTheDocument();
});
it('renders Refresh Now button', () => {
renderPage();
expect(screen.getByText('Refresh Now')).toBeInTheDocument();
});
it('shows empty state when no operations', () => {
renderPage();
expect(screen.getByText('No active operations')).toBeInTheDocument();
});
it('search input accepts text', () => {
renderPage();
const input = screen.getByPlaceholderText('Search by package name or agent...');
fireEvent.change(input, { target: { value: 'nginx' } });
expect(input).toHaveValue('nginx');
});
});

1
web/src/test/setup.ts Normal file
View file

@ -0,0 +1 @@
import '@testing-library/jest-dom';

18
web/vitest.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: false,
},
});