mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Make configuration forms programmatically labeled
Change-source: pulse-maintainer
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user