Merge pull request #1852 from rcourtman/maintainer/20260902T060320Z

Fix empty-slot alerts and make keyboard navigation accessible
This commit is contained in:
pulse-triage[bot]
2026-09-02 09:52:36 +01:00
committed by GitHub
21 changed files with 412 additions and 68 deletions
@@ -4689,6 +4689,11 @@ route-path wiring, and command selection, and
owns canonical command construction plus query normalization and filtering
policy. Future command-palette work should extend those owners instead of
pushing route construction or search policy back into the shared shell.
The palette search composes `SearchField` as an editable list-autocomplete
combobox: DOM focus remains on the search input while arrow-key selection is
exposed through `aria-activedescendant`, and the active listbox option remains
scrolled into view. Palette options stay outside the Tab sequence; filtering
to an empty result collapses the combobox and clears its active descendant.
The OpenCode reference for this interaction is
`packages/opencode/src/cli/cmd/run/footer.command.tsx` at `origin/dev`
`e82542b8023a8374f29c23b70ec019c8f256354e`, where `RunCommandMenuBody`
+23 -51
View File
@@ -1,70 +1,42 @@
{
"version": 1,
"base_sha": "fd36b3124c31bde2bea6b916652b9319827f890f",
"verified_at": "2026-09-02T06:55:42Z",
"base_sha": "23b3893ae88b86497f3f103c8972663508555766",
"verified_at": "2026-09-02T07:15:12Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/aiPatrolCost.ts",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
"frontend-modern/src/components/Settings/AISettings.tsx",
"frontend-modern/src/components/Settings/useAISettingsState.ts",
"frontend-modern/src/components/shared/AIModelPicker.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx",
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"frontend-modern/src/stores/aiIntelligence.ts",
"frontend-modern/src/types/ai.ts",
"frontend-modern/src/utils/aiPatrolCostPresentation.ts",
"frontend-modern/src/utils/patrolRuntimeActions.ts"
"frontend-modern/src/components/shared/CommandPaletteModal.tsx",
"frontend-modern/src/components/shared/SearchField.tsx",
"frontend-modern/src/components/shared/searchFieldModel.ts",
"frontend-modern/src/components/shared/useCommandPaletteState.ts"
],
"content_sha256": {
"frontend-modern/src/api/aiPatrolCost.ts": "be947370111a1efb7e2618f344b4b81481262f6b1fd8fc4ed2690a7729a05bff",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx": "33b63989306752e5b852e5d2438dfcb99c0df78530ba50c1e157b895d7f1a623",
"frontend-modern/src/components/Settings/AISettings.tsx": "7103686e133e08d50563bf66da63ea161f73ae0386c59d6c770da78f0bdb78bb",
"frontend-modern/src/components/Settings/useAISettingsState.ts": "841fa917cf7c5a1f7867b992813984bbd1ce6f57ed9f1abe8db2b7183a4596a5",
"frontend-modern/src/components/shared/AIModelPicker.tsx": "79abf84c58d6baec0351bea8cfb7d828db8ff00f5bae7ed0fff4079bbef25b91",
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx": "085c1d677ed5e15698e32759b4c01a05e0fd038fbf686ded48b390623de41d22",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "69ff486b95aafd445600571c977f8670e9686d9185f2c1285c2a6690e9c2d113",
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx": "cd6d888b620e44fa8ec2125b818e59c8ed802f8aeae0c554c4ce5bd1d40e314f",
"frontend-modern/src/features/patrol/patrolControlPresentation.ts": "58ad626d6af2472343307c01583621d4cfd742684f36c5583a3b72de75f86929",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "7578f9e010bc36a1d5a3ec932a69a23a077e176755e7207905ec47f5e94d5fae",
"frontend-modern/src/stores/aiIntelligence.ts": "8b81de1c09f47555b148415641f9cc42a9db25e0a54cd0bc64bb24d03bb1a0ac",
"frontend-modern/src/types/ai.ts": "12473e6f0e6339094689ac77f9a32aa1ffb62def1e81c780f4263cbcf5923474",
"frontend-modern/src/utils/aiPatrolCostPresentation.ts": "3d1ffa67e0df24af89ca26bb40e29fe55ef859902145aa958d4331483952a032",
"frontend-modern/src/utils/patrolRuntimeActions.ts": "a71f234c2007dafd6e8773542ae238b8cd3cdb9501aaf5f1a403060335fbabd9"
"frontend-modern/src/components/shared/CommandPaletteModal.tsx": "300030975b76634513d98b9459fe9ae4d07e8d7d03fedffa91f702746a7d3f06",
"frontend-modern/src/components/shared/SearchField.tsx": "0c2cc0d002763b59c2c065ceccff134318122aa975fe45ddc31271387cd623ad",
"frontend-modern/src/components/shared/searchFieldModel.ts": "3b9ca4ffc6aef0e5910e5094daebb9b03878b2b6ef4c9c7aed165bf7f46097db",
"frontend-modern/src/components/shared/useCommandPaletteState.ts": "df4baf2ccba420f0c8d7d67ee7ddeba5a55ddbee8af323d8d7924aa389ad9b3a"
},
"routes": [
"/settings/pulse-intelligence/patrol",
"/settings/pulse-intelligence/provider",
"/patrol"
],
"routes": ["/proxmox"],
"viewports": [
{
"width": 1280,
"height": 1000
"height": 720
},
{
"width": 375,
"height": 812
"width": 393,
"height": 851
}
],
"states": [
"Patrol model picker open with the Suggested for Patrol section and Recommended for Patrol badge (Ollama qwen3:8b)",
"estimate box for a local model (no per-token bill) with the 30-day budget line",
"estimate box for a per-token model (Anthropic Sonnet 5) with monthly figure, per-run assumption, token explainer, and schedule recommendation",
"schedule select priced per preset and current-schedule card after the auto-adjust to once a day",
"estimate box with the budget reached line after seeding an over-budget usage history",
"Patrol page paused banner with spend, limit, and Raise the cost budget action",
"Patrol page setup card after a backend restart: paused subtitle and Raise the cost budget action from the persisted finding cause",
"narrow viewport: settings Patrol tab, picker, and Patrol page with no horizontal overflow"
"Command palette open with twelve results and the first result selected at desktop and narrow widths",
"Command palette scrolled to the last keyboard-selected result at desktop and narrow widths",
"Command palette empty result state after a query with no matching commands at desktop and narrow widths",
"Command palette closed after Escape and after backdrop dismissal at desktop and narrow widths"
],
"interactions": [
"configured Ollama and a 20 USD budget on Provider & Models and saved",
"opened the Patrol model picker, searched a custom route, selected anthropic:claude-sonnet-5 and observed the schedule change to once a day",
"triggered a Patrol run against an over-budget usage history and observed the blocked status and finding",
"restarted the backend and reloaded the Patrol page",
"resized to 375x812 and repeated the settings and Patrol page checks"
"opened the command palette with Control+K on the authenticated Proxmox route",
"verified the search retained DOM focus while pointer hover and Home and End keys updated aria-activedescendant and aria-selected",
"verified End scrolled the last selected option fully into the result viewport and Tab did not focus an option",
"entered a no-match query and verified the combobox collapsed, cleared its active descendant, and removed the listbox",
"dismissed the palette with Escape and reopened and dismissed it through the backdrop"
]
}
@@ -46,6 +46,28 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
}
void props.handleDiscoveryModeChange(mode);
};
const handleScanScopeKeyDown = (
event: KeyboardEvent & { currentTarget: HTMLButtonElement },
currentMode: 'auto' | 'custom',
) => {
const direction =
event.key === 'ArrowRight' || event.key === 'ArrowDown'
? 1
: event.key === 'ArrowLeft' || event.key === 'ArrowUp'
? -1
: 0;
if (direction === 0) return;
event.preventDefault();
const modes = ['auto', 'custom'] as const;
const currentIndex = modes.indexOf(currentMode);
const targetMode = modes[(currentIndex + direction + modes.length) % modes.length];
const target = event.currentTarget
.closest('[role="radiogroup"]')
?.querySelector<HTMLButtonElement>(`[role="radio"][data-scan-scope="${targetMode}"]`);
selectDiscoveryMode(targetMode);
queueMicrotask(() => target?.focus());
};
return (
<div class="space-y-5">
@@ -111,8 +133,11 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
type="button"
role="radio"
aria-checked={props.discoveryMode() === 'auto'}
data-scan-scope="auto"
tabIndex={props.discoveryMode() === 'auto' ? 0 : -1}
disabled={scanScopeLocked()}
onClick={() => selectDiscoveryMode('auto')}
onKeyDown={(event) => handleScanScopeKeyDown(event, 'auto')}
class={scanScopeOptionClass('auto')}
>
<span class={scanScopeIndicatorClass('auto')} aria-hidden="true">
@@ -132,8 +157,11 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
type="button"
role="radio"
aria-checked={props.discoveryMode() === 'custom'}
data-scan-scope="custom"
tabIndex={props.discoveryMode() === 'custom' ? 0 : -1}
disabled={scanScopeLocked()}
onClick={() => selectDiscoveryMode('custom')}
onKeyDown={(event) => handleScanScopeKeyDown(event, 'custom')}
class={scanScopeOptionClass('custom')}
>
<span class={scanScopeIndicatorClass('custom')} aria-hidden="true">
@@ -91,6 +91,31 @@ describe('DiscoverySettingsForm', () => {
expect(screen.getByRole('button', { name: '192.168.1.0/24' })).toBeInTheDocument();
});
it('uses one tab stop and arrow keys to move and select within the scan scope group', async () => {
const { discoveryMode, handleDiscoveryModeChange } = renderDiscoverySettingsForm();
const autoScope = screen.getByRole('radio', { name: /Automatic scan \(full network scope\)/i });
const customScope = screen.getByRole('radio', { name: /Custom subnets \(targeted\)/i });
expect(autoScope).toHaveAttribute('tabindex', '0');
expect(customScope).toHaveAttribute('tabindex', '-1');
autoScope.focus();
fireEvent.keyDown(autoScope, { key: 'ArrowDown' });
await waitFor(() => expect(discoveryMode()).toBe('custom'));
expect(customScope).toHaveFocus();
expect(handleDiscoveryModeChange).toHaveBeenLastCalledWith('custom');
expect(autoScope).toHaveAttribute('tabindex', '-1');
expect(customScope).toHaveAttribute('tabindex', '0');
fireEvent.keyDown(customScope, { key: 'ArrowRight' });
await waitFor(() => expect(discoveryMode()).toBe('auto'));
expect(autoScope).toHaveFocus();
expect(handleDiscoveryModeChange).toHaveBeenLastCalledWith('auto');
});
it('commits a common custom subnet chip from the scan scope control', async () => {
const { commitDiscoverySubnet, discoverySubnetDraft } = renderDiscoverySettingsForm({
initialMode: 'custom',
@@ -9,9 +9,17 @@ import {
export type { CommandPaletteModalProps } from './useCommandPaletteState';
const COMMAND_PALETTE_RESULTS_ID = 'command-palette-results';
const getCommandPaletteOptionId = (commandId: string) => `command-palette-option-${commandId}`;
export function CommandPaletteModal(props: CommandPaletteModalProps) {
const commandPalette = useCommandPaletteState(props);
const activeOptionId = () => {
const command = commandPalette.filteredCommands()[commandPalette.selectedIndex()];
return command ? getCommandPaletteOptionId(command.id) : undefined;
};
const handleSelect = (command: CommandPaletteModalCommand) => {
commandPalette.handleSelect(command);
};
@@ -34,6 +42,11 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
inputClass="bg-base"
clearOnFocusedEscape={false}
shortcutHint="Cmd+K"
role="combobox"
ariaAutocomplete="list"
ariaControls={COMMAND_PALETTE_RESULTS_ID}
ariaExpanded={commandPalette.filteredCommands().length > 0}
ariaActiveDescendant={activeOptionId()}
/>
</div>
@@ -42,15 +55,22 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
when={commandPalette.filteredCommands().length > 0}
fallback={<div class="px-3 py-8 text-center text-sm text-muted">No matches found.</div>}
>
<div role="listbox" aria-label="Command palette results">
<div
ref={commandPalette.setListboxRef}
id={COMMAND_PALETTE_RESULTS_ID}
role="listbox"
aria-label="Command palette results"
>
<For each={commandPalette.filteredCommands()}>
{(command, index) => {
const selected = () => commandPalette.selectedIndex() === index();
return (
<button
type="button"
id={getCommandPaletteOptionId(command.id)}
role="option"
aria-selected={selected()}
tabIndex={-1}
class={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm text-base-content outline-none transition-colors hover:bg-surface-hover focus:bg-surface-hover ${
selected() ? 'bg-surface-hover' : ''
}`}
@@ -23,6 +23,9 @@ export const SearchField: Component<SearchFieldProps> = (props) => {
aria-label={props.title ?? props.placeholder ?? 'Search'}
role={props.role}
aria-autocomplete={props.ariaAutocomplete}
aria-controls={props.ariaControls}
aria-expanded={props.ariaExpanded}
aria-activedescendant={props.ariaActiveDescendant}
class={`min-h-11 w-full pl-8 sm:min-h-10 sm:pl-9 ${search.inputPaddingRight()} py-1.5 sm:py-2 text-sm border border-border rounded-md
bg-surface text-base-content placeholder-muted
focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all disabled:opacity-60 disabled:cursor-not-allowed ${props.inputClass ?? ''}`}
@@ -40,7 +40,7 @@ export const subtabsListClass =
export const subtabsRailClass = 'relative min-w-0 flex-1';
export const subtabsTrailingRowClass = 'flex flex-wrap items-center justify-between gap-3';
export const subtabButtonClass =
'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors sm:min-h-10 sm:py-2 sm:text-sm';
'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:min-h-10 sm:py-2 sm:text-sm';
export const subtabButtonActiveClass = 'border-blue-600 text-base-content';
export const subtabButtonInactiveClass = 'border-transparent text-muted hover:text-base-content';
export const Subtabs: Component<SubtabsProps> = (props) => {
@@ -107,6 +107,36 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
});
};
const focusTab = (currentTab: HTMLButtonElement, key: string) => {
const enabledTabs = Array.from(
currentTab
.closest('[role="tablist"]')
?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)') ?? [],
);
const currentIndex = enabledTabs.indexOf(currentTab);
if (currentIndex < 0 || enabledTabs.length < 2) return;
let targetIndex: number;
switch (key) {
case 'ArrowLeft':
targetIndex = (currentIndex - 1 + enabledTabs.length) % enabledTabs.length;
break;
case 'ArrowRight':
targetIndex = (currentIndex + 1) % enabledTabs.length;
break;
case 'Home':
targetIndex = 0;
break;
case 'End':
targetIndex = enabledTabs.length - 1;
break;
default:
return;
}
enabledTabs[targetIndex]?.focus();
};
const tablist = () => (
<div class={subtabsRailClass}>
<div
@@ -126,6 +156,17 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
tabIndex={selected() ? 0 : -1}
disabled={tab.disabled}
onClick={() => local.onChange(tab.value)}
onKeyDown={(event) => {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'Home' ||
event.key === 'End'
) {
event.preventDefault();
focusTab(event.currentTarget, event.key);
}
}}
class={`${subtabButtonClass} ${
selected() ? subtabButtonActiveClass : subtabButtonInactiveClass
} ${local.tabClass ?? ''}`.trim()}
@@ -157,17 +157,21 @@ describe('CommandPaletteModal', () => {
));
const input = screen.getByPlaceholderText('Type a command or search...');
expect(screen.getByRole('option', { name: /Ask about Proxmox/ })).toHaveAttribute(
'aria-selected',
'true',
);
const firstOption = screen.getByRole('option', { name: /Ask about Proxmox/ });
expect(input).toHaveAttribute('role', 'combobox');
expect(input).toHaveAttribute('aria-autocomplete', 'list');
expect(input).toHaveAttribute('aria-controls', 'command-palette-results');
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(firstOption).toHaveAttribute('aria-selected', 'true');
expect(firstOption).toHaveAttribute('tabindex', '-1');
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id);
await fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(screen.getByRole('option', { name: /Show Assistant commands/ })).toHaveAttribute(
'aria-selected',
'true',
);
const secondOption = screen.getByRole('option', { name: /Show Assistant commands/ });
expect(secondOption).toHaveAttribute('aria-selected', 'true');
expect(input).toHaveFocus();
expect(input).toHaveAttribute('aria-activedescendant', secondOption.id);
await fireEvent.keyDown(input, { key: 'Enter' });
@@ -178,6 +182,48 @@ describe('CommandPaletteModal', () => {
expect(openAssistant).not.toHaveBeenCalled();
});
it('keeps the active command visible while keyboard focus remains on the combobox', async () => {
render(() => (
<CommandPaletteModal
isOpen={true}
onClose={vi.fn()}
platformVisibility={platformVisibility}
/>
));
const input = screen.getByRole('combobox', { name: 'Type a command or search...' });
const secondOption = screen.getByRole('option', { name: /Show Assistant commands/ });
const scrollIntoView = vi.fn();
Object.defineProperty(secondOption, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
input.focus();
fireEvent.keyDown(input, { key: 'ArrowDown' });
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }));
expect(input).toHaveFocus();
});
it('clears active-option semantics when filtering produces no matches', async () => {
render(() => (
<CommandPaletteModal
isOpen={true}
onClose={vi.fn()}
platformVisibility={platformVisibility}
/>
));
const input = screen.getByRole('combobox', { name: 'Type a command or search...' });
fireEvent.input(input, { target: { value: 'definitely-no-command-matches-this' } });
await waitFor(() => expect(screen.getByText('No matches found.')).toBeInTheDocument());
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(input).not.toHaveAttribute('aria-activedescendant');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('keeps the selected command across command-list recomputes before Enter selection', async () => {
const onClose = vi.fn();
const openAssistant = vi.spyOn(aiChatStore, 'open').mockImplementation(() => {});
@@ -42,6 +42,27 @@ describe('SearchField', () => {
expect(screen.getByPlaceholderText('Search field')).toHaveClass('sm:min-h-10');
});
it('forwards combobox ownership and active-option semantics', () => {
render(() => (
<SearchField
value=""
onChange={vi.fn()}
placeholder="Command search"
role="combobox"
ariaAutocomplete="list"
ariaControls="command-results"
ariaExpanded={true}
ariaActiveDescendant="command-result-active"
/>
));
const input = screen.getByRole('combobox', { name: 'Command search' });
expect(input).toHaveAttribute('aria-autocomplete', 'list');
expect(input).toHaveAttribute('aria-controls', 'command-results');
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input).toHaveAttribute('aria-activedescendant', 'command-result-active');
});
it('reserves mobile input space for every visible trailing action', () => {
expect(
getSearchFieldInputPaddingRightClass({
@@ -77,6 +77,70 @@ describe('Subtabs', () => {
}
});
it('moves focus across enabled tabs with standard tab-list keys without changing selection', () => {
const onChange = vi.fn();
render(() => (
<Subtabs
value="overview"
onChange={onChange}
ariaLabel="Resource detail sections"
tabs={[
{ value: 'overview', label: 'Overview' },
{ value: 'performance', label: 'Performance', disabled: true },
{ value: 'history', label: 'History' },
{ value: 'manage', label: 'Manage' },
]}
/>
));
const overview = screen.getByRole('tab', { name: 'Overview' });
const history = screen.getByRole('tab', { name: 'History' });
const manage = screen.getByRole('tab', { name: 'Manage' });
overview.focus();
fireEvent.keyDown(overview, { key: 'ArrowRight' });
expect(history).toHaveFocus();
fireEvent.keyDown(history, { key: 'End' });
expect(manage).toHaveFocus();
fireEvent.keyDown(manage, { key: 'ArrowRight' });
expect(overview).toHaveFocus();
fireEvent.keyDown(overview, { key: 'ArrowLeft' });
expect(manage).toHaveFocus();
fireEvent.keyDown(manage, { key: 'Home' });
expect(overview).toHaveFocus();
expect(overview).toHaveAttribute('aria-selected', 'true');
expect(onChange).not.toHaveBeenCalled();
});
it('keeps keyboard-focused tabs available for manual activation', () => {
const onChange = vi.fn();
render(() => (
<Subtabs
value="overview"
onChange={onChange}
ariaLabel="Resource detail sections"
tabs={[
{ value: 'overview', label: 'Overview' },
{ value: 'history', label: 'History' },
]}
/>
));
const overview = screen.getByRole('tab', { name: 'Overview' });
const history = screen.getByRole('tab', { name: 'History' });
overview.focus();
fireEvent.keyDown(overview, { key: 'ArrowRight' });
fireEvent.click(history);
expect(history).toHaveFocus();
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith('history');
});
it('shows phone scroll affordances when the tab rail is clipped', async () => {
render(() => (
<Subtabs
@@ -38,6 +38,9 @@ export interface SearchFieldProps {
completionSuffix?: string;
role?: JSX.InputHTMLAttributes<HTMLInputElement>['role'];
ariaAutocomplete?: JSX.AriaAttributes['aria-autocomplete'];
ariaControls?: JSX.AriaAttributes['aria-controls'];
ariaExpanded?: JSX.AriaAttributes['aria-expanded'];
ariaActiveDescendant?: JSX.AriaAttributes['aria-activedescendant'];
}
export const shouldShowSearchFieldShortcutHint = (value: string, shortcutHint?: string) =>
@@ -28,6 +28,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
const location = useLocation();
const [query, setQuery] = createSignal('');
const [inputRef, setInputRef] = createSignal<HTMLInputElement>();
const [listboxRef, setListboxRef] = createSignal<HTMLDivElement>();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const assistantPageContext = createMemo(() => getAssistantPageContext(location.pathname));
@@ -128,6 +129,16 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
setSelectedIndex((index) => (total > 0 ? Math.min(index, total - 1) : 0));
});
createEffect(() => {
selectedIndex();
filteredCommands();
queueMicrotask(() => {
listboxRef()
?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
?.scrollIntoView?.({ block: 'nearest' });
});
});
createEffect(() => {
if (props.isOpen) {
setQuery('');
@@ -148,6 +159,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
selectedIndex,
setSelectedIndex,
setInputRef,
setListboxRef,
setQuery,
};
}
+2 -2
View File
@@ -522,10 +522,10 @@ func isUnraidEmptySlot(disk agentshost.UnraidDisk) bool {
// Unraid names every configured slot (for example disk6 or parity2), even
// when it has never been assigned. A slot label is therefore topology, not
// membership evidence. Preserve DISK_NP members only when native identity,
// device, filesystem, or size evidence shows that a disk was assigned.
// device, a concrete filesystem, or size evidence shows that a disk was assigned.
return strings.TrimSpace(disk.Device) == "" &&
!unraidstatus.HasMeaningfulIdentity(disk.Model, disk.Serial) &&
strings.TrimSpace(disk.Filesystem) == "" &&
!unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) &&
disk.SizeBytes == 0
}
+4
View File
@@ -154,6 +154,7 @@ diskNumber.5=5
diskName.5=disk5
diskSize.5=0
diskId.5=ata-_
diskFsType.5=auto
rdevStatus.5=DISK_NP
rdevName.5=
rdevId.5=ata-_
@@ -161,6 +162,7 @@ diskNumber.29=29
diskName.29=parity2
diskSize.29=0
diskId.29=ata-_
diskFsType.29=auto
rdevStatus.29=DISK_NP_DSBL
rdevName.29=
rdevId.29=ata-_
@@ -286,6 +288,7 @@ id="ata-_"
size="0"
status="DISK_NP"
type="Data"
fsType="auto"
["parity2"]
idx="29"
name="parity2"
@@ -294,6 +297,7 @@ id="ata-_"
size="0"
status="DISK_NP_DSBL"
type="Parity"
fsType="auto"
`
disks := parseUnraidDisksINI(input)
+1 -1
View File
@@ -4147,7 +4147,7 @@ func isLegacyUnraidEmptySlot(disk agentshost.UnraidDisk, normalizedStatus string
}
return strings.TrimSpace(disk.Device) == "" &&
!unraidstatus.HasMeaningfulIdentity(disk.Model, disk.Serial) &&
strings.TrimSpace(disk.Filesystem) == "" &&
!unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) &&
disk.SizeBytes == 0
}
@@ -2588,8 +2588,8 @@ func TestApplyHostReportFiltersLegacyUnraidEmptySlots(t *testing.T) {
Disks: []agentshost.UnraidDisk{
{Name: "parity", Device: "/dev/sdb", Role: "parity", RawStatus: "DISK_OK", SizeBytes: 5860522532},
{Name: "disk1", Device: "/dev/sde", Role: "data", RawStatus: "DISK_OK", SizeBytes: 5860522532},
{Name: "disk6", Role: "data", RawStatus: "DISK_NP", Model: "ata -", Serial: "ata-_", Slot: 6},
{Name: "parity2", Role: "parity", RawStatus: "DISK_NP_DSBL", Model: "ata -", Serial: "ata-_", Slot: 29},
{Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Model: "ata -", Serial: "ata-_", Filesystem: "auto", Slot: 6},
{Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Model: "ata -", Serial: "ata-_", Filesystem: "auto", Slot: 29},
},
},
Timestamp: time.Now().UTC(),
+5 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
unraidstatus "github.com/rcourtman/pulse-go-rewrite/internal/unraid"
)
func AssessHostRAIDArray(array models.HostRAIDArray) Assessment {
@@ -346,13 +347,16 @@ func unraidDiskStateCounts(storage models.HostUnraidStorage) (disabled, invalid,
func isUnraidEmptySlot(disk models.HostUnraidDisk) bool {
rawStatus := strings.ToUpper(strings.TrimSpace(disk.RawStatus))
status := strings.ToLower(strings.TrimSpace(disk.Status))
if unraidstatus.IsExplicitMissingMember(rawStatus) {
return false
}
if !strings.Contains(rawStatus, "DISK_NP") && status != "missing" {
return false
}
return strings.TrimSpace(disk.Device) == "" &&
strings.TrimSpace(disk.Model) == "" &&
strings.TrimSpace(disk.Serial) == "" &&
strings.TrimSpace(disk.Filesystem) == "" &&
!unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) &&
disk.SizeBytes == 0
}
+23 -1
View File
@@ -237,7 +237,8 @@ func TestAssessUnraidStorageTreatsEmptyNoPresentSlotsAsUnprotected(t *testing.T)
{Name: "parity", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL"},
{Name: "md1p1", Device: "/dev/sde", Status: "online", RawStatus: "DISK_OK", SizeBytes: 5860522532},
{Name: "disk5", Role: "data", Status: "missing", RawStatus: "DISK_NP", Slot: 5},
{Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Slot: 29},
{Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Filesystem: "auto", Slot: 6},
{Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Filesystem: "auto", Slot: 29},
},
})
@@ -281,6 +282,27 @@ func TestAssessUnraidStorageUsesDiskStatusesOverAggregateCounters(t *testing.T)
}
}
func TestAssessUnraidStoragePreservesExplicitMissingMemberWithoutIdentity(t *testing.T) {
assessment := AssessUnraidStorage(models.HostUnraidStorage{
ArrayStarted: true,
Disks: []models.HostUnraidDisk{
{Name: "parity", Role: "parity", Status: "online"},
{Name: "disk1", Role: "data", Status: "online"},
{Name: "disk2", Role: "data", Status: "missing", RawStatus: "DISK_NP_MISSING", Filesystem: "auto"},
},
})
if assessment.Level != RiskCritical {
t.Fatalf("Level = %q, want %q", assessment.Level, RiskCritical)
}
for _, reason := range assessment.Reasons {
if reason.Code == "unraid_missing_disks" {
return
}
}
t.Fatalf("explicit missing member without identity was not preserved: %+v", assessment.Reasons)
}
func TestAssessUnraidStoragePreservesGenuineStructuredMissingDisk(t *testing.T) {
assessment := AssessUnraidStorage(models.HostUnraidStorage{
ArrayStarted: true,
+9
View File
@@ -31,6 +31,15 @@ func HasMeaningfulIdentity(model, serial string) bool {
return NormalizeNativeIdentity(model) != "" || NormalizeNativeIdentity(serial) != ""
}
// HasMeaningfulFilesystem reports whether a native filesystem field is
// evidence that a slot has a disk assigned. Unraid emits "auto" for configured
// but empty DISK_NP slots, so presence of that value alone cannot establish
// membership.
func HasMeaningfulFilesystem(filesystem string) bool {
filesystem = strings.TrimSpace(filesystem)
return filesystem != "" && !strings.EqualFold(filesystem, "auto")
}
// IsExplicitMissingMember reports Unraid's provider-owned status for a slot
// that was assigned but whose device is no longer present. Plain DISK_NP means
// no device is assigned and must not be treated as equivalent.
+15
View File
@@ -35,3 +35,18 @@ func TestIsExplicitMissingMember(t *testing.T) {
}
}
}
func TestHasMeaningfulFilesystem(t *testing.T) {
t.Parallel()
for _, filesystem := range []string{"", "auto", " AUTO "} {
if HasMeaningfulFilesystem(filesystem) {
t.Errorf("%q must not establish Unraid disk assignment", filesystem)
}
}
for _, filesystem := range []string{"xfs", "btrfs", "luks:xfs"} {
if !HasMeaningfulFilesystem(filesystem) {
t.Errorf("%q must establish Unraid disk assignment", filesystem)
}
}
}
@@ -258,6 +258,56 @@ test.describe("Infrastructure onboarding", () => {
await expect(page).toHaveURL(/\/settings\/infrastructure(?:\?.*)?$/);
});
test("discovery scan scope uses radio-group keyboard navigation", async ({
page,
}) => {
await prepareOnboardingPage(page);
await page.route("**/api/system/settings", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
const response = await route.fetch();
const body = await response.json();
body.discoveryEnabled = true;
body.discoverySubnet = "auto";
await route.fulfill({ response, json: body });
});
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
const discoveryBand = page.getByRole("region", {
name: "Discover Proxmox systems",
});
await discoveryBand
.getByRole("button", { name: "Settings", exact: true })
.click();
const dialog = page.getByRole("dialog", { name: "Discovery settings" });
const automaticScope = dialog.getByRole("radio", {
name: /Automatic scan \(full network scope\)/i,
});
const customScope = dialog.getByRole("radio", {
name: /Custom subnets \(targeted\)/i,
});
await expect(automaticScope).toHaveAttribute("tabindex", "0");
await expect(customScope).toHaveAttribute("tabindex", "-1");
await automaticScope.focus();
await automaticScope.press("ArrowDown");
await expect(customScope).toBeFocused();
await expect(customScope).toHaveAttribute("aria-checked", "true");
await expect(customScope).toHaveAttribute("tabindex", "0");
await expect(automaticScope).toHaveAttribute("tabindex", "-1");
});
test("desktop picker add opens the matching modal", async ({
page,
}, testInfo) => {