Make configuration forms programmatically labeled

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-31 15:57:46 +01:00
parent 1bb23545b6
commit 65e6d5428a
33 changed files with 392 additions and 99 deletions
+1
View File
@@ -13,6 +13,7 @@ This document outlines the standard UI primitives, tokens, and components that c
- `npm run lint:theme` is a hard gate for theme governance across the whole frontend (`src/**` + `index.html`).
- `npm run lint:headers` audits header composition and page-level header usage policy.
- Routed surfaces must use shared header primitives (`PageHeader`, `SectionHeader`, `SettingsPanel`, `OperationsPanel`) instead of raw `<h1>` markup.
- Visible form labels must either use `for` with a matching control ID or wrap a native labelable control; use a heading, paragraph, or span for non-control captions.
- Only explicit non-visual route wrappers are exempt from header primitive requirements.
- Theme ownership policy:
- Only `src/utils/theme.ts` and `index.html` may read/write theme keys (`pulseThemePreference`, `darkMode`, `pulse_dark_mode`) or toggle the root `dark` class.
+2 -1
View File
@@ -35,10 +35,11 @@
"test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include=src/**/*.ts --coverage.include=src/**/*.tsx --coverage.exclude=src/index.tsx",
"test:coverage:ai": "vitest run --coverage --coverage.provider=v8 --coverage.thresholds.100 --coverage.thresholds.perFile --coverage.include=src/components/AI/aiChatUtils.ts",
"type-check": "tsc --noEmit",
"lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms",
"lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms && npm run lint:form-labels",
"lint:canonical-platforms": "node scripts/canonical-platform-audit.mjs",
"lint:eslint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:copy": "node scripts/copy-style-audit.mjs",
"lint:form-labels": "node scripts/form-label-audit.mjs",
"lint:theme": "node scripts/theme-audit.mjs",
"lint:headers": "node scripts/header-audit.mjs",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
@@ -0,0 +1,117 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const sourceRoot = path.resolve('src');
const sourceFiles = [];
const collectSourceFiles = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(entryPath);
} else if (entry.isFile() && entry.name.endsWith('.tsx')) {
sourceFiles.push(entryPath);
}
}
};
collectSourceFiles(sourceRoot);
const labelableElements = new Set([
'button',
'input',
'meter',
'output',
'progress',
'select',
'textarea',
]);
const diagnostics = [];
const getTagName = (node, sourceFile) => node.tagName.getText(sourceFile);
const getAttributeValue = (attribute, sourceFile) =>
attribute.initializer?.getText(sourceFile).replace(/^['"]|['"]$/g, '');
const hasLabelTarget = (element, sourceFile, labelableIds) => {
const attributes = element.openingElement.attributes.properties;
const forAttribute = attributes.find(
(attribute) =>
ts.isJsxAttribute(attribute) &&
['for', 'htmlFor'].includes(attribute.name.getText(sourceFile)),
);
if (forAttribute) {
const target = getAttributeValue(forAttribute, sourceFile);
return target !== undefined && labelableIds.has(target);
}
let containsLabelableElement = false;
const visit = (node) => {
if (
(ts.isJsxElement(node) &&
labelableElements.has(getTagName(node.openingElement, sourceFile))) ||
(ts.isJsxSelfClosingElement(node) && labelableElements.has(getTagName(node, sourceFile)))
) {
containsLabelableElement = true;
return;
}
ts.forEachChild(node, visit);
};
element.children.forEach(visit);
return containsLabelableElement;
};
for (const sourcePath of sourceFiles.sort()) {
const sourceText = fs.readFileSync(sourcePath, 'utf8');
const sourceFile = ts.createSourceFile(
sourcePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const labelableIds = new Set();
const collectLabelableIds = (node) => {
const element = ts.isJsxElement(node)
? node.openingElement
: ts.isJsxSelfClosingElement(node)
? node
: undefined;
if (element && labelableElements.has(getTagName(element, sourceFile))) {
const idAttribute = element.attributes.properties.find(
(attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'id',
);
if (idAttribute) {
const id = getAttributeValue(idAttribute, sourceFile);
if (id !== undefined) labelableIds.add(id);
}
}
ts.forEachChild(node, collectLabelableIds);
};
collectLabelableIds(sourceFile);
const visit = (node) => {
if (
ts.isJsxElement(node) &&
getTagName(node.openingElement, sourceFile) === 'label' &&
!hasLabelTarget(node, sourceFile, labelableIds)
) {
const position = sourceFile.getLineAndCharacterOfPosition(node.openingElement.getStart());
diagnostics.push(
`${path.relative(process.cwd(), sourcePath)}:${position.line + 1}:${position.character + 1} ` +
'label must target an ID on a native labelable control or contain that control',
);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
if (diagnostics.length > 0) {
console.error('Form label audit failed:\n');
diagnostics.forEach((diagnostic) => console.error(`- ${diagnostic}`));
process.exit(1);
}
console.log(`Form label audit passed (${sourceFiles.length} TSX files checked).`);
@@ -117,7 +117,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
return (
<div class="space-y-2 pb-4 border-b border-border-subtle last:border-0">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-base-content">{column}</label>
<span class="text-sm font-medium text-base-content">{column}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 font-mono">
{isOff()
@@ -148,6 +148,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
{['cpu', 'memory', 'disk', 'temperature'].includes(metric) ? (
<div class="pt-2 px-1">
<ThresholdSlider
ariaLabel={`${column} threshold`}
type={metric as 'cpu' | 'memory' | 'disk' | 'temperature'}
min={bounds.min}
max={bounds.max}
@@ -161,6 +162,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
) : (
<input
type="number"
aria-label={`${column} threshold`}
class="w-full h-9 rounded-md border border-border bg-surface px-3 py-1 text-sm shadow-sm transition-colors focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
min={bounds.min}
max={bounds.max}
@@ -141,9 +141,9 @@ export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSecti
/>
</div>
<div class="mt-4">
<label class="text-xs font-medium uppercase tracking-wide text-muted">
<span class="text-xs font-medium uppercase tracking-wide text-muted">
{state.backupOrphanedPresentation.ignoreVmidsLabel}
</label>
</span>
<p class="mt-1 text-xs text-muted">
{state.backupOrphanedPresentation.ignoreVmidsDescription}
</p>
@@ -80,7 +80,7 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
<div class="space-y-4 text-sm">
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-base-content">Service Type</label>
<span class="text-sm font-medium text-base-content">Service Type</span>
<button
type="button"
onClick={() => props.setShowServiceDropdown((open) => !open)}
@@ -124,8 +124,11 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>Name</label>
<label for="alert-webhook-name" class={labelClass()}>
Name
</label>
<input
id="alert-webhook-name"
type="text"
value={props.formData().name}
onInput={(e) => props.setFormData((prev) => ({ ...prev, name: e.currentTarget.value }))}
@@ -150,8 +153,11 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
</div>
<div class={formField}>
<label class={labelClass()}>Webhook URL</label>
<label for="alert-webhook-url" class={labelClass()}>
Webhook URL
</label>
<input
id="alert-webhook-url"
type="url"
value={props.formData().url}
onInput={(e) => props.setFormData((prev) => ({ ...prev, url: e.currentTarget.value }))}
@@ -219,11 +225,12 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
)}
>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
<label for="alert-webhook-mention" class={labelClass('flex items-center gap-2')}>
Mention
<span class="text-xs text-muted">{ALERT_WEBHOOK_MENTION_HELP_LABEL}</span>
</label>
<input
id="alert-webhook-mention"
type="text"
value={props.formData().mention || ''}
onInput={(e) =>
@@ -274,7 +281,7 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
<Show when={props.customFieldInputs().length > 0 || props.formData().service === 'pushover'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
<span class={labelClass('flex items-center gap-2')}>
Custom fields
<span class="text-xs text-muted">
{ALERT_WEBHOOK_CUSTOM_FIELDS_HELP}{' '}
@@ -283,7 +290,7 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
</code>{' '}
in templates
</span>
</label>
</span>
<div class="space-y-2 text-xs">
<Index each={props.customFieldInputs()}>
{(field, index) => (
@@ -341,10 +348,10 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
</Show>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
<span class={labelClass('flex items-center gap-2')}>
Custom headers
<span class="text-xs text-muted">{ALERT_WEBHOOK_HEADERS_HELP_LABEL}</span>
</label>
</span>
<div class="space-y-2 text-xs">
<Index each={props.headerInputs()}>
{(header, index) => (
@@ -663,7 +663,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
<Show when={!schedulerOpen()}>
<div class="flex flex-col items-stretch justify-between gap-3 border-t border-border-subtle pt-2 sm:flex-row sm:items-center">
<div class="min-w-0 flex-1">
<label class="text-sm font-medium text-base-content">Maintenance window</label>
<span class="text-sm font-medium text-base-content">Maintenance window</span>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Suspend findings on this resource for a defined window. Useful for scheduled upgrades,
planned downtime, or reboots where Pulse should stay quiet until the window closes.
@@ -874,7 +874,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
<div class="space-y-3 border-t border-border-subtle pt-3" aria-label="Automatic actions">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<label class="text-sm font-medium text-base-content">Automatic action limits</label>
<span class="text-sm font-medium text-base-content">Automatic action limits</span>
<p class="mt-0.5 text-[11px] leading-tight text-muted">
This resource follows your Patrol mode by default. Turn this on only to limit
automatic work to selected actions or daily hours. Live safety checks and
@@ -882,6 +882,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</p>
</div>
<Toggle
ariaLabel="Limit automatic actions for this resource"
checked={autoRemediationEnabled()}
onChange={(event) => setAutoRemediationEnabled(event.currentTarget.checked)}
disabled={saving() || neverAutoRemediate()}
@@ -922,7 +923,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
)}
</For>
<label class="flex items-center justify-between gap-3 border-t border-border-subtle pt-2">
<div class="flex items-center justify-between gap-3 border-t border-border-subtle pt-2">
<span>
<span class="block text-xs font-medium text-base-content">
Restrict to daily hours
@@ -932,11 +933,12 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</span>
</span>
<Toggle
ariaLabel="Restrict automatic actions to daily hours"
checked={autoWindowEnabled()}
onChange={(event) => setAutoWindowEnabled(event.currentTarget.checked)}
disabled={saving()}
/>
</label>
</div>
<Show when={autoWindowEnabled()}>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
@@ -1025,9 +1027,9 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
<div class="flex items-start justify-between gap-3 pt-2 border-t border-border-subtle">
<div class="min-w-0 flex-1">
<label class="text-sm font-medium text-red-700 dark:text-red-400">
<span class="text-sm font-medium text-red-700 dark:text-red-400">
Never auto-remediate
</label>
</span>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Refuse all automated remediation against this resource, even with a valid approval. The
action broker logs every refused dispatch as a Failed audit record. Use for resources
@@ -1035,6 +1037,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</p>
</div>
<Toggle
ariaLabel="Never auto-remediate this resource"
checked={neverAutoRemediate()}
onChange={(e) => handleNeverAutoRemediateToggle(e.currentTarget.checked)}
disabled={saving() || lifecycleState() === 'retired'}
@@ -61,7 +61,7 @@ export const AIChatMaintenanceSection: Component<AIChatMaintenanceSectionProps>
shared provider and model defaults.
</p>
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-muted">Session</label>
<span class="text-xs font-medium text-muted">Session</span>
<button
type="button"
onClick={state.loadChatSessions}
@@ -400,7 +400,7 @@ export const AIModelOverrideField: Component<{
return (
<div class={formField}>
<label class="block text-xs font-medium text-muted mb-0.5">{config().label}</label>
<span class="block text-xs font-medium text-muted mb-0.5">{config().label}</span>
<p class="text-[11px] text-muted mb-1">{config().description}</p>
<Show
when={selectableModels().length > 0}
@@ -500,10 +500,10 @@ export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> =
<>
<div class={formField}>
<div class="flex items-center justify-between mb-1">
<label class={labelClass()}>
<span class={labelClass()}>
Shared Default Model
{state.modelsLoading() && <span class="ml-2 text-xs text-muted">(loading...)</span>}
</label>
</span>
<button
type="button"
onClick={state.loadModels}
@@ -195,13 +195,13 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
class="px-3 py-3 bg-surface-alt border-t border-border space-y-2"
>
<Show when={config.provider === 'ollama'}>
<label class="text-xs text-muted inline-flex items-center gap-1">
<span class="text-xs text-muted inline-flex items-center gap-1">
Server URL
<HelpIcon contentId="ai.ollama.baseUrl" size="xs" />
</label>
</span>
</Show>
<Show when={config.inputLabel}>
<label class="text-xs text-muted">{config.inputLabel}</label>
<span class="text-xs text-muted">{config.inputLabel}</span>
</Show>
<Show
when={config.inputType === 'toggle'}
@@ -247,12 +247,12 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
<For each={config.extraFields || []}>
{(extraField) => (
<div class="space-y-1">
<label class="text-xs text-muted inline-flex items-center gap-1">
<span class="text-xs text-muted inline-flex items-center gap-1">
{extraField.label}
<Show when={extraField.helpContentId}>
<HelpIcon contentId={extraField.helpContentId!} size="xs" />
</Show>
</label>
</span>
<input
type={extraField.type || 'text'}
value={String(props.form[extraField.inputField])}
@@ -95,11 +95,12 @@ export const AIDiscoveryControlsSection: Component<AIRuntimeControlsSectionProps
class="px-3 py-3 bg-surface border-t border-border space-y-3"
>
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-muted flex items-center gap-1.5">
<span class="text-xs font-medium text-muted flex items-center gap-1.5">
Enable service context scans
<HelpIcon inline={getAISettingsWorkloadDiscoveryHelpContent()} size="xs" />
</label>
</span>
<Toggle
ariaLabel="Enable service context scans"
checked={state.form.discoveryEnabled}
onChange={(event) => state.setForm('discoveryEnabled', event.currentTarget.checked)}
disabled={state.saving()}
@@ -188,10 +189,13 @@ export const AIProviderRuntimeControlsSection: Component<AIRuntimeControlsSectio
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<label class="text-xs font-medium text-base-content">30-day Budget</label>
<label for="ai-cost-budget" class="text-xs font-medium text-base-content">
30-day Budget
</label>
<div class="relative flex-shrink-0">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-muted text-xs">$</span>
<input
id="ai-cost-budget"
type="number"
class="w-24 min-h-10 sm:min-h-9 pl-5 pr-2 py-2 text-sm border border-border rounded bg-surface"
value={state.form.costBudgetUSD30d}
@@ -227,8 +231,11 @@ export const AIProviderRuntimeControlsSection: Component<AIRuntimeControlsSectio
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<label class="text-xs font-medium text-base-content">Request Timeout</label>
<label for="ai-request-timeout" class="text-xs font-medium text-base-content">
Request Timeout
</label>
<input
id="ai-request-timeout"
type="number"
class="w-20 min-h-10 sm:min-h-9 px-2 py-2 text-sm border border-border rounded bg-surface"
value={state.form.requestTimeoutSeconds}
@@ -359,11 +366,15 @@ export const AIAssistantCommandAccessSection: Component<AIRuntimeControlsSection
<Show when={state.form.controlLevel !== 'read_only'}>
<div class="flex items-start gap-3 pt-2 border-t border-blue-200 dark:border-blue-700">
<label class="text-xs font-medium text-muted w-28 flex-shrink-0 pt-1">
<label
for="ai-protected-guests"
class="text-xs font-medium text-muted w-28 flex-shrink-0 pt-1"
>
Protected guests
</label>
<div class="flex-1">
<input
id="ai-protected-guests"
type="text"
value={state.form.protectedGuests}
onInput={(e) => state.setForm('protectedGuests', e.currentTarget.value)}
@@ -61,10 +61,14 @@ export const AISettingsDialogs: Component<AISettingsDialogsProps> = (props) => {
when={props.setupProvider() === 'ollama'}
fallback={
<div>
<label class="block text-sm font-medium text-base-content mb-1.5">
<label
for="ai-setup-api-key"
class="block text-sm font-medium text-base-content mb-1.5"
>
{setupProviderConfig().title} API Key
</label>
<input
id="ai-setup-api-key"
type="password"
value={props.setupApiKey()}
onInput={(event) => props.setSetupApiKey(event.currentTarget.value)}
@@ -80,10 +84,14 @@ export const AISettingsDialogs: Component<AISettingsDialogsProps> = (props) => {
}
>
<div>
<label class="block text-sm font-medium text-base-content mb-1.5">
<label
for="ai-setup-ollama-url"
class="block text-sm font-medium text-base-content mb-1.5"
>
Ollama Server URL
</label>
<input
id="ai-setup-ollama-url"
type="url"
value={props.setupOllamaUrl()}
onInput={(event) => props.setSetupOllamaUrl(event.currentTarget.value)}
@@ -669,10 +669,14 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<div class="space-y-4">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-wide text-muted">
<label
for="api-token-name"
class="text-xs font-semibold uppercase tracking-wide text-muted"
>
Token name
</label>
<input
id="api-token-name"
type="text"
value={nameInput()}
onInput={(e) => setNameInput(e.currentTarget.value)}
@@ -340,6 +340,7 @@ export const AgentProfilesPanel: Component = () => {
<button
type="button"
onClick={() => setShowModal(false)}
aria-label="Close agent profile editor"
class="p-1.5 rounded-md text-slate-500 hover:text-base-content hover:bg-surface-hover"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -356,8 +357,14 @@ export const AgentProfilesPanel: Component = () => {
<div class="px-6 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Profile Name */}
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Profile Name</label>
<label
for="agent-profile-name"
class="block text-sm font-medium text-base-content"
>
Profile Name
</label>
<input
id="agent-profile-name"
type="text"
value={formName()}
onInput={(e) => setFormName(e.currentTarget.value)}
@@ -384,18 +391,24 @@ export const AgentProfilesPanel: Component = () => {
{/* Settings */}
<div class="space-y-3">
<label class="block text-sm font-medium text-base-content">Settings</label>
<span class="block text-sm font-medium text-base-content">Settings</span>
<For each={KNOWN_SETTINGS}>
{(setting) => (
<div class="rounded-md border border-border p-3 space-y-1">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-base-content">
<span class="text-sm font-medium text-base-content">
{setting.label}
</label>
</span>
<Show when={setting.type === 'boolean'}>
<button
type="button"
aria-label={`${setting.label}: ${String(formSettings()[setting.key] ?? 'default')}`}
aria-pressed={
formSettings()[setting.key] === undefined
? 'mixed'
: formSettings()[setting.key] === true
}
onClick={() => {
const current = formSettings()[setting.key];
if (current === undefined) {
@@ -445,6 +458,7 @@ export const AgentProfilesPanel: Component = () => {
<Show when={setting.type === 'duration'}>
<input
type="text"
aria-label={setting.label}
value={(formSettings()[setting.key] as string) || ''}
onInput={(e) =>
updateSetting(setting.key, e.currentTarget.value || undefined)
@@ -456,6 +470,7 @@ export const AgentProfilesPanel: Component = () => {
<Show when={setting.type === 'string'}>
<input
type="text"
aria-label={setting.label}
value={(formSettings()[setting.key] as string) || ''}
onInput={(e) =>
updateSetting(setting.key, e.currentTarget.value || undefined)
@@ -480,12 +495,13 @@ export const AgentProfilesPanel: Component = () => {
{(key) => (
<div class="rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 p-3 mb-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-base-content font-mono">
<span class="text-sm font-medium text-base-content font-mono">
{key}
</label>
</span>
<div class="flex items-center gap-2">
<input
type="text"
aria-label={key}
value={String(formSettings()[key] ?? '')}
onInput={(e) => {
const val = e.currentTarget.value;
@@ -87,7 +87,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
</Show>
<div class={formField}>
<label class={labelClass()}>
<label for="backup-export-passphrase" class={labelClass()}>
{props.securityStatus()?.hasAuthentication
? props.useCustomPassphrase()
? 'Custom Passphrase'
@@ -95,6 +95,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
: 'Encryption Passphrase'}
</label>
<input
id="backup-export-passphrase"
type="password"
value={props.exportPassphrase()}
onInput={(event) => props.setExportPassphrase(event.currentTarget.value)}
@@ -192,8 +193,11 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
</p>
<div class={formField}>
<label class={labelClass()}>API Token</label>
<label for="backup-api-token" class={labelClass()}>
API Token
</label>
<input
id="backup-api-token"
type="password"
value={props.apiTokenInput()}
onInput={(event) => props.setApiTokenInput(event.currentTarget.value)}
@@ -243,8 +247,11 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<div class="space-y-4">
<div class={formField}>
<label class={labelClass()}>Configuration File</label>
<label for="backup-import-file" class={labelClass()}>
Configuration File
</label>
<input
id="backup-import-file"
type="file"
accept=".json"
onChange={(event) => {
@@ -256,8 +263,11 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
</div>
<div class={formField}>
<label class={labelClass()}>Backup Password</label>
<label for="backup-import-passphrase" class={labelClass()}>
Backup Password
</label>
<input
id="backup-import-passphrase"
type="password"
value={props.importPassphrase()}
onInput={(event) => props.setImportPassphrase(event.currentTarget.value)}
@@ -381,7 +381,10 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<Show when={props.pvePollingSelection() === 'custom'}>
<div class="mt-4 flex flex-col sm:flex-row sm:items-center gap-4 rounded-md border border-dashed border-border bg-surface-hover p-4 transition-all animate-in fade-in slide-in-from-top-1">
<div class="flex-1 min-w-0">
<label class="block text-sm font-medium text-base-content truncate">
<label
for="pve-custom-polling-seconds"
class="block text-sm font-medium text-base-content truncate"
>
{t('settings.general.monitoringCadence.custom.title')}
</label>
<p class="text-xs text-muted mt-0.5 line-clamp-2">
@@ -392,6 +395,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
</p>
</div>
<input
id="pve-custom-polling-seconds"
type="number"
min={PVE_POLLING_MIN_SECONDS}
max={PVE_POLLING_MAX_SECONDS}
@@ -531,11 +531,15 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
<Show when={showAdvancedOptions()}>
<div class="space-y-3 rounded-md border border-border bg-surface px-4 py-4">
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
<label class="mb-1.5 block text-xs font-medium text-base-content">
<label
for="agent-custom-connection-url"
class="mb-1.5 block text-xs font-medium text-base-content"
>
Connection URL (Agent Pulse)
</label>
<div class="flex gap-2">
<input
id="agent-custom-connection-url"
type="text"
value={state.customAgentUrl()}
onInput={(event) => state.setCustomAgentUrl(event.currentTarget.value)}
@@ -47,7 +47,7 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
Public URL
</h4>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content">
<label for="pulse-public-url" class="text-sm font-medium text-base-content">
Pulse URL for Alerts and Agent Commands
</label>
<p class="text-xs text-muted">
@@ -57,6 +57,7 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
</p>
<div class="relative">
<input
id="pulse-public-url"
type="text"
value={props.publicURL()}
onInput={(e) => {
@@ -104,12 +105,15 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
Network Settings
</h4>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content">CORS Allowed Origins</label>
<label for="cors-allowed-origins" class="text-sm font-medium text-base-content">
CORS Allowed Origins
</label>
<p class="text-xs text-muted">
For reverse proxy setups (* = allow all, empty = same-origin only)
</p>
<div class="relative">
<input
id="cors-allowed-origins"
type="text"
value={props.allowedOrigins()}
onInput={(e) => {
@@ -171,7 +175,7 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
<Show when={props.allowEmbedding()}>
<div class="space-y-2">
<label class="text-xs font-medium text-base-content">
<label for="allowed-embed-origins" class="text-xs font-medium text-base-content">
Allowed Embed Origins (optional)
</label>
<p class="text-xs text-muted">
@@ -179,6 +183,7 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
only)
</p>
<input
id="allowed-embed-origins"
type="text"
value={props.allowedEmbedOrigins()}
onChange={(e) => {
@@ -217,7 +222,7 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
</h3>
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-base-content">
<label for="webhook-private-cidrs" class="text-sm font-medium text-base-content">
Allowed Private IP Ranges for Webhooks
</label>
<p class="text-xs text-muted mb-2">
@@ -226,6 +231,7 @@ export const NetworkBoundarySettingsSection: Component<NetworkBoundarySettingsSe
IPs).
</p>
<input
id="webhook-private-cidrs"
type="text"
value={props.webhookAllowedPrivateCIDRs()}
onChange={(e) => {
@@ -66,10 +66,11 @@ export const NodeModalAuthenticationSection: Component<NodeModalAuthenticationSe
<Show when={state.formData().authType === 'password'}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>
<label for="node-auth-username" class={labelClass()}>
Username <span class="text-red-500">*</span>
</label>
<input
id="node-auth-username"
type="text"
value={state.formData().user}
onInput={(event) => state.updateField('user', event.currentTarget.value)}
@@ -83,13 +84,14 @@ export const NodeModalAuthenticationSection: Component<NodeModalAuthenticationSe
</div>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
<label for="node-auth-password" class={labelClass('flex items-center gap-2')}>
Password
<Show when={!state.isEditingExistingNode()}>
<span class="text-red-500">*</span>
</Show>
</label>
<input
id="node-auth-password"
type="password"
value={state.formData().password}
onInput={(event) => state.updateField('password', event.currentTarget.value)}
@@ -116,10 +118,11 @@ export const NodeModalAuthenticationSection: Component<NodeModalAuthenticationSe
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>
<label for="node-auth-token-id" class={labelClass()}>
Token ID <span class="text-red-500">*</span>
</label>
<input
id="node-auth-token-id"
type="text"
value={state.formData().tokenName}
onInput={(event) => state.updateField('tokenName', event.currentTarget.value)}
@@ -133,13 +136,14 @@ export const NodeModalAuthenticationSection: Component<NodeModalAuthenticationSe
</div>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
<label for="node-auth-token-value" class={labelClass('flex items-center gap-2')}>
Token Value
<Show when={!state.isEditingExistingNode()}>
<span class="text-red-500">*</span>
</Show>
</label>
<input
id="node-auth-token-value"
type="password"
value={state.formData().tokenValue}
onInput={(event) => state.updateField('tokenValue', event.currentTarget.value)}
@@ -27,10 +27,11 @@ export const NodeModalBasicInfoSection: Component<NodeModalBasicInfoSectionProps
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
<label for="node-name" class={labelClass('flex items-center gap-2')}>
Node Name <span class="text-red-500">*</span>
</label>
<input
id="node-name"
type="text"
value={state.formData().name}
onInput={(event) => state.updateField('name', event.currentTarget.value)}
@@ -45,10 +46,11 @@ export const NodeModalBasicInfoSection: Component<NodeModalBasicInfoSectionProps
</div>
<div class={formField}>
<label class={labelClass('flex items-center gap-1')}>
<label for="node-endpoint-url" class={labelClass('flex items-center gap-1')}>
Endpoint URL <span class="text-red-500">*</span>
</label>
<input
id="node-endpoint-url"
type="text"
value={state.formData().host}
onInput={(event) => state.updateField('host', event.currentTarget.value)}
@@ -62,10 +64,11 @@ export const NodeModalBasicInfoSection: Component<NodeModalBasicInfoSectionProps
</div>
<div class={formField}>
<label class={labelClass('flex items-center gap-1')}>
<label for="node-guest-url" class={labelClass('flex items-center gap-1')}>
Guest URL <span class="text-slate-500 text-xs font-normal">(Optional)</span>
</label>
<input
id="node-guest-url"
type="text"
value={state.formData().guestURL}
onInput={(event) => state.updateField('guestURL', event.currentTarget.value)}
@@ -40,8 +40,11 @@ export const NodeModalMonitoringSection: Component<NodeModalMonitoringSectionPro
</label>
<div class={formField}>
<label class={labelClass()}>SSL Fingerprint (optional)</label>
<label for="node-ssl-fingerprint" class={labelClass()}>
SSL Fingerprint (optional)
</label>
<input
id="node-ssl-fingerprint"
type="text"
value={state.formData().fingerprint}
onInput={(event) => state.updateField('fingerprint', event.currentTarget.value)}
@@ -174,7 +177,7 @@ export const NodeModalMonitoringSection: Component<NodeModalMonitoringSectionPro
</div>
<Show when={state.formData().monitorPhysicalDisks}>
<div class="mt-3 flex items-center gap-2 border-t border-border pt-3">
<label class="text-xs text-muted">Poll every</label>
<span class="text-xs text-muted">Poll every</span>
<FormSelect
label="Physical disk health polling interval"
labelClass="sr-only"
@@ -219,7 +219,7 @@ Important:
<div class="bg-base rounded-md p-3 space-y-3">
<div class="flex items-center justify-between">
<label class={labelClass()}>Password Setup</label>
<span class={labelClass()}>Password Setup</span>
<div class="flex items-center space-x-2">
<button
type="button"
@@ -249,8 +249,11 @@ Important:
<Show when={useCustomPassword()}>
<div class="space-y-2">
<div class={formField}>
<label class={labelClass()}>Username</label>
<label for="quick-security-username" class={labelClass()}>
Username
</label>
<input
id="quick-security-username"
type="text"
value={customUsername()}
onInput={(e) => setCustomUsername(e.currentTarget.value)}
@@ -259,8 +262,11 @@ Important:
/>
</div>
<div class={formField}>
<label class={labelClass()}>Password (min 12 characters)</label>
<label for="quick-security-password" class={labelClass()}>
Password (min 12 characters)
</label>
<input
id="quick-security-password"
type="password"
value={customPassword()}
onInput={(e) => setCustomPassword(e.currentTarget.value)}
@@ -269,8 +275,11 @@ Important:
/>
</div>
<div class={formField}>
<label class={labelClass()}>Confirm password</label>
<label for="quick-security-confirm-password" class={labelClass()}>
Confirm password
</label>
<input
id="quick-security-confirm-password"
type="password"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
@@ -382,7 +391,7 @@ Important:
<div class="space-y-3">
<div class="bg-base rounded-md p-3">
<label class={labelClass('text-xs')}>Username</label>
<span class={labelClass('text-xs')}>Username</span>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-surface px-3 py-2 rounded border border-border">
{credentials()!.username}
@@ -398,7 +407,7 @@ Important:
</div>
<div class="bg-base rounded-md p-3">
<label class={labelClass('text-xs')}>Password</label>
<span class={labelClass('text-xs')}>Password</span>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-surface px-3 py-2 rounded border border-border break-all">
{credentials()!.password}
@@ -414,7 +423,7 @@ Important:
</div>
<div class="bg-base rounded-md p-3">
<label class={labelClass('text-xs')}>API token</label>
<span class={labelClass('text-xs')}>API token</span>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-surface px-3 py-2 rounded border border-border break-all">
{credentials()!.apiToken}
@@ -136,11 +136,15 @@ export const RecoverySettingsPanel: Component<RecoverySettingsPanelProps> = (pro
{/* Custom interval input */}
<Show when={props.backupIntervalSelectValue() === 'custom'}>
<div class="space-y-2">
<label class="text-xs font-medium text-base-content">
<label
for="backup-custom-interval-minutes"
class="text-xs font-medium text-base-content"
>
Custom interval (minutes)
</label>
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
id="backup-custom-interval-minutes"
type="number"
min="1"
max={BACKUP_INTERVAL_MAX_MINUTES}
@@ -30,7 +30,7 @@ interface RelayPairingSectionProps {
export const RelayPairingSection: Component<RelayPairingSectionProps> = (props) => (
<div class={formField}>
<label class={labelClass()}>Pair Mobile Device</label>
<span class={labelClass()}>Pair Mobile Device</span>
<Card tone="muted" padding="md">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
@@ -95,10 +95,11 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
<div class={formField}>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class={labelClass()}>Enable Remote Access</label>
<span class={labelClass()}>Enable Remote Access</span>
<p class={formHelpText}>{RELAY_ENABLE_HELP_TEXT}</p>
</div>
<Toggle
ariaLabel="Enable remote access"
checked={state.config()?.enabled ?? false}
onChange={(e) => void state.handleToggleEnabled(e.currentTarget.checked)}
disabled={!state.canManage() || state.saving()}
@@ -109,9 +110,12 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
{/* Server URL */}
<div class={formField}>
<label class={labelClass()}>Relay Server URL</label>
<label for="relay-server-url" class={labelClass()}>
Relay Server URL
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
id="relay-server-url"
type="text"
class={controlClass()}
value={state.serverUrl()}
@@ -137,7 +141,7 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
{/* Identity Fingerprint */}
<Show when={state.config()?.identity_fingerprint}>
<div class={formField}>
<label class={labelClass()}>Instance Fingerprint</label>
<span class={labelClass()}>Instance Fingerprint</span>
<code class="block text-xs font-mono text-base-content bg-surface-alt rounded px-3 py-2 select-all break-all">
{state.config()!.identity_fingerprint}
</code>
@@ -43,8 +43,8 @@ interface FormFieldProps {
function FormField(props: FormFieldProps) {
return (
<div class={formField}>
<label class={formLabel}>{props.label}</label>
<div class={formField} role="group" aria-label={props.label}>
<span class={formLabel}>{props.label}</span>
{props.children}
{props.helpText && <span class={formHelpText}>{props.helpText}</span>}
</div>
@@ -232,6 +232,7 @@ export function ReportingPanel() {
>
<input
id="metric-type"
aria-label="Metric Type (Optional)"
type="text"
class={formControl}
placeholder="e.g. cpu, memory, disk, temperature (leave empty for all)"
@@ -245,6 +246,7 @@ export function ReportingPanel() {
<FormField label="Report Title" helpText="Custom title for the PDF report">
<input
id="report-title"
aria-label="Report Title"
type="text"
class={formControl}
placeholder="Auto-generated if empty"
@@ -527,6 +529,7 @@ export function ReportingPanel() {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField label="Schedule name">
<input
aria-label="Schedule name"
type="text"
class={formControl}
value={scheduleForm().name}
@@ -536,6 +539,7 @@ export function ReportingPanel() {
</FormField>
<FormField label="Timezone">
<input
aria-label="Timezone"
type="text"
class={formControl}
value={scheduleForm().timezone}
@@ -576,6 +580,7 @@ export function ReportingPanel() {
>
<FormField label="Day of month">
<input
aria-label="Day of month"
type="number"
min="1"
max="28"
@@ -589,6 +594,7 @@ export function ReportingPanel() {
</Show>
<FormField label="Time">
<input
aria-label="Time"
type="time"
class={formControl}
value={scheduleForm().time}
@@ -621,6 +627,7 @@ export function ReportingPanel() {
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField label="Tag filter" helpText="Comma-separated tags">
<input
aria-label="Tag filter"
type="text"
class={formControl}
value={scheduleForm().tagFilter}
@@ -642,6 +649,7 @@ export function ReportingPanel() {
</FormSelect>
<FormField label="Retention">
<input
aria-label="Retention"
type="number"
min="1"
max="120"
@@ -660,6 +668,7 @@ export function ReportingPanel() {
helpText="Blank uses the existing email notification recipients"
>
<input
aria-label="Email recipients"
type="text"
class={formControl}
value={scheduleForm().recipients}
@@ -53,8 +53,11 @@ export const RolesEditorDialog: Component<RolesEditorDialogProps> = (props) => (
<div class="px-4 sm:px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Role ID</label>
<label for="role-editor-id" class="block text-sm font-medium text-base-content">
Role ID
</label>
<input
id="role-editor-id"
type="text"
value={props.formId}
onInput={(event) => props.onFormIdInput(event.currentTarget.value)}
@@ -64,8 +67,11 @@ export const RolesEditorDialog: Component<RolesEditorDialogProps> = (props) => (
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Role Name</label>
<label for="role-editor-name" class="block text-sm font-medium text-base-content">
Role Name
</label>
<input
id="role-editor-name"
type="text"
value={props.formName}
onInput={(event) => props.onFormNameInput(event.currentTarget.value)}
@@ -75,8 +81,14 @@ export const RolesEditorDialog: Component<RolesEditorDialogProps> = (props) => (
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Description</label>
<label
for="role-editor-description"
class="block text-sm font-medium text-base-content"
>
Description
</label>
<input
id="role-editor-description"
type="text"
value={props.formDescription}
onInput={(event) => props.onFormDescriptionInput(event.currentTarget.value)}
@@ -87,7 +99,7 @@ export const RolesEditorDialog: Component<RolesEditorDialogProps> = (props) => (
<div class="space-y-3 pt-2">
<div class="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<label class="block text-sm font-medium text-base-content">Permissions</label>
<span class="block text-sm font-medium text-base-content">Permissions</span>
<button
type="button"
onClick={props.onAddPermission}
@@ -287,8 +287,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
{/* Common fields */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class={formField}>
<label class={labelClass()}>Provider Name</label>
<label for="sso-provider-name" class={labelClass()}>
Provider Name
</label>
<input
id="sso-provider-name"
type="text"
value={form.name}
onInput={(e) => setForm('name', e.currentTarget.value)}
@@ -299,8 +302,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<p class={formHelpText}>Display name for this provider</p>
</div>
<div class={formField}>
<label class={labelClass()}>Display Name (Button)</label>
<label for="sso-provider-display-name" class={labelClass()}>
Display Name (Button)
</label>
<input
id="sso-provider-display-name"
type="text"
value={form.displayName}
onInput={(e) => setForm('displayName', e.currentTarget.value)}
@@ -347,9 +353,12 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class={formField}>
<label class={labelClass()}>Issuer URL</label>
<label for="sso-oidc-issuer-url" class={labelClass()}>
Issuer URL
</label>
<div class="flex gap-2">
<input
id="sso-oidc-issuer-url"
type="url"
value={form.oidcIssuerUrl}
onInput={(e) => setForm('oidcIssuerUrl', e.currentTarget.value)}
@@ -371,8 +380,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</div>
</div>
<div class={formField}>
<label class={labelClass()}>Client ID</label>
<label for="sso-oidc-client-id" class={labelClass()}>
Client ID
</label>
<input
id="sso-oidc-client-id"
type="text"
value={form.oidcClientId}
onInput={(e) => setForm('oidcClientId', e.currentTarget.value)}
@@ -382,8 +394,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
/>
</div>
<div class={formField}>
<label class={labelClass()}>Client Secret</label>
<label for="sso-oidc-client-secret" class={labelClass()}>
Client Secret
</label>
<input
id="sso-oidc-client-secret"
type="password"
value={form.oidcClientSecret}
onInput={(e) => setForm('oidcClientSecret', e.currentTarget.value)}
@@ -394,8 +409,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
/>
</div>
<div class={formField}>
<label class={labelClass()}>Redirect URL</label>
<label for="sso-oidc-redirect-url" class={labelClass()}>
Redirect URL
</label>
<input
id="sso-oidc-redirect-url"
type="url"
value={form.oidcRedirectUrl}
onInput={(e) => setForm('oidcRedirectUrl', e.currentTarget.value)}
@@ -406,8 +424,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</div>
<div class={formField}>
<label class={labelClass()}>Scopes</label>
<label for="sso-oidc-scopes" class={labelClass()}>
Scopes
</label>
<input
id="sso-oidc-scopes"
type="text"
value={form.oidcScopes}
onInput={(e) => setForm('oidcScopes', e.currentTarget.value)}
@@ -449,9 +470,12 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</div>
<div class={formField}>
<label class={labelClass()}>IdP Metadata URL</label>
<label for="sso-saml-metadata-url" class={labelClass()}>
IdP Metadata URL
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
id="sso-saml-metadata-url"
type="url"
value={form.samlIdpMetadataUrl}
onInput={(e) => setForm('samlIdpMetadataUrl', e.currentTarget.value)}
@@ -491,8 +515,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class={formField}>
<label class={labelClass()}>IdP SSO URL</label>
<label for="sso-saml-sso-url" class={labelClass()}>
IdP SSO URL
</label>
<input
id="sso-saml-sso-url"
type="url"
value={form.samlIdpSsoUrl}
onInput={(e) => setForm('samlIdpSsoUrl', e.currentTarget.value)}
@@ -501,8 +528,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
/>
</div>
<div class={formField}>
<label class={labelClass()}>IdP Entity ID</label>
<label for="sso-saml-entity-id" class={labelClass()}>
IdP Entity ID
</label>
<input
id="sso-saml-entity-id"
type="text"
value={form.samlIdpEntityId}
onInput={(e) => setForm('samlIdpEntityId', e.currentTarget.value)}
@@ -523,8 +553,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class={formField}>
<label class={labelClass()}>Username Attribute</label>
<label for="sso-saml-username-attribute" class={labelClass()}>
Username Attribute
</label>
<input
id="sso-saml-username-attribute"
type="text"
value={form.samlUsernameAttr}
onInput={(e) => setForm('samlUsernameAttr', e.currentTarget.value)}
@@ -533,8 +566,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
/>
</div>
<div class={formField}>
<label class={labelClass()}>Email Attribute</label>
<label for="sso-saml-email-attribute" class={labelClass()}>
Email Attribute
</label>
<input
id="sso-saml-email-attribute"
type="text"
value={form.samlEmailAttr}
onInput={(e) => setForm('samlEmailAttr', e.currentTarget.value)}
@@ -543,8 +579,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
/>
</div>
<div class={formField}>
<label class={labelClass()}>Groups Attribute</label>
<label for="sso-saml-groups-attribute" class={labelClass()}>
Groups Attribute
</label>
<input
id="sso-saml-groups-attribute"
type="text"
value={form.groupsClaim}
onInput={(e) => setForm('groupsClaim', e.currentTarget.value)}
@@ -683,8 +722,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="mt-4 space-y-4 p-4 bg-surface-alt rounded-md">
<Show when={form.type === 'oidc'}>
<div class={formField}>
<label class={labelClass()}>Groups Claim</label>
<label for="sso-oidc-groups-claim" class={labelClass()}>
Groups Claim
</label>
<input
id="sso-oidc-groups-claim"
type="text"
value={form.groupsClaim}
onInput={(e) => setForm('groupsClaim', e.currentTarget.value)}
@@ -820,9 +820,10 @@ describe('APITokenManager', () => {
const renameButton = within(row).getByRole('button', { name: 'Rename' });
fireEvent.click(renameButton);
const input = await screen.findByRole('textbox', { name: 'Token name' });
const dialog = await screen.findByRole('dialog', { name: 'Rename API token' });
const input = within(dialog).getByRole('textbox', { name: 'Token name' });
fireEvent.input(input, { target: { value: 'PBS 01 telemetry' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(renameTokenMock).toHaveBeenCalledWith('token-pbs', 'PBS 01 telemetry');
@@ -128,10 +128,14 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
</div>
<div class="p-8 space-y-6 relative z-10">
<div>
<label class="block text-sm font-medium text-base-content mb-2">
<label
for="setup-security-username"
class="block text-sm font-medium text-base-content mb-2"
>
{t('setup.security.label.username')}
</label>
<input
id="setup-security-username"
type="text"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
@@ -141,9 +145,9 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-3">
<span class="block text-sm font-medium text-base-content mb-3">
{t('setup.security.label.password')}
</label>
</span>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<button
type="button"
@@ -173,6 +177,7 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
<div class="space-y-2">
<div class="relative">
<input
aria-label={t('setup.security.label.password')}
type={showPassword() ? 'text' : 'password'}
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
@@ -190,6 +195,7 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
</button>
</div>
<input
aria-label={t('setup.security.label.confirmPassword')}
type={showPassword() ? 'text' : 'password'}
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
@@ -26,6 +26,7 @@ export function ThresholdSlider(props: ThresholdSliderProps) {
{/* Native range input (invisible but functional) */}
<input
type="range"
aria-label={props.ariaLabel ?? `${props.type} threshold`}
min={props.min ?? 0}
max={props.max ?? 100}
value={props.value}
@@ -8,6 +8,7 @@ export interface ThresholdSliderProps {
min?: number;
max?: number;
disabled?: boolean;
ariaLabel?: string;
}
export const DEFAULT_THRESHOLD_SLIDER_MIN = 0;
@@ -247,9 +247,9 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP}</p>
</div>
<div class={`${formField} sm:col-span-2`}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<span class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TLS_LABEL}
</label>
</span>
<Show when={props.config.skipTlsVerify}>
<TlsVerificationWarningBanner
class="mb-3"