Move three forms onto shared controls and draw the select arrow

The shared template audit listed Settings, Alerts and Discovery forms
still building their own controls. The Thresholds CPU averaging selects
and the Discovery availability proposal now compose FormSelect (the
proposal's name field takes the shared field classes beside them), and
an availability check's request body composes FormTextarea, which links
its help text and, with sm:min-h-24, finally gets the height its class
asked for on desktop.

FormSelect's default chrome set appearance-none and drew no arrow, so
every default select in Settings read as a text field. A
form-select-arrow utility draws the caret in the padding the class
already reserved, and hands the native arrow back in forced-colors mode.

Two registry entries were wrong: AlertEscalationSection has used
checkboxes instead of a select since its destinations became
per-escalation, and the inline detail row rule matched a test file;
test files are now excluded from it.
This commit is contained in:
rcourtman
2026-10-05 23:01:46 +01:00
parent 9794660138
commit f7e61446bd
16 changed files with 177 additions and 85 deletions
@@ -2928,7 +2928,8 @@ hours colour the dashboard and never alert. A group switched off as a whole
shows its Disabled badge and no defaults line. The CPU averaging line counts
every configured profile override, including ones only the API sets. CPU averaging is tuning, not the page's
answer. It is one collapsed disclosure stating the effective window and the
number of platform overrides, with the same per-profile selects inside. Docker
number of platform overrides, with the same per-profile selects inside, each
a compact shared `FormSelect` with its profile name as a visible label. Docker
and Machines list their resource groups before Docker-only rules and the
SMART card. Threshold values, inheritance, metric evaluation windows and
persistence are unchanged.
@@ -1822,6 +1822,10 @@ payload shape change when the portal presents compact client rows.
arbitrary remote content. Contract tests must pin redaction, unchanged-
secret preservation, bounded evaluation, and the reachable-but-incorrect
application case.
The settings client edits the request body in the shared `FormTextarea`,
whose size note (`Up to 8 KiB`) is the field's accessible description;
removing a stored body stays an explicit action beside it, and leaving the
field blank keeps the stored body as before.
Availability target protocol vocabulary is canonicalized at the API/config
boundary: clients may submit `ping` as a user-facing alias for the ICMP
check, but saved targets, API reads, probe status, connections rows, and
@@ -2464,6 +2464,12 @@ not a replacement status card, CTA band, or page-local nested card.
apply controlled `value` props after options are mounted so settings panels
such as Discovery show the persisted option instead of falling back
to the first option while the collapsed summary shows a different value.
Its default select chrome (`formSelect` / `formSelectCompact` in
`Form.ts`) sets `appearance-none` to match the text inputs, so it also
carries the `form-select-arrow` utility, which draws the dropdown caret in
the reserved right padding (and hands the native arrow back in forced-colors
mode); without it every default `FormSelect` read as a text field. Callers
that pass their own `selectBaseClass` keep their own arrow.
The Assistant runtime controls in
`frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx` — e.g. the
service context scan `Toggle` — are settings-shell chrome bound to the canonical
@@ -2907,7 +2907,9 @@ Discovery-led availability proposals present their evidence and canonical
attachment boundary as short, complete sentences in both the inline card and
machine-wide review. The colocated component test pins that operator-facing
copy so the shared copy-style audit cannot regress it back to semicolon-joined
instructions.
instructions. The card's editable check name, interval and observation
location use the shared form label and control chrome (`FormSelect` for the
two choices), so they match the availability settings form they hand off to.
### Provider workflows hydrate only their active route inventory
@@ -0,0 +1,48 @@
{
"version": 1,
"base_sha": "979466013876ce54a67494b0c4cda9b404ea45e8",
"verified_at": "2026-10-05T22:01:46Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx",
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx",
"frontend-modern/src/components/shared/Form.ts",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx": "bfa50b13f521b9a91b57495eec3a827a71a413a0eed0832be0d58444e8ca3ee7",
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx": "af7a1d322045c33747600466a580ae6148f752c8d847404a6f5b04fa49fd53e7",
"frontend-modern/src/components/shared/Form.ts": "e47d06ebbbba68ac0d19769acac83a0ca930b1e2d5a7258c7c1fcf43e82ce44a",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx": "8142c12c2e6930a505e1f063aa8a396bfa45c6c55a8ea7c7d1085198a9f3915f",
"frontend-modern/src/index.css": "b8ae1a40f0915ff034e51a16a94e0c2159ac726ef0afdfdd8fb321f5d5f7dc5e"
},
"routes": [
"/alerts/thresholds",
"/settings/monitoring/availability",
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 1100
},
{
"width": 390,
"height": 1100
}
],
"states": [
"Alerts > Thresholds > CPU averaging opened: all 10 per-platform selects render through FormSelect with their visible label tied by for/id, values intact (5-minute average, Inherit), 32px desktop / 44px phone, and a dropdown caret; main showed native selects",
"Settings > Availability checks > Add service/device check with an HTTP POST probe: Request body (optional) is a FormTextarea with its label tied and the 8 KiB help linked by aria-describedby, 96px tall on desktop and phone (main: 54px on desktop because formControl's sm:min-h-9 overrode min-h-24; no help link)",
"Existing default FormSelects in that dialog (Preset, Target type, Probe) now show a caret; on main they had appearance-none and no arrow, reading as text fields",
"Proxmox guest drawer Discovery tab with discovery_enabled and an availability proposal served by route interception: Check name input and the Interval / Observation location FormSelects share the shared form label and control chrome at matched heights, with carets",
"Dark mode at 1440: caret drawn in the muted text colour and readable; forced-colors emulation: select falls back to appearance auto with no background image",
"Shared template audit: only the three findings in BackupTransferDialogs.tsx and AlertDeadManDestinationSection.tsx remain (held by another session's open bg-base change)",
"Re-checked after rebasing onto 9794660138 (#2535, shared toggle): CPU averaging selects unchanged, labelled, with the caret"
],
"interactions": [
"Opened the CPU averaging disclosure, the availability check dialog (HTTP, POST) and a guest Discovery tab, on main (reverse-applied patch) and on the change",
"Emulated forced-colors: active"
]
}
@@ -1709,7 +1709,6 @@
{ "path": "src/components/shared/FilterToolbar.tsx" },
{ "path": "src/features/alerts/AlertAppriseDestinationsSection.tsx" },
{ "path": "src/features/alerts/AlertDeliveryRoutingSection.tsx" },
{ "path": "src/features/alerts/AlertEscalationSection.tsx" },
{ "path": "src/features/alerts/AlertQuietHoursSection.tsx" },
{ "path": "src/features/patrol/PatrolAttentionWorkbench.tsx" }
],
@@ -6664,12 +6663,11 @@
"src/features/vmware"
],
"extensions": [".tsx"],
"pathExcludes": [".test.", "__tests__"],
"triggerPatterns": ["data-inline", "TableRow"],
"requiredPatterns": ["InlineDetailTableRow"],
"allowedPaths": [],
"ignoredPaths": [
"src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx"
],
"ignoredPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
@@ -304,6 +304,18 @@ describe('App architecture', () => {
);
});
it('draws the shared select arrow that appearance-none removes', () => {
// FormSelect's default chrome sets appearance-none to match text inputs;
// without this utility every default select read as a text field.
expect(appStylesSource).toMatch(
/@utility form-select-arrow \{\s*background-image:\s*linear-gradient\(45deg, transparent 50%, var\(--color-text-muted\) 50%\)/,
);
// Forced-colors mode drops background images, so the native arrow returns.
expect(appStylesSource).toMatch(
/@media \(forced-colors: active\) \{\s*appearance: auto !important;\s*background-image: none;/,
);
});
it('keeps compact phone settings spacing scoped to the settings content shell', () => {
expect(appStylesSource).toContain('@media (max-width: 39.999rem)');
expect(appStylesSource).toContain(
@@ -22,6 +22,8 @@ import {
} from '@/api/discovery';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { formControl, formField, formLabel } from '@/components/shared/Form';
import { FormSelect } from '@/components/shared/FormSelect';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import type { DiscoverySummary, ResourceDiscovery, ResourceType } from '@/types/discovery';
import {
@@ -298,47 +300,35 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
</p>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<label class="block sm:col-span-1">
<span class="text-[11px] font-medium text-base-content">
Check name · you control
</span>
<label class={formField}>
<span class={formLabel}>Check name · you control</span>
<input
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
class={formControl}
value={name()}
onInput={(event) => setName(event.currentTarget.value)}
/>
</label>
<label class="block">
<span class="text-[11px] font-medium text-base-content">
Interval · you control
</span>
<select
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
value={intervalSeconds()}
onChange={(event) => setIntervalSeconds(Number(event.currentTarget.value))}
>
<option value={30}>Every 30 seconds</option>
<option value={60}>Every minute</option>
<option value={300}>Every 5 minutes</option>
</select>
</label>
<label class="block">
<span class="text-[11px] font-medium text-base-content">
Observation location · you control
</span>
<select
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
value={probeAgentId()}
onChange={(event) => setProbeAgentId(event.currentTarget.value)}
>
<option value="">This Pulse server</option>
<For each={props.connectedAgents ?? []}>
{(agent) => (
<option value={agent.agent_id}>{agent.hostname || agent.agent_id}</option>
)}
</For>
</select>
</label>
<FormSelect
label="Interval · you control"
value={intervalSeconds()}
onChange={(event) => setIntervalSeconds(Number(event.currentTarget.value))}
>
<option value={30}>Every 30 seconds</option>
<option value={60}>Every minute</option>
<option value={300}>Every 5 minutes</option>
</FormSelect>
<FormSelect
label="Observation location · you control"
value={probeAgentId()}
onChange={(event) => setProbeAgentId(event.currentTarget.value)}
>
<option value="">This Pulse server</option>
<For each={props.connectedAgents ?? []}>
{(agent) => (
<option value={agent.agent_id}>{agent.hostname || agent.agent_id}</option>
)}
</For>
</FormSelect>
</div>
<Show when={duplicate()}>
@@ -10,6 +10,7 @@ import {
formLabel,
} from '@/components/shared/Form';
import { FormSelect } from '@/components/shared/FormSelect';
import { FormTextarea } from '@/components/shared/FormTextarea';
import {
AvailabilityTargetsAPI,
type AvailabilityProbeProtocol,
@@ -776,10 +777,11 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
</div>
<Show when={form().httpMethod === 'POST'}>
<label class={formField}>
<span class={formLabel}>Request body (optional)</span>
<textarea
class={`${formControl} min-h-24 font-mono text-xs`}
<div class={formField}>
<FormTextarea
label="Request body (optional)"
textareaBaseClass={`${formControl} min-h-24 sm:min-h-24 font-mono text-xs`}
help="Up to 8 KiB. Kept out of history and test output."
value={form().httpBody}
onInput={(event) =>
updateForm({
@@ -794,11 +796,8 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
: '{"operation":"health"}'
}
/>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class={formHelpText}>
Up to 8 KiB. Kept out of history and test output.
</span>
<Show when={form().httpBodyConfigured && !form().httpBodyTouched}>
<Show when={form().httpBodyConfigured && !form().httpBodyTouched}>
<div>
<button
type="button"
class="text-xs font-medium text-error hover:underline"
@@ -812,9 +811,9 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
>
Remove stored body
</button>
</Show>
</div>
</label>
</div>
</Show>
</div>
</Show>
<div class="grid gap-4 sm:grid-cols-2">
@@ -338,6 +338,10 @@ describe('AvailabilityTargetSlot', () => {
'placeholder',
'Stored securely — leave blank to keep it',
);
// The request body composes FormTextarea, which links its size note.
expect(screen.getByLabelText(/^Request body \(optional\)/)).toHaveAccessibleDescription(
'Up to 8 KiB. Kept out of history and test output.',
);
expect(screen.getByLabelText('Header value')).toHaveAttribute(
'placeholder',
'Stored securely — leave blank to keep it',
@@ -25,9 +25,9 @@ export const formCheckbox = baseCheckbox;
export const formControlDense = join(baseControl, 'py-1.5 px-2');
export const formControlMono = join(baseControl, 'font-mono');
export const formSelect = join(baseControl, 'pr-8 appearance-none');
export const formSelect = join(baseControl, 'pr-8 appearance-none form-select-arrow');
export const formTextarea = join(baseControl, 'min-h-[120px] resize-vertical');
export const formSelectCompact = join(compactControl, 'pr-8 appearance-none');
export const formSelectCompact = join(compactControl, 'pr-8 appearance-none form-select-arrow');
export const formTextareaCompact = join(compactControl, 'min-h-16 resize-y');
export const formLabelMuted = join(baseLabel, 'text-muted font-normal');
@@ -20,6 +20,8 @@ describe('FormSelect', () => {
expect(select).toBe(screen.getByTestId('delivery-mode'));
expect(select).toHaveValue('cli');
expect(select).toHaveClass('min-h-11', 'sm:min-h-9');
// appearance-none drops the native arrow, so the shared class draws one.
expect(select).toHaveClass('appearance-none', 'form-select-arrow');
});
it('preserves explicit ids and compact styling hooks', () => {
@@ -719,7 +719,6 @@ describe('shared primitive guardrails', () => {
'src/components/shared/FilterToolbar.tsx',
'src/features/alerts/AlertAppriseDestinationsSection.tsx',
'src/features/alerts/AlertDeliveryRoutingSection.tsx',
'src/features/alerts/AlertEscalationSection.tsx',
'src/features/alerts/AlertQuietHoursSection.tsx',
'src/features/patrol/PatrolAttentionWorkbench.tsx',
]);
@@ -7730,6 +7729,7 @@ describe('shared primitive guardrails', () => {
requiredPatterns?: string[];
allowedPaths?: string[];
ignoredPaths?: string[];
pathExcludes?: string[];
}>;
};
const registeredRule = registry.rules?.find(
@@ -7774,9 +7774,9 @@ describe('shared primitive guardrails', () => {
expect(guard?.allowedPaths ?? []).toHaveLength(0);
}
expect(inlineGuard?.triggerPatterns).toEqual(['data-inline', 'TableRow']);
expect(inlineGuard?.ignoredPaths).toEqual([
'src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx',
]);
// Test files assert on rendered rows; they are not row surfaces.
expect(inlineGuard?.pathExcludes).toEqual(['.test.', '__tests__']);
expect(inlineGuard?.ignoredPaths).toEqual([]);
expect(namedGuard?.triggerPatterns).toEqual(['detail-row', 'TableRow']);
expect(namedGuard?.ignoredPaths ?? []).toHaveLength(0);
@@ -379,6 +379,12 @@ describe('ThresholdsTab', () => {
'5-minute average, 1 platform override',
);
expect(virtualMachineWindow.options[0].textContent).toBe('Inherit (15 minutes)');
// The window controls compose the shared FormSelect: a visible label tied
// to the select and the shared dropdown arrow.
expect(document.querySelector(`label[for="${workloadWindow.id}"]`)?.textContent).toBe(
'All workloads',
);
expect(workloadWindow).toHaveClass('appearance-none', 'form-select-arrow');
fireEvent.input(workloadWindow, { target: { value: '60' } });
@@ -1,6 +1,7 @@
import { createSignal, For, Show } from 'solid-js';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import { FormSelect } from '@/components/shared/FormSelect';
import {
AlertIntentPolicyPanel,
type AlertIntentPolicySelectionTarget,
@@ -96,31 +97,28 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
const evaluationProfileControl = (profile: (typeof evaluationProfiles)[number]) => {
const configured = () => props.metricEvaluationWindows?.()[profile.key]?.cpu;
return (
<label class="block rounded-md border border-border p-3">
<span class="block text-xs font-medium text-base-content">{profile.label}</span>
<select
aria-label={`${profile.label} CPU averaging`}
class="mt-2 w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-base-content"
value={
profile.key === 'all'
? String(configured() ?? 300)
: configured() === undefined
? 'inherit'
: String(configured())
}
onInput={(event) => updateCPUWindow(profile.key, event.currentTarget.value)}
>
<Show when={profile.key !== 'all'}>
<option value="inherit">
Inherit ({evaluationLabel(inheritedCPUWindow(profile))})
</option>
</Show>
<option value="0">Current value</option>
<option value="60">1-minute average</option>
<option value="300">5-minute average</option>
<option value="900">15-minute average</option>
</select>
</label>
<FormSelect
label={profile.label}
aria-label={`${profile.label} CPU averaging`}
fieldClass="rounded-md border border-border p-3"
density="compact"
value={
profile.key === 'all'
? String(configured() ?? 300)
: configured() === undefined
? 'inherit'
: String(configured())
}
onInput={(event) => updateCPUWindow(profile.key, event.currentTarget.value)}
>
<Show when={profile.key !== 'all'}>
<option value="inherit">Inherit ({evaluationLabel(inheritedCPUWindow(profile))})</option>
</Show>
<option value="0">Current value</option>
<option value="60">1-minute average</option>
<option value="300">5-minute average</option>
<option value="900">15-minute average</option>
</FormSelect>
);
};
+22
View File
@@ -88,6 +88,28 @@
padding-bottom: max(3.5rem, env(safe-area-inset-bottom, 3.5rem));
}
/* The shared select classes drop the native arrow (appearance-none) so the
control matches the text inputs, which left every FormSelect looking like
a text field. Draw a small caret in the reserved right padding instead, in
the muted text colour so it follows the theme. */
@utility form-select-arrow {
background-image:
linear-gradient(45deg, transparent 50%, var(--color-text-muted) 50%),
linear-gradient(135deg, var(--color-text-muted) 50%, transparent 50%);
background-position:
calc(100% - 15px) calc(50% + 1px),
calc(100% - 10px) calc(50% + 1px);
background-size: 5px 5px;
background-repeat: no-repeat;
/* Forced-colors mode drops background images, so hand the arrow back to
the browser there. */
@media (forced-colors: active) {
appearance: auto !important;
background-image: none;
}
}
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;