From d1d21abf7e498cd37573e8b76095c0d85964531c Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Sun, 28 Jun 2026 18:17:39 +0200 Subject: [PATCH 1/5] feat(frontend): reorganize config forms and add unit segmented controls (#17, #18) Schema-driven configuration forms previously presented every field as a flat list, with unit-of-measure fields rendered as free-text inputs (error-prone: "w", "KW", "kw") and entity/unit fields split far apart. This reworks ConfigSchemaForm so that, generically for all forms: - entity_ fields are paired with their unit_ counterpart on the same row, keeping the value next to its unit of measure (#17); - rows are grouped by domain (Power / Energy / Hash Rate) when at least two domains are present, preserving the schema declaration (chronological) order within each group (#17); - unit fields are rendered as a segmented control whose options are inferred from the field's value family (power/energy/hash rate), instead of a free-text input (#18). Field rendering is extracted into a reusable ConfigFieldControl component and the schema/unit helpers into core/utils/configSchema.ts. Field names and helper texts are intentionally left unchanged (a separate naming issue). --- CHANGELOG.md | 16 + .../src/components/ConfigFieldControl.vue | 246 +++++++++++ frontend/src/components/ConfigSchemaForm.vue | 415 +++++------------- frontend/src/core/utils/configSchema.ts | 167 +++++++ 4 files changed, 544 insertions(+), 300 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 frontend/src/components/ConfigFieldControl.vue create mode 100644 frontend/src/core/utils/configSchema.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cd614db7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Unit-of-measure fields in configuration forms are now selected through a segmented control (e.g. `W` / `kW` / `MW`, `Wh` / `kWh` / `MWh`, `GH/s` / `TH/s` / `PH/s`) instead of a free-text input, preventing inconsistent or invalid values. The available options are inferred automatically from each field, so the control applies to every configuration form (#18). + +### Changed + +- Reorganized configuration forms for readability: each entity field is now paired with its unit of measure on the same row, and fields are grouped by domain (Power / Energy) when at least two domains are present, preserving the chronological order within each group. Applied generically to all schema-driven configuration forms (#17). diff --git a/frontend/src/components/ConfigFieldControl.vue b/frontend/src/components/ConfigFieldControl.vue new file mode 100644 index 00000000..64d57379 --- /dev/null +++ b/frontend/src/components/ConfigFieldControl.vue @@ -0,0 +1,246 @@ + + + diff --git a/frontend/src/components/ConfigSchemaForm.vue b/frontend/src/components/ConfigSchemaForm.vue index 3e5c55b0..ae63199d 100644 --- a/frontend/src/components/ConfigSchemaForm.vue +++ b/frontend/src/components/ConfigSchemaForm.vue @@ -1,30 +1,14 @@ @@ -216,178 +174,35 @@ const onNestedEntityInput = (
-
-
- -
- {{ property.title || formatFieldName(String(fieldName)) }} - (required) - (optional) -
- - - + {{ group.label }} +
-
-
- {{ nestedProp.title || formatFieldName(String(nestedKey)) }} -
- - -
- sensor. - -
- - -
- - -
- - - - -
- {{ nestedProp.description }} -
- - -
- sensor. - -
- - -
- - -
- - - - - - - - -
- {{ property.description }} -
diff --git a/frontend/src/core/utils/configSchema.ts b/frontend/src/core/utils/configSchema.ts new file mode 100644 index 00000000..2399ac65 --- /dev/null +++ b/frontend/src/core/utils/configSchema.ts @@ -0,0 +1,167 @@ +/** + * Shared helpers for the dynamic configuration forms generated from a + * JSON schema (see ConfigSchemaForm.vue / ConfigFieldControl.vue). + */ + +export interface ConfigSchemaProperty { + type?: string; + title?: string; + description?: string; + default?: any; + $ref?: string; + anyOf?: any[]; + enum?: any[]; + properties?: Record; + minimum?: number; + maximum?: number; + required?: string[]; +} + +export interface ConfigSchema { + title?: string; + description?: string; + type?: string; + properties: Record; + required?: string[]; + $defs?: Record; +} + +export type FieldType = "enum" | "object" | "number" | "string" | "boolean" | "unknown"; + +// ── Schema resolution ─────────────────────────────────────────── + +export const resolveRef = (refStr: string, s: ConfigSchema): any => { + if (!refStr.startsWith("#/$defs/")) return null; + return s.$defs?.[refStr.replace("#/$defs/", "")] || null; +}; + +export const getPropertySchema = (property: any, s: ConfigSchema): any => { + if (property.$ref) return resolveRef(property.$ref, s); + if (property.anyOf) { + const nonNull = property.anyOf.find((i: any) => i.type !== "null"); + if (nonNull) { + if (nonNull.$ref) return resolveRef(nonNull.$ref, s); + return { ...property, ...nonNull, anyOf: undefined }; + } + } + return property; +}; + +export const isNullable = (property: any): boolean => + !!property.anyOf?.some((i: any) => i.type === "null"); + +export const getFieldType = (property: any, s: ConfigSchema): FieldType => { + const r = getPropertySchema(property, s); + if (r?.enum) return "enum"; + if (r?.type === "object" && r?.properties) return "object"; + if (r?.type === "integer" || r?.type === "number") return "number"; + if (r?.type === "string") return "string"; + if (r?.type === "boolean") return "boolean"; + return "unknown"; +}; + +export const initializeDefaultValue = (property: any, s: ConfigSchema): any => { + if (property.default !== undefined) return property.default; + const resolved = getPropertySchema(property, s); + if (resolved?.default !== undefined) return resolved.default; + if (isNullable(property)) return null; + if (resolved?.enum) return resolved.enum[0]; + if (resolved?.type === "object" && resolved?.properties) { + const obj: any = {}; + Object.entries(resolved.properties).forEach(([k, p]: [string, any]) => { + obj[k] = initializeDefaultValue(p, s); + }); + return obj; + } + if (resolved?.type === "string") return ""; + if (resolved?.type === "number" || resolved?.type === "integer") return 0; + if (resolved?.type === "boolean") return false; + return null; +}; + +// ── Field name helpers ────────────────────────────────────────── + +export const formatFieldName = (name: string): string => + name + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + +export const isPasswordField = (name: string): boolean => + String(name).toLowerCase().includes("password"); + +export const isEntityField = (name: string): boolean => + String(name).toLowerCase().includes("entity"); + +export const isUnitField = (name: string): boolean => + String(name).toLowerCase().includes("unit"); + +// ── Measurement unit registry (issue #18) ─────────────────────── +// +// Unit fields are rendered as a segmented control instead of a free-text +// input. The set of options is inferred from the field's value family, +// detected primarily from its current/default value (most reliable) and, +// as a fallback, from keywords in the field name. + +interface UnitFamily { + key: string; + /** Canonical, selectable options shown in the segmented control. */ + options: string[]; + /** Extra recognized values (used only to detect the family, not shown). */ + aliases: string[]; + /** Keywords in the field name that hint at this family. */ + keywords: string[]; +} + +const UNIT_FAMILIES: UnitFamily[] = [ + { key: "power", options: ["W", "kW", "MW"], aliases: ["GW"], keywords: ["power"] }, + { key: "energy", options: ["Wh", "kWh", "MWh"], aliases: ["GWh"], keywords: ["energy", "capacity"] }, + { + key: "hashrate", + options: ["GH/s", "TH/s", "PH/s"], + aliases: ["H/s", "KH/s", "MH/s", "EH/s"], + keywords: ["hash"], + }, +]; + +const familyFromValue = (value: unknown): UnitFamily | undefined => { + if (typeof value !== "string" || value === "") return undefined; + const v = value.toLowerCase(); + return UNIT_FAMILIES.find((f) => + [...f.options, ...f.aliases].some((u) => u.toLowerCase() === v) + ); +}; + +const familyFromName = (name: string): UnitFamily | undefined => { + const n = name.toLowerCase(); + return UNIT_FAMILIES.find((f) => f.keywords.some((k) => n.includes(k))); +}; + +/** + * Returns the list of selectable unit options for a unit field, or `null` + * if the field is not a unit field or its family cannot be determined + * (in which case the caller should fall back to a plain text input). + * + * The current value is always included so no previously-saved custom value + * is lost. + */ +export const getUnitOptions = ( + name: string, + property: ConfigSchemaProperty, + currentValue: unknown +): string[] | null => { + if (!isUnitField(name)) return null; + + const family = + familyFromValue(currentValue) ?? + familyFromValue(property.default) ?? + familyFromName(name); + + if (!family) return null; + + const options = [...family.options]; + if (typeof currentValue === "string" && currentValue && !options.includes(currentValue)) { + options.push(currentValue); + } + return options; +}; From 702b7be8db143eacf4e6ffe2d5bcb09d615ef1b3 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Sun, 28 Jun 2026 18:24:40 +0200 Subject: [PATCH 2/5] refactor(frontend): use shared config form for miner controllers (#17, #18) MinerControllerConfigForm had its own duplicated copy of the schema-driven form renderer, so the miner controller add/edit modal did not get the entity/unit pairing or the unit segmented controls. Home Assistant-based controllers (e.g. generic socket) therefore showed entities and their unit of measure as separate, unpaired free-text fields. Replace the duplicated implementation with a delegation to the shared ConfigSchemaForm (config-endpoint "miner-controllers"). sensorPrefix is left disabled because these controllers mix entity prefixes (switch., sensor.) and expect full entity ids. --- .../MinerControllerConfigForm.vue | 331 +----------------- 1 file changed, 9 insertions(+), 322 deletions(-) diff --git a/frontend/src/components/minerControllers/MinerControllerConfigForm.vue b/frontend/src/components/minerControllers/MinerControllerConfigForm.vue index ca3f4efa..2afc114a 100644 --- a/frontend/src/components/minerControllers/MinerControllerConfigForm.vue +++ b/frontend/src/components/minerControllers/MinerControllerConfigForm.vue @@ -1,331 +1,18 @@ From 10ebd6cf3006ce6aff55d2c90cdca92e37a2a061 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Sun, 28 Jun 2026 18:53:34 +0200 Subject: [PATCH 3/5] feat(frontend): compact layout for paired entity/unit config fields (#17) The Configuration section rendered each entity and its unit as two separate fields side by side; long titles and descriptions wrapped onto multiple lines and broke the vertical alignment between the entity input and its unit. Render the unit-of-measure segmented control inline as the entity field's addon, so each paired row keeps a single (entity) label and helper text, with the unit selector attached to the right of the input. This removes the redundant unit labels/descriptions and keeps rows aligned regardless of text length. ConfigFieldControl gains hideLabel / hideDescription props and an "addon" slot. Configuration modals are widened to max-w-3xl for more breathing room. --- .../src/components/ConfigFieldControl.vue | 274 ++++++++++-------- frontend/src/components/ConfigSchemaForm.vue | 37 +-- .../energyMonitors/EnergyMonitorFormModal.vue | 2 +- .../ForecastProviderFormModal.vue | 2 +- .../EnergyLoadForecastProviderFormModal.vue | 2 +- .../EnergyLoadHistoryProviderFormModal.vue | 2 +- .../MinerControllerFormModal.vue | 4 +- 7 files changed, 171 insertions(+), 152 deletions(-) diff --git a/frontend/src/components/ConfigFieldControl.vue b/frontend/src/components/ConfigFieldControl.vue index 64d57379..d59998d9 100644 --- a/frontend/src/components/ConfigFieldControl.vue +++ b/frontend/src/components/ConfigFieldControl.vue @@ -21,8 +21,12 @@ const props = withDefaults( required?: boolean; /** If true, string entity fields get a "sensor." prefix (Home Assistant). */ sensorPrefix?: boolean; + /** Hide the field label (used when rendered inline as another field's addon). */ + hideLabel?: boolean; + /** Hide the helper/description text. */ + hideDescription?: boolean; }>(), - { required: false, sensorPrefix: false } + { required: false, sensorPrefix: false, hideLabel: false, hideDescription: false } ); const value = defineModel(); @@ -42,6 +46,14 @@ const unitOptions = computed(() => { return getUnitOptions(props.name, props.property, value.value); }); +const showDescription = computed( + () => + !props.hideDescription && + !!props.property.description && + fieldType.value !== "boolean" && + fieldType.value !== "object" +); + // ── Sensor entity (Home Assistant "sensor." prefix) ───────────── const entityDisplayValue = computed(() => { @@ -77,169 +89,175 @@ const togglePassword = (key: string) => {