Drive reporting selection limits from catalog

This commit is contained in:
rcourtman 2026-03-25 22:32:04 +00:00
parent 7728b352c0
commit cf80556a6d
4 changed files with 50 additions and 8 deletions

View file

@ -181,6 +181,11 @@ than hardcoding panel copy, routes, or range presets in the frontend. The
frontend models may validate and present the catalog, but the canonical panel
title, descriptions, endpoints, filename prefixes, range windows, and column
list belong to the API reporting contract.
The same reporting catalog ownership now also governs the operator resource-
selection cap for performance reports. `ReportingPanel.tsx` and
`ResourcePicker.tsx` may present or enforce that limit, but they must receive
it from the backend-owned `multiResourceMax` definition rather than hardcoding
the reporting cap in frontend-local constants.
The shared updates settings owner also defines the user-facing framing for
rc-tagged builds. `frontend-modern/src/components/Settings/updatesSettingsModel.ts`
and `frontend-modern/src/utils/updatesPresentation.ts` must present that

View file

@ -163,6 +163,7 @@ export function ReportingPanel() {
helpText="Select the resources to include in the report"
>
<ResourcePicker
maxSelection={performanceReport()?.multiResourceMax}
selected={selectedResources}
onSelectionChange={setSelectedResources}
/>

View file

@ -21,7 +21,7 @@ import { showWarning } from '@/utils/toast';
import { getResourceTypePresentation } from '@/utils/resourceTypePresentation';
import { getSimpleStatusIndicator } from '@/utils/status';
const MAX_SELECTION = 50;
const DEFAULT_MAX_SELECTION = 50;
export interface SelectedResource {
id: string;
@ -30,6 +30,7 @@ export interface SelectedResource {
}
interface ResourcePickerProps {
maxSelection?: number;
selected: Accessor<SelectedResource[]>;
onSelectionChange: (items: SelectedResource[]) => void;
}
@ -39,6 +40,7 @@ export function ResourcePicker(props: ResourcePickerProps) {
const [search, setSearch] = createSignal('');
const [typeFilter, setTypeFilter] = createSignal<TypeFilter>('all');
const [tagFilter, setTagFilter] = createSignal('');
const maxSelection = () => props.maxSelection ?? DEFAULT_MAX_SELECTION;
// Filter to reportable resource types across infrastructure, workloads, storage, and recovery.
const reportableResources = createMemo(() => {
@ -90,8 +92,8 @@ export function ResourcePicker(props: ResourcePickerProps) {
if (isSelected(resource.id)) {
props.onSelectionChange(current.filter((s) => s.id !== resource.id));
} else {
if (current.length >= MAX_SELECTION) {
showWarning(`Maximum ${MAX_SELECTION} resources can be selected`);
if (current.length >= maxSelection()) {
showWarning(`Maximum ${maxSelection()} resources can be selected`);
return;
}
props.onSelectionChange([
@ -117,11 +119,14 @@ export function ResourcePicker(props: ResourcePickerProps) {
}));
const newSelection = [...current, ...toAdd];
if (newSelection.length > MAX_SELECTION) {
if (newSelection.length > maxSelection()) {
showWarning(
`Maximum ${MAX_SELECTION} resources can be selected. Only ${MAX_SELECTION - current.length} more can be added.`,
`Maximum ${maxSelection()} resources can be selected. Only ${maxSelection() - current.length} more can be added.`,
);
props.onSelectionChange([...current, ...toAdd.slice(0, MAX_SELECTION - current.length)]);
props.onSelectionChange([
...current,
...toAdd.slice(0, maxSelection() - current.length),
]);
return;
}
props.onSelectionChange(newSelection);
@ -329,7 +334,7 @@ export function ResourcePicker(props: ResourcePickerProps) {
</div>
<span class="text-xs sm:text-sm text-slate-500">
{props.selected().length} selected
<Show when={props.selected().length >= MAX_SELECTION}>
<Show when={props.selected().length >= maxSelection()}>
<span class="text-amber-400 ml-1">(max)</span>
</Show>
</span>

View file

@ -39,6 +39,13 @@ const createSelectedResources = (count: number): SelectedResource[] =>
}));
const renderPicker = (initialSelection: SelectedResource[] = []) => {
return renderPickerWithOptions(initialSelection, {});
};
const renderPickerWithOptions = (
initialSelection: SelectedResource[] = [],
options: { maxSelection?: number } = {},
) => {
const onSelectionChange = vi.fn();
render(() => {
const [selected, setSelected] = createSignal<SelectedResource[]>(initialSelection);
@ -46,7 +53,13 @@ const renderPicker = (initialSelection: SelectedResource[] = []) => {
setSelected(items);
onSelectionChange(items);
};
return <ResourcePicker selected={selected} onSelectionChange={handleSelectionChange} />;
return (
<ResourcePicker
maxSelection={options.maxSelection}
selected={selected}
onSelectionChange={handleSelectionChange}
/>
);
});
return { onSelectionChange };
};
@ -304,6 +317,24 @@ describe('ResourcePicker', () => {
expect(onSelectionChange).not.toHaveBeenCalled();
});
it('obeys a caller-provided max selection limit', async () => {
mockResources = [
makeResource({ id: 'vm-new', type: 'vm', name: 'Overflow VM', displayName: 'Overflow VM' }),
];
const { onSelectionChange } = renderPickerWithOptions(createSelectedResources(2), {
maxSelection: 2,
});
const resourceButton = (await screen.findByText('Overflow VM')).closest('button');
expect(resourceButton).toBeTruthy();
fireEvent.click(resourceButton!);
expect(showWarningMock).toHaveBeenCalledWith('Maximum 2 resources can be selected');
expect(screen.getByText('2 selected')).toBeInTheDocument();
expect(onSelectionChange).not.toHaveBeenCalled();
});
it('supports select-all-visible and clear-all', async () => {
mockResources = [
makeResource({