Config UI: add param value validation: host (BC) (#26149)

This commit is contained in:
Michael Geers 2026-01-19 09:58:13 +01:00 • committed by GitHub
parent d3d902ebd8
commit c0e9e4c913
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 157 additions and 0 deletions

View file

@ -15,6 +15,7 @@
:type="Type" :type="Type"
:unit="Unit" :unit="Unit"
:required="Required" :required="Required"
:pattern="Pattern"
:choice="Choice" :choice="Choice"
:service-values="serviceValues" :service-values="serviceValues"
:label="label" :label="label"
@ -41,6 +42,7 @@ export default {
Type: String, Type: String,
Unit: String, Unit: String,
Mask: Boolean, Mask: Boolean,
Pattern: { type: Object, default: () => ({}) },
Choice: Array, Choice: Array,
serviceValues: Array, serviceValues: Array,
modelValue: [String, Number, Boolean, Object], modelValue: [String, Number, Boolean, Object],

View file

@ -73,6 +73,8 @@
:step="step" :step="step"
:placeholder="placeholder" :placeholder="placeholder"
:required="required" :required="required"
:pattern="patternRegex"
:title="patternTitle"
:aria-describedby="unitValue ? id + '_unit' : null" :aria-describedby="unitValue ? id + '_unit' : null"
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${showClearButton ? 'has-clear-button' : ''} ${invalid ? 'is-invalid' : ''} ${endAlign ? 'text-end' : ''}`" :class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${showClearButton ? 'has-clear-button' : ''} ${invalid ? 'is-invalid' : ''} ${endAlign ? 'text-end' : ''}`"
:style=" :style="
@ -126,6 +128,7 @@ export default {
scale: Number, scale: Number,
required: Boolean, required: Boolean,
invalid: Boolean, invalid: Boolean,
pattern: { type: Object, default: () => ({}) },
choice: { type: Array, default: () => [] }, choice: { type: Array, default: () => [] },
modelValue: [String, Number, Boolean, Object], modelValue: [String, Number, Boolean, Object],
label: String, label: String,
@ -136,6 +139,14 @@ export default {
return { selectMode: false }; return { selectMode: false };
}, },
computed: { computed: {
patternRegex() {
return this.pattern.Regex || null;
},
patternTitle() {
const examples = this.pattern.Examples || [];
if (!examples.length) return null;
return examples.join(", ");
},
datalistId() { datalistId() {
return this.serviceValues.length > 0 ? `${this.id}-datalist` : null; return this.serviceValues.length > 0 ? `${this.id}-datalist` : null;
}, },

View file

@ -0,0 +1,11 @@
site:
title: Host Pattern Test
meters:
grid: grid
meters:
- name: grid
type: template
template: shelly-pro-3em
usage: grid
host: http://192.168.1.100

View file

@ -0,0 +1,53 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible } from "./utils";
const CONFIG_HOST_PATTERN = "config-host-pattern.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.describe("host pattern validation", async () => {
test.afterEach(async () => {
await stop();
});
test("show error for meter with invalid host pattern on startup", async ({ page }) => {
await start(CONFIG_HOST_PATTERN);
await page.goto("/#/config");
// verify fatal error is shown
await expect(page.getByTestId("fatal-error")).toBeVisible();
await expect(page.getByTestId("fatal-error")).toContainText("does not match required pattern");
});
test("reject URL with scheme in host field", async ({ page }) => {
await start();
await page.goto("/#/config");
const modal = page.getByTestId("meter-modal");
await page.getByRole("button", { name: "Add solar or battery" }).click();
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Add solar meter" }).click();
await modal.getByLabel("Title").fill("Test PV");
await page.waitForLoadState("networkidle");
await modal.getByLabel("Manufacturer").selectOption("Shelly Pro 3 EM");
const hostInput = modal.getByLabel("IP address or hostname");
await hostInput.fill("http://192.168.1.100");
// Check browser invalid state
const isValid = await hostInput.evaluate((el: HTMLInputElement) => el.checkValidity());
expect(isValid).toBe(false);
// Check validate status is still unknown (hasn't tried to validate yet)
const testResult = modal.getByTestId("test-result");
await expect(testResult).toContainText("Status: unknown");
// Manually delete the pattern attribute to bypass client validation
await hostInput.evaluate((el: HTMLInputElement) => el.removeAttribute("pattern"));
await testResult.getByRole("link", { name: "validate" }).click();
await expect(testResult).toContainText("Status: failed");
await expect(testResult).toContainText("does not match required pattern");
});
});

View file

@ -20,6 +20,9 @@ params:
de: IP-Adresse oder Hostname de: IP-Adresse oder Hostname
en: IP address or hostname en: IP address or hostname
example: 192.0.2.2 example: 192.0.2.2
pattern:
regex: "^[^\\/\\s]+(:[0-9]{1,5})?$" # any char except slash/space, optional :port
examples: ["192.168.1.100", "example.com", "server.local:8080"]
- name: ip - name: ip
description: description:
de: IP-Adresse de: IP-Adresse

View file

@ -119,6 +119,15 @@ func (t *Template) Validate() error {
} }
} }
} }
// validate pattern examples against pattern
if p.Pattern != nil && p.Pattern.Regex != "" && len(p.Pattern.Examples) > 0 {
for _, example := range p.Pattern.Examples {
if err := p.Pattern.Validate(example); err != nil {
return fmt.Errorf("param %s: pattern example %q is invalid: pattern=%q", p.Name, example, p.Pattern.Regex)
}
}
}
} }
return nil return nil
@ -371,6 +380,13 @@ func (t *Template) RenderResult(renderMode int, other map[string]any) ([]byte, m
} }
} }
// validate pattern if defined
if s != "" && p.Pattern != nil && p.Pattern.Regex != "" {
if err := p.Pattern.Validate(s); err != nil {
return nil, nil, fmt.Errorf("%s: %w", p.Name, err)
}
}
res[out] = s res[out] = s
} }
} }

View file

@ -133,3 +133,35 @@ func TestRequiredPerUsage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
} }
func TestValidatePattern(t *testing.T) {
tmpl := &Template{
TemplateDefinition: TemplateDefinition{
Params: []Param{{Name: "host", Pattern: &Pattern{Regex: `^[^\\/\s]+(:[0-9]{1,5})?$`}}},
},
}
tests := []struct {
host string
valid bool
}{
{"192.168.1.100", true},
{"192.168.1.100:8080", true},
{"example.com", true},
{"http://192.168.1.100", false},
{"192.168.1.100/admin", false},
{"192.168.1.100 ", false},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
_, _, err := tmpl.RenderResult(RenderModeInstance, map[string]any{"host": tt.host})
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), "does not match required pattern")
}
})
}
}

View file

@ -3,6 +3,7 @@ package templates
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"regexp" "regexp"
"slices" "slices"
@ -79,6 +80,33 @@ var predefinedTemplateProperties = slices.Concat(
[]string{"type", "template", "name"}, ModbusParams, ModbusConnectionTypes, []string{"type", "template", "name"}, ModbusParams, ModbusConnectionTypes,
) )
// Pattern contains regex pattern and examples for input validation
type Pattern struct {
Regex string `json:",omitempty"`
Examples []string `json:",omitempty"`
}
// Validate checks if a value matches the pattern and returns a descriptive error if not
func (p *Pattern) Validate(value string) error {
if p.Regex == "" {
return nil
}
matched, err := regexp.MatchString(p.Regex, value)
if err != nil {
return fmt.Errorf("invalid regex pattern: %w", err)
}
if matched {
return nil
}
errMsg := fmt.Sprintf("value %q does not match required pattern", value)
if len(p.Examples) > 0 {
errMsg += fmt.Sprintf(". Valid examples: %s", strings.Join(p.Examples, ", "))
}
return errors.New(errMsg)
}
// TextLanguage contains language-specific texts // TextLanguage contains language-specific texts
type TextLanguage struct { type TextLanguage struct {
Generic string `json:",omitempty"` // language independent Generic string `json:",omitempty"` // language independent
@ -191,6 +219,7 @@ type Param struct {
Type ParamType // string representation of the value type, "string" is default Type ParamType // string representation of the value type, "string" is default
Choice []string `json:",omitempty"` // defines a set of choices, e.g. "grid", "pv", "battery", "charge" for "usage" Choice []string `json:",omitempty"` // defines a set of choices, e.g. "grid", "pv", "battery", "charge" for "usage"
Service string `json:",omitempty"` // defines a service to provide choices Service string `json:",omitempty"` // defines a service to provide choices
Pattern *Pattern `json:",omitempty"` // regex pattern and examples for input validation
// TODO move somewhere else should not be part of the param definition // TODO move somewhere else should not be part of the param definition
Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate