diff --git a/assets/css/app.css b/assets/css/app.css index b2c0f2763..89e32828c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -241,6 +241,13 @@ a:hover { opacity: 0.3; } +.btn-succeeded:disabled { + color: var(--bs-primary) !important; + background-color: transparent !important; + border-color: var(--bs-primary) !important; + opacity: 1 !important; +} + .dark .btn-disabled { opacity: 0.4; } diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue index 6b5cc176d..9d5586def 100644 --- a/assets/js/components/Config/ChargerModal.vue +++ b/assets/js/components/Config/ChargerModal.vue @@ -80,8 +80,10 @@ :is-deletable="isDeletable" :test-state="test" :is-saving="saving" + :is-succeeded="succeeded" + :is-new="isNew" :sponsor-token-required="sponsorTokenRequired" - @save="isNew ? create() : update()" + @save="(force) => (isNew ? create(force) : update(force))" @remove="remove" @test="testManually" /> @@ -102,6 +104,7 @@ import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue"; import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue"; import YamlEntry from "./DeviceModal/YamlEntry.vue"; import { initialTestState, performTest } from "./utils/test"; +import sleep from "@/utils/sleep"; import { ConfigType } from "@/types/evcc"; import { handleError, @@ -126,10 +129,6 @@ import { LOADPOINT_TYPE, type LoadpointType } from "@/types/evcc"; const initialValues = { type: ConfigType.Template }; const device = createDeviceUtils("charger"); -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - const CUSTOM_FIELDS = ["modbus"]; type Requirements = { @@ -178,6 +177,7 @@ export default defineComponent({ templateName: null as string | null, template: null as Template | null, saving: false, + succeeded: false, loadingTemplate: false, values: { ...initialValues } as ChargerDeviceValues, test: initialTestState(), @@ -332,6 +332,7 @@ export default defineComponent({ this.templateName = null; this.reset(); this.test = initialTestState(); + this.succeeded = false; this.loadProducts(); if (this.id !== undefined) { this.loadConfiguration(); @@ -406,30 +407,34 @@ export default defineComponent({ } this.loadingTemplate = false; }, - async create() { + async create(force = false) { // persist selected template product if (this.template && this.$refs["templateSelect"]) { this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName(); } - if (this.test.isUnknown) { + if (this.test.isUnknown && !force) { const success = await performTest( this.test, this.testCharger, this.$refs["form"] as HTMLFormElement ); - if (!success) return; - await sleep(100); + if (!success) { + return; + } } this.saving = true; try { - const { name } = await device.create(this.apiData); + const { name } = await device.create(this.apiData, force); + this.saving = false; + this.succeeded = true; + await sleep(1000); this.$emit("added", name); (this.$refs["modal"] as any).close(); } catch (e) { handleError(e, "create failed"); + this.saving = false; } - this.saving = false; }, async testManually() { await performTest(this.test, this.testCharger, this.$refs["form"] as HTMLFormElement); @@ -437,25 +442,29 @@ export default defineComponent({ async testCharger() { return device.test(this.id, this.apiData); }, - async update() { - if (this.test.isUnknown) { + async update(force = false) { + if (this.test.isUnknown && !force) { const success = await performTest( this.test, this.testCharger, this.$refs["form"] as HTMLFormElement ); - if (!success) return; - await sleep(250); + if (!success) { + return; + } } this.saving = true; try { - await device.update(this.id as number, this.apiData); + await device.update(this.id as number, this.apiData, force); + this.saving = false; + this.succeeded = true; + await sleep(1000); this.$emit("updated"); (this.$refs["modal"] as any).close(); } catch (e) { handleError(e, "update failed"); + this.saving = false; } - this.saving = false; }, async remove() { try { diff --git a/assets/js/components/Config/DeviceModal/Actions.vue b/assets/js/components/Config/DeviceModal/Actions.vue index bd30ebb64..6310882f8 100644 --- a/assets/js/components/Config/DeviceModal/Actions.vue +++ b/assets/js/components/Config/DeviceModal/Actions.vue @@ -28,10 +28,10 @@ @@ -67,8 +64,31 @@ export default defineComponent({ default: () => {}, }, isSaving: Boolean as PropType, + isSucceeded: Boolean as PropType, + isNew: Boolean as PropType, sponsorTokenRequired: Boolean as PropType, }, emits: ["save", "remove", "test"], + computed: { + saveButtonLabel(): string { + const { isError, isUnknown, isRunning } = this.testState; + if (isRunning) return this.$t("config.validation.running"); + if (this.isSaving) return this.$t("config.general.save"); + if (isError) return this.$t("config.general.forceSave"); + if (isUnknown) return this.$t("config.general.validateSave"); + return this.$t("config.general.save"); + }, + buttonClass(): string { + if (this.isSucceeded) return "btn btn-succeeded"; + if (this.testState.isError) return "btn btn-danger"; + return "btn btn-primary"; + }, + }, + methods: { + handleSave(): void { + const force = this.testState.isError; + this.$emit("save", force); + }, + }, }); diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index c1227a4cc..755ab7068 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -85,8 +85,9 @@ export function createDeviceUtils(deviceType: DeviceType) { return api.post(url, data, { timeout }); } - function update(id: number, data: any) { - return api.put(`config/devices/${deviceType}/${id}`, data); + function update(id: number, data: any, force = false) { + const params = { force }; + return api.put(`config/devices/${deviceType}/${id}`, data, { params }); } function remove(id: number) { @@ -98,8 +99,9 @@ export function createDeviceUtils(deviceType: DeviceType) { return response.data; } - async function create(data: any) { - const response = await api.post(`config/devices/${deviceType}`, data); + async function create(data: any, force = false) { + const params = { force }; + const response = await api.post(`config/devices/${deviceType}`, data, { params }); return response.data; } diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue index 6ad29e962..0a048d709 100644 --- a/assets/js/components/Config/MeterModal.vue +++ b/assets/js/components/Config/MeterModal.vue @@ -113,7 +113,9 @@ :is-deletable="isDeletable" :test-state="test" :is-saving="saving" - @save="isNew ? create() : update()" + :is-succeeded="succeeded" + :is-new="isNew" + @save="(force) => (isNew ? create(force) : update(force))" @remove="remove" @test="testManually" /> @@ -137,6 +139,7 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe import YamlEntry from "./DeviceModal/YamlEntry.vue"; import { ICONS } from "../VehicleIcon/VehicleIcon.vue"; import { initialTestState, performTest } from "./utils/test"; +import sleep from "@/utils/sleep"; import { ConfigType, type SelectedMeterType } from "@/types/evcc"; import { handleError, @@ -154,10 +157,6 @@ import defaultYaml from "./defaultYaml/meter.yaml?raw"; const initialValues = { type: ConfigType.Template, deviceTitle: "", deviceIcon: "" }; const device = createDeviceUtils("meter"); -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - const CUSTOM_FIELDS = ["usage", "modbus"]; const defaultIcons: Record = { @@ -215,6 +214,7 @@ export default defineComponent({ templateName: null as string | null, template: null as Template | null, saving: false, + succeeded: false, selectedType: null as string | null, loadingTemplate: false, iconChoices: ICONS, @@ -335,6 +335,7 @@ export default defineComponent({ this.selectedType = null; this.reset(); this.test = initialTestState(); + this.succeeded = false; this.loadProducts(); if (this.id !== undefined) { this.loadConfiguration(); @@ -402,30 +403,35 @@ export default defineComponent({ } this.loadingTemplate = false; }, - async create() { - // persist selected template product - if (this.template && this.$refs["templateSelect"]) { - this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName(); - } - - if (this.test.isUnknown) { + async create(force = false) { + if (this.test.isUnknown && !force) { const success = await performTest( this.test, this.testMeter, this.$refs["form"] as HTMLFormElement ); - if (!success) return; - await sleep(100); + if (!success) { + return; + } } + + // persist selected template product + if (this.template && this.$refs["templateSelect"]) { + this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName(); + } + this.saving = true; try { - const { name } = await device.create(this.apiData); + const { name } = await device.create(this.apiData, force); + this.saving = false; + this.succeeded = true; + await sleep(1000); this.$emit("added", this.meterType, name); (this.$refs["modal"] as any).close(); } catch (e) { handleError(e, "create failed"); + this.saving = false; } - this.saving = false; }, async testManually() { await performTest(this.test, this.testMeter, this.$refs["form"] as HTMLFormElement); @@ -433,25 +439,29 @@ export default defineComponent({ async testMeter() { return device.test(this.id, this.apiData); }, - async update() { - if (this.test.isUnknown) { + async update(force = false) { + if (this.test.isUnknown && !force) { const success = await performTest( this.test, this.testMeter, this.$refs["form"] as HTMLFormElement ); - if (!success) return; - await sleep(250); + if (!success) { + return; + } } this.saving = true; try { - await device.update(this.id as number, this.apiData); + await device.update(this.id as number, this.apiData, force); + this.saving = false; + this.succeeded = true; + await sleep(1000); this.$emit("updated"); (this.$refs["modal"] as any).close(); } catch (e) { handleError(e, "update failed"); + this.saving = false; } - this.saving = false; }, async remove() { try { diff --git a/assets/js/components/Config/TestResult.vue b/assets/js/components/Config/TestResult.vue index 162d27ca1..09b2c0cc8 100644 --- a/assets/js/components/Config/TestResult.vue +++ b/assets/js/components/Config/TestResult.vue @@ -5,10 +5,10 @@ {{ $t("config.validation.label") }}: {{ $t("config.validation.unknown") }} {{ $t("config.validation.running") }} - + {{ $t("config.validation.success") }} - + {{ $t("config.validation.failed") }} @@ -27,11 +27,11 @@

-
+
{{ error }}

-
+
diff --git a/assets/js/components/Config/VehicleModal.vue b/assets/js/components/Config/VehicleModal.vue index f0172ad21..1b69291ef 100644 --- a/assets/js/components/Config/VehicleModal.vue +++ b/assets/js/components/Config/VehicleModal.vue @@ -165,7 +165,9 @@ :is-deletable="isDeletable" :test-state="test" :is-saving="saving" - @save="isNew ? create() : update()" + :is-succeeded="succeeded" + :is-new="isNew" + @save="(force) => (isNew ? create(force) : update(force))" @remove="remove" @test="testManually" /> @@ -186,6 +188,7 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe import DeviceModalActions from "./DeviceModal/Actions.vue"; import YamlEntry from "./DeviceModal/YamlEntry.vue"; import { initialTestState, performTest } from "./utils/test"; +import sleep from "@/utils/sleep"; import { ConfigType } from "@/types/evcc"; import { handleError, @@ -200,10 +203,6 @@ import defaultYaml from "./defaultYaml/vehicle.yaml?raw"; const initialValues = { type: ConfigType.Template, icon: "car" }; const device = createDeviceUtils("vehicle"); -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - const CUSTOM_FIELDS = ["minCurrent", "maxCurrent", "priority", "identifiers", "phases", "mode"]; type VehicleDeviceValues = DeviceValues & { @@ -241,6 +240,7 @@ export default defineComponent({ templateName: null as string | null, template: null as Template | null, saving: false, + succeeded: false, loadingTemplate: false, values: { ...initialValues } as VehicleDeviceValues, test: initialTestState(), @@ -356,6 +356,7 @@ export default defineComponent({ this.reset(); this.templateName = "offline"; this.test = initialTestState(); + this.succeeded = false; this.loadProducts(); if (this.id !== undefined) { this.loadConfiguration(); @@ -413,25 +414,29 @@ export default defineComponent({ } this.loadingTemplate = false; }, - async create() { + async create(force = false) { // persist selected template product if (this.template && this.$refs["templateSelect"]) { this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName(); } - if (this.test.isUnknown) { + if (this.test.isUnknown && !force) { const success = await performTest(this.test, this.testVehicle, this.$refs["form"]); - if (!success) return; - await sleep(100); + if (!success) { + return; + } } this.saving = true; try { - const { name } = await device.create(this.apiData); + const { name } = await device.create(this.apiData, force); + this.saving = false; + this.succeeded = true; + await sleep(1000); this.$emit("vehicle-changed", name); this.closed(); } catch (e) { handleError(e, "create failed"); + this.saving = false; } - this.saving = false; }, async testManually() { await performTest(this.test, this.testVehicle, this.$refs["form"]); @@ -439,21 +444,25 @@ export default defineComponent({ async testVehicle() { return device.test(this.id, this.apiData); }, - async update() { - if (this.test.isUnknown) { + async update(force = false) { + if (this.test.isUnknown && !force) { const success = await performTest(this.test, this.testVehicle, this.$refs["form"]); - if (!success) return; - await sleep(250); + if (!success) { + return; + } } this.saving = true; try { - await device.update(this.id as number, this.apiData); + await device.update(this.id as number, this.apiData, force); + this.saving = false; + this.succeeded = true; + await sleep(1000); this.$emit("vehicle-changed"); this.closed(); } catch (e) { handleError(e, "update failed"); + this.saving = false; } - this.saving = false; }, async remove() { try { diff --git a/assets/js/components/Config/utils/test.ts b/assets/js/components/Config/utils/test.ts index 045297f26..7a0e1723c 100644 --- a/assets/js/components/Config/utils/test.ts +++ b/assets/js/components/Config/utils/test.ts @@ -1,4 +1,5 @@ import type { AxiosResponse } from "axios"; +import sleep from "@/utils/sleep"; export type TestState = { isUnknown: boolean; @@ -20,6 +21,8 @@ export const initialTestState = (): TestState => ({ errorLine: null, }); +const MIN_TEST_DURATION = 500; + export const performTest = async ( state: TestState, api: () => Promise>, @@ -28,12 +31,13 @@ export const performTest = async ( if (form && !(form as HTMLFormElement).reportValidity()) return false; state.isUnknown = false; state.isSuccess = false; - state.isError = false; state.isRunning = true; - state.error = null; - state.errorLine = null; + const startTime = Date.now(); try { const res = await api(); + state.isError = false; + state.error = null; + state.errorLine = null; for (const [key, value] of Object.entries(res.data)) { const { error } = value as { error?: string }; if (error) { @@ -50,6 +54,11 @@ export const performTest = async ( state.error = e.response?.data?.error || e.message; state.errorLine = e.response?.data?.line || null; } finally { + const elapsed = Date.now() - startTime; + const remainingTime = MIN_TEST_DURATION - elapsed; + if (remainingTime > 0) { + await sleep(remainingTime); + } state.isRunning = false; } return false; diff --git a/assets/js/utils/sleep.ts b/assets/js/utils/sleep.ts new file mode 100644 index 000000000..1fc86a2c4 --- /dev/null +++ b/assets/js/utils/sleep.ts @@ -0,0 +1 @@ +export default (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/i18n/de.json b/i18n/de.json index 32f21013e..8a84364cb 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -165,6 +165,7 @@ "dragItem": "Verschiebbar: {title}", "dragList": "Sortierbare Liste", "experimental": "Experimentell", + "forceSave": "Trotzdem speichern", "hideAdvancedSettings": "Erweiterte Einstellungen ausblenden", "invalidFileSelected": "Ungültige Datei ausgewählt", "noFileSelected": "Keine Datei ausgewählt.", @@ -176,6 +177,7 @@ "required": "erforderlich", "reset": "Zurücksetzen", "save": "Speichern", + "saved": "Gespeichert.", "selectFile": "Durchsuchen", "showAdvancedSettings": "Erweiterte Einstellungen anzeigen", "telemetry": "Telemetrie", diff --git a/i18n/en.json b/i18n/en.json index 708c08914..d987a4b5e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -165,6 +165,7 @@ "dragItem": "Draggable: {title}", "dragList": "Reorderable list", "experimental": "Experimental", + "forceSave": "Save anyway", "hideAdvancedSettings": "Hide advanced settings", "invalidFileSelected": "Invalid file selected", "noFileSelected": "No file selected.", @@ -176,6 +177,7 @@ "required": "required", "reset": "Reset", "save": "Save", + "saved": "Saved.", "selectFile": "Browse", "showAdvancedSettings": "Show advanced settings", "telemetry": "Telemetry", diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index ea08bb163..e131d6970 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -254,9 +254,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) { jsonWrite(w, testInstance(instance)) } -func newDevice[T any](ctx context.Context, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (*config.Config, error) { +func newDevice[T any](ctx context.Context, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) (*config.Config, error) { instance, err := newFromConf(ctx, req.Type, req.Other) - if err != nil { + if err != nil && !force { return nil, err } @@ -287,20 +287,22 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) { var conf *config.Config ctx, cancel, done := startDeviceTimeout() + force := r.URL.Query().Get("force") == "true" + switch class { case templates.Charger: - conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers()) + conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers(), force) case templates.Meter: - conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters()) + conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters(), force) case templates.Vehicle: - conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles()) + conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force) case templates.Circuit: conf, err = newDevice(ctx, class, req, func(ctx context.Context, _ string, other map[string]interface{}) (api.Circuit, error) { return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other) - }, config.Circuits()) + }, config.Circuits(), force) } if err != nil { @@ -325,10 +327,13 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) { jsonWrite(w, res) } -func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) error { +func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) error { dev, instance, merged, err := deviceInstanceFromMergedConfig(ctx, id, class, req, newFromConf, h) if err != nil { - return err + // allow force-updating if merged config exists + if !force || merged == nil { + return err + } } configurable, ok := dev.(config.ConfigurableDevice[T]) @@ -363,20 +368,22 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel, done := startDeviceTimeout() + force := r.URL.Query().Get("force") == "true" + switch class { case templates.Charger: - err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers()) + err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers(), force) case templates.Meter: - err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters()) + err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters(), force) case templates.Vehicle: - err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles()) + err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force) case templates.Circuit: err = updateDevice(ctx, id, class, req, func(ctx context.Context, _ string, other map[string]interface{}) (api.Circuit, error) { return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other) - }, config.Circuits()) + }, config.Circuits(), force) } setConfigDirty() diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 21e591725..33f658913 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -91,10 +91,12 @@ func jsonError(w http.ResponseWriter, status int, err error) { w.WriteHeader(status) res := struct { - Error string `json:"error"` - Line int `json:"line,omitempty"` + Error string `json:"error"` + Line int `json:"line,omitempty"` + IsAuthError bool `json:"isAuthError,omitempty"` }{ - Error: err.Error(), + Error: err.Error(), + IsAuthError: errors.Is(err, api.ErrLoginRequired) || errors.Is(err, api.ErrMissingToken), } var ( diff --git a/tests/config-pv.spec.ts b/tests/config-pv.spec.ts index d25925527..6da782c0d 100644 --- a/tests/config-pv.spec.ts +++ b/tests/config-pv.spec.ts @@ -70,4 +70,38 @@ test.describe("pv meter", async () => { await page.reload(); await expect(page.getByTestId("pv")).toHaveCount(0); }); + + test("create broken pv meter with validation failure", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page, false); + + await expect(page.getByTestId("pv")).toHaveCount(0); + + // create broken meter + await page.getByRole("button", { name: "Add solar or battery" }).click(); + + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByRole("button", { name: "Add solar meter" }).click(); + await meterModal.getByLabel("Title").fill("Broken PV"); + await meterModal.getByLabel("Manufacturer").selectOption("SunSpec Inverter"); + await meterModal.getByLabel("IP address or hostname").fill("0.0.0.0"); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); + + // wait for validation to complete and check failure + const testResult = meterModal.getByTestId("test-result"); + await expect(testResult).toContainText("Status: failed"); + await expect(testResult).toContainText("connection refused"); + + // verify "Save anyway" button is now visible + await expect(meterModal.getByRole("button", { name: "Save anyway" })).toBeVisible(); + + // save anyway + await meterModal.getByRole("button", { name: "Save anyway" }).click(); + await expectModalHidden(meterModal); + + // verify broken meter is visible in list + await expect(page.getByTestId("pv")).toBeVisible(); + await expect(page.getByTestId("pv")).toContainText("Broken PV"); + }); });