+
{{ $t("config.section.system") }}
-
+
{{ $t("config.system.logs") }}
+
@@ -367,6 +373,7 @@
+
@@ -412,6 +419,7 @@ import TariffsModal from "../components/Config/TariffsModal.vue";
import Header from "../components/Top/Header.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
+import BackupRestoreModal from "@/components/Config/BackupRestoreModal.vue";
import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
import ExperimentalBanner from "../components/Config/ExperimentalBanner.vue";
@@ -419,6 +427,7 @@ export default {
name: "Config",
components: {
NewDeviceButton,
+ BackupRestoreModal,
ChargerModal,
CircuitsIcon,
CircuitsModal,
diff --git a/cmd/setup.go b/cmd/setup.go
index 13a08307c..e6b3e5e3a 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -22,6 +22,7 @@ import (
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/core/session"
coresettings "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/hems"
"github.com/evcc-io/evcc/meter"
@@ -573,6 +574,10 @@ func configureDatabase(conf globalconfig.DB) error {
return err
}
+ if err := session.Init(); err != nil {
+ return err
+ }
+
if err := settings.Init(); err != nil {
return err
}
diff --git a/core/session/db.go b/core/session/db.go
index 57ad1f308..b1d785bdd 100644
--- a/core/session/db.go
+++ b/core/session/db.go
@@ -1,6 +1,7 @@
package session
import (
+ "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
"gorm.io/gorm"
)
@@ -12,6 +13,18 @@ type DB struct {
name string
}
+var (
+ sessions Sessions
+)
+
+func Init() error {
+ err := db.Instance.AutoMigrate(new(Session))
+ if err == nil {
+ err = db.Instance.Find(&sessions).Error
+ }
+ return err
+}
+
// NewStore creates a session store
func NewStore(name string, db *gorm.DB) (*DB, error) {
err := db.AutoMigrate(new(Session))
diff --git a/i18n/de.json b/i18n/de.json
index 3295fd9c3..463134c2f 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -155,12 +155,15 @@
"docsLink": "Siehe Dokumentation.",
"experimental": "Experimentell",
"hideAdvancedSettings": "Erweiterte Einstellungen ausblenden",
+ "invalidFileSelected": "Ungültige Datei ausgewählt",
+ "noFileSelected": "Keine Datei ausgewählt.",
"off": "aus",
"on": "an",
"password": "Passwort",
"readFromFile": "Aus Datei lesen",
"remove": "Entfernen",
"save": "Speichern",
+ "selectFile": "Durchsuchen...",
"showAdvancedSettings": "Erweiterte Einstellungen anzeigen",
"telemetry": "Telemetrie",
"templateLoading": "Lade …",
@@ -435,6 +438,39 @@
"trialToken": "Testtoken"
},
"system": {
+ "backupRestore": {
+ "backup": {
+ "action": "Sicherung herunterladen...",
+ "confirmationButton": "Sicherung herunterladen",
+ "confirmationText": "Bitte gib dein Passwort ein, um die Datenbankdatei herunterzuladen.",
+ "description": "Sichere deine Daten in eine Datei. Diese Datei kann verwendet werden, um deine Daten im Falle eines Systemausfalls wiederherzustellen.",
+ "title": "Sichern"
+ },
+ "cancel": "Abbrechen",
+ "confirmWithPassword": "Aktion bestätigen",
+ "description": "Sichern, wiederherstellen und zurücksetzen deiner Daten. Nützlich, wenn du deine Daten auf ein anderes System übertragen möchtest.",
+ "note": "Hinweis: Alle oben genannten Aktionen betreffen nur deine Datenbankdaten. Die evcc.yaml-Konfigurationsdatei bleibt unverändert.",
+ "reset": {
+ "action": "Zurücksetzen...",
+ "confirmationButton": "Zurücksetzen & Neustart",
+ "confirmationText": "Dies löscht deine ausgewählten Daten dauerhaft. Stelle sicher, dass du zuerst eine Sicherung heruntergeladen hast.",
+ "description": "Hast du Probleme mit der Konfiguration und möchtest von vorne beginnen? Lösche alle Daten und beginne neu.",
+ "sessions": "Ladevorgänge",
+ "sessionsDescription": "Löscht den Verlauf deiner Ladevorgänge.",
+ "settings": "Konfiguration & Einstellungen",
+ "settingsDescription": "Löscht alle konfigurierten Geräte, Dienste, Pläne, usw.",
+ "title": "Zurücksetzen"
+ },
+ "restore": {
+ "action": "Wiederherstellen...",
+ "confirmationButton": "Wiederherstellen & Neustart",
+ "confirmationText": "Dies überschreibt deine komplette Datenbank. Stelle sicher, dass du zuerst eine Sicherung heruntergeladen hast.",
+ "description": "Stelle deine Daten aus einer Sicherungsdatei wieder her. Dies überschreibt alle deine aktuellen Daten.",
+ "labelFile": "Sicherungsdatei",
+ "title": "Wiederherstellen"
+ },
+ "title": "Sichern & Wiederherstellen"
+ },
"logs": "Logs",
"restart": "Neu starten",
"restartRequiredDescription": "Bitte neu starten, um die Änderungen zu übernehmen.",
diff --git a/i18n/en.json b/i18n/en.json
index 25101c059..85dbdba44 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -155,12 +155,15 @@
"docsLink": "See documentation.",
"experimental": "Experimental",
"hideAdvancedSettings": "Hide advanced settings",
+ "invalidFileSelected": "Invalid file selected",
+ "noFileSelected": "No file selected.",
"off": "off",
"on": "on",
"password": "Password",
"readFromFile": "Read from file",
"remove": "Remove",
"save": "Save",
+ "selectFile": "Browse",
"showAdvancedSettings": "Show advanced settings",
"telemetry": "Telemetry",
"templateLoading": "Loading...",
@@ -435,6 +438,39 @@
"trialToken": "Trial token"
},
"system": {
+ "backupRestore": {
+ "backup": {
+ "action": "Download backup...",
+ "confirmationButton": "Download backup",
+ "confirmationText": "Please enter your password to download the database file.",
+ "description": "Backup your data to a file. This file can be used to restore your data in case of a system failure.",
+ "title": "Backup"
+ },
+ "cancel": "Cancel",
+ "confirmWithPassword": "Confirm action",
+ "description": "Backup, restore and reset your data. Useful if you want to move your data to another system.",
+ "note": "Note: All above actions only affect your database data. The evcc.yaml configuration file remains unchanged.",
+ "reset": {
+ "action": "Reset...",
+ "confirmationButton": "Reset & restart",
+ "confirmationText": "This will permanently delete your selected data. Ensure you've downloaded a backup first.",
+ "description": "Having problems with configuration and want to start over? Delete all data and start fresh.",
+ "sessions": "Charging sessions",
+ "sessionsDescription": "Deletes your charging session history.",
+ "settings": "Configuration & settings",
+ "settingsDescription": "Deletes all configured devices, services, plans, etc.",
+ "title": "Reset"
+ },
+ "restore": {
+ "action": "Restore...",
+ "confirmationButton": "Restore & restart",
+ "confirmationText": "This will overwrite your complete database. Ensure you've downloaded a backup first.",
+ "description": "Restore your data from a backup file. This will overwrite all your current data.",
+ "labelFile": "Backup file",
+ "title": "Restore"
+ },
+ "title": "Backup & Restore"
+ },
"logs": "Logs",
"restart": "Restart",
"restartRequiredDescription": "Please restart to see the effect.",
diff --git a/server/db/db.go b/server/db/db.go
index f67d6cb44..b5ebf7b63 100644
--- a/server/db/db.go
+++ b/server/db/db.go
@@ -12,7 +12,10 @@ import (
"gorm.io/gorm"
)
-var Instance *gorm.DB
+var (
+ Instance *gorm.DB
+ FilePath string // Store the actual SQLite file path
+)
func New(driver, dsn string) (*gorm.DB, error) {
var dialect gorm.Dialector
@@ -24,6 +27,10 @@ func New(driver, dsn string) (*gorm.DB, error) {
if err != nil {
return nil, err
}
+
+ // Store the expanded file path for later use
+ FilePath = file
+
log.INFO.Println("using sqlite database:", file)
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
return nil, err
@@ -47,3 +54,11 @@ func NewInstance(driver, dsn string) (err error) {
Instance, err = New(strings.ToLower(driver), dsn)
return
}
+
+func Close() error {
+ db, err := Instance.DB()
+ if err != nil {
+ return err
+ }
+ return db.Close()
+}
diff --git a/server/http.go b/server/http.go
index 161fa3ed2..7816d5e0d 100644
--- a/server/http.go
+++ b/server/http.go
@@ -345,9 +345,12 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
// system api
routes := map[string]route{
- "log": {"GET", "/log", logHandler},
- "logareas": {"GET", "/log/areas", logAreasHandler},
- "reset": {"POST", "/reset", resetHandler},
+ "log": {"GET", "/log", logHandler},
+ "logareas": {"GET", "/log/areas", logAreasHandler},
+ "clearcache": {"DELETE", "/cache", clearCacheHandler},
+ "backup": {"POST", "/backup", getBackup(auth)},
+ "restore": {"POST", "/restore", restoreDatabase(auth, shutdown)},
+ "reset": {"POST", "/reset", resetDatabase(auth, shutdown)},
"shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) {
shutdown()
w.WriteHeader(http.StatusNoContent)
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 18f285447..e9ad4fdef 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -4,8 +4,10 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"io/fs"
"net/http"
+ "os"
"strconv"
"strings"
"text/template"
@@ -15,7 +17,10 @@ import (
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
+ "github.com/evcc-io/evcc/server/db"
+ "github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/encode"
"github.com/evcc-io/evcc/util/jq"
"github.com/evcc-io/evcc/util/logstash"
@@ -316,7 +321,7 @@ func logAreasHandler(w http.ResponseWriter, r *http.Request) {
jsonResult(w, logstash.Areas())
}
-func resetHandler(w http.ResponseWriter, r *http.Request) {
+func clearCacheHandler(w http.ResponseWriter, r *http.Request) {
util.ResetCached()
jsonResult(w, "OK")
}
@@ -348,3 +353,172 @@ func logHandler(w http.ResponseWriter, r *http.Request) {
jsonResult(w, log)
}
+
+func getBackup(authObject auth.Auth) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req loginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if authObject.GetAuthMode() == auth.Enabled && !authObject.IsAdminPasswordValid(req.Password) {
+ http.Error(w, "Invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ settings.Persist()
+
+ f, err := os.Open(db.FilePath)
+ if err != nil {
+ http.Error(w, "Could not open DB file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer f.Close()
+
+ filename := "evcc-backup-" + time.Now().Format("2006-01-02--15-04") + ".db"
+
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
+
+ if _, err := io.Copy(w, f); err != nil {
+ http.Error(w, "Error streaming DB file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+}
+
+// createLocalDatabaseBackup creates a local backup in case of catastrophic error in reset or restore
+func createLocalDatabaseBackup() error {
+ backupPath := db.FilePath + ".bak"
+
+ src, err := os.Open(db.FilePath)
+ if err != nil {
+ return fmt.Errorf("failed to open database file: %w", err)
+ }
+ defer src.Close()
+
+ dst, err := os.Create(backupPath)
+ if err != nil {
+ return fmt.Errorf("failed to create backup file: %w", err)
+ }
+ defer dst.Close()
+
+ if _, err := io.Copy(dst, src); err != nil {
+ // clean up partial backup on error
+ os.Remove(backupPath)
+ return fmt.Errorf("failed to copy database: %w", err)
+ }
+
+ return nil
+}
+
+func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ // Parse multipart form
+ err := r.ParseMultipartForm(32 << 20) // 32MB max memory
+ if err != nil {
+ http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if authObject.GetAuthMode() == auth.Enabled && !authObject.IsAdminPasswordValid(r.FormValue("password")) {
+ http.Error(w, "Invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ settings.Persist()
+
+ // close db connection to avoid corruption
+ if err := db.Close(); err != nil {
+ jsonError(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ // create local backup before overwriting
+ if err := createLocalDatabaseBackup(); err != nil {
+ http.Error(w, "Failed to create local backup: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // overwrite DB file
+ f, err := os.Create(db.FilePath)
+ if err != nil {
+ http.Error(w, "Could not open DB file for writing: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer f.Close()
+
+ if _, err := io.Copy(f, file); err != nil {
+ http.Error(w, "Failed to write DB file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ shutdown()
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func resetDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Password string `json:"password"`
+ Sessions bool `json:"sessions"`
+ Settings bool `json:"settings"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ if authObject.GetAuthMode() == auth.Enabled && !authObject.IsAdminPasswordValid(req.Password) {
+ http.Error(w, "Invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ settings.Persist()
+
+ if err := createLocalDatabaseBackup(); err != nil {
+ jsonError(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ if req.Sessions {
+ query := db.Instance.Exec("DELETE FROM sessions")
+ if query.Error != nil {
+ jsonError(w, http.StatusInternalServerError, query.Error)
+ return
+ }
+ }
+
+ if req.Settings {
+ query := db.Instance.Exec("DELETE FROM settings")
+ if query.Error != nil {
+ jsonError(w, http.StatusInternalServerError, query.Error)
+ return
+ }
+ query = db.Instance.Exec("DELETE FROM configs")
+ if query.Error != nil {
+ jsonError(w, http.StatusInternalServerError, query.Error)
+ return
+ }
+ }
+
+ // close db connection to avoid on-shutdown writes
+ if err := db.Close(); err != nil {
+ jsonError(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ shutdown()
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
diff --git a/tests/backup-restore.spec.ts b/tests/backup-restore.spec.ts
new file mode 100644
index 000000000..f5f3af689
--- /dev/null
+++ b/tests/backup-restore.spec.ts
@@ -0,0 +1,250 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, baseUrl, restart } from "./evcc";
+import {
+ enableExperimental,
+ openTopNavigation,
+ expectModalVisible,
+ expectModalHidden,
+} from "./utils";
+import fs from "fs";
+import path from "path";
+
+test.use({ baseURL: baseUrl() });
+
+test.describe("reset", async () => {
+ test("reset sessions", async ({ page }) => {
+ await start(undefined, "sessions.sql");
+
+ // check sessions
+ await page.goto("/#/sessions?year=2023&month=5");
+ await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+
+ // open backup & restore modal
+ await openTopNavigation(page);
+ await page.getByRole("link", { name: "Configuration" }).click();
+ await enableExperimental(page);
+ await page.getByRole("button", { name: "Backup & Restore" }).click();
+ const modal = page.getByTestId("backup-restore-modal");
+ await expectModalVisible(modal);
+
+ // reset
+ await expect(modal.getByRole("button", { name: "Reset..." })).toBeDisabled();
+ await modal.getByRole("checkbox", { name: "Charging sessions" }).check();
+ await modal.getByRole("button", { name: "Reset..." }).click();
+ const confirmModal = page.getByTestId("backup-restore-confirm-modal");
+ await expectModalVisible(confirmModal);
+ await confirmModal.getByRole("button", { name: "Reset & restart" }).click();
+ await expectModalHidden(confirmModal);
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "false");
+
+ // manual restart
+ await restart(undefined, undefined, true);
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "true");
+
+ // verify sessions deleted
+ await page.goto("/#/sessions?year=2023&month=5");
+ await expect(page.getByTestId("sessions-entry")).toHaveCount(0);
+ await stop();
+ });
+
+ test("reset settings", async ({ page }) => {
+ const title = "Home base";
+
+ await start(undefined, "sessions.sql");
+
+ // create grid meter and title via UI
+ await page.goto("/#/config");
+ await enableExperimental(page);
+ await page.getByTestId("add-grid").click();
+ const meterModal = page.getByTestId("meter-modal");
+ await expectModalVisible(meterModal);
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await meterModal.getByLabel("Power").fill("2000");
+ await meterModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(meterModal);
+ await page.getByTestId("generalconfig-title").getByRole("button", { name: "edit" }).click();
+ const titleModal = page.getByTestId("title-modal");
+ await expectModalVisible(titleModal);
+ await titleModal.getByLabel("Title").fill(title);
+ await titleModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(titleModal);
+
+ // restart to apply
+ await restart();
+
+ // verify changes are present
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "true");
+ await expect(page.getByTestId("grid")).toBeVisible();
+ await expect(page.getByTestId("generalconfig-title")).toContainText(title);
+
+ // reset settings only
+ await page.getByRole("button", { name: "Backup & Restore" }).click();
+ const modal = page.getByTestId("backup-restore-modal");
+ await expectModalVisible(modal);
+ await expect(modal.getByRole("button", { name: "Reset..." })).toBeDisabled();
+ await modal.getByRole("checkbox", { name: "Settings" }).check();
+ await modal.getByRole("button", { name: "Reset..." }).click();
+ const confirmModal = page.getByTestId("backup-restore-confirm-modal");
+ await expectModalVisible(confirmModal);
+ await confirmModal.getByRole("button", { name: "Reset & restart" }).click();
+ await expectModalHidden(confirmModal);
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "false");
+
+ await restart(undefined, undefined, true);
+
+ // verify welcome message
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "true");
+ await expect(page.getByRole("heading", { name: "Hello aboard!" })).toBeVisible();
+
+ // verify sessions
+ await page.goto("/#/sessions?year=2023&month=5");
+ await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+
+ // verify deleted config and settings
+ await page.goto("/#/config");
+ await expect(page.getByTestId("grid")).not.toBeVisible();
+ await expect(page.getByTestId("generalconfig-title")).not.toContainText(title);
+ await stop();
+ });
+});
+
+test.describe("backup and restore", async () => {
+ test("download backup and restore from file", async ({ page }) => {
+ const initialTitle = "My Home Base";
+ const changedTitle = "Changed Title";
+
+ await start();
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ // set initial title
+ await page.getByTestId("generalconfig-title").getByRole("button", { name: "edit" }).click();
+ const titleModal = page.getByTestId("title-modal");
+ await expectModalVisible(titleModal);
+ await titleModal.getByLabel("Title").fill(initialTitle);
+ await titleModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(titleModal);
+
+ // create grid meter
+ await page.getByTestId("add-grid").click();
+ const meterModal = page.getByTestId("meter-modal");
+ await expectModalVisible(meterModal);
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await meterModal.getByLabel("Power").fill("2000");
+ await meterModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(meterModal);
+
+ // verify initial state
+ await expect(page.getByTestId("grid")).toBeVisible();
+ await expect(page.getByTestId("generalconfig-title")).toContainText(initialTitle);
+
+ // open backup & restore modal
+ await page.getByRole("button", { name: "Backup & Restore" }).click();
+ const backupModal = page.getByTestId("backup-restore-modal");
+ await expectModalVisible(backupModal);
+
+ // download backup
+ const downloadPromise = page.waitForEvent("download");
+ await backupModal.getByRole("button", { name: "Download backup..." }).click();
+
+ // download backup confirm
+ const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal");
+ await expectModalVisible(backupConfirmModal);
+ await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
+ await expectModalHidden(backupConfirmModal);
+ const download = await downloadPromise;
+ await backupModal.locator(".btn-close").click();
+ await expectModalHidden(backupModal);
+
+ // change title and delete meter
+ await page.getByTestId("generalconfig-title").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(titleModal);
+ await titleModal.getByLabel("Title").fill(changedTitle);
+ await titleModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(titleModal);
+ await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
+ const gridModal = page.getByTestId("meter-modal");
+ await expectModalVisible(gridModal);
+ await gridModal.getByRole("button", { name: "Delete" }).click();
+ await expectModalHidden(gridModal);
+
+ // verify changes
+ await expect(page.getByTestId("grid")).not.toBeVisible();
+ await expect(page.getByTestId("generalconfig-title")).toContainText(changedTitle);
+
+ // restore
+ await page.getByRole("button", { name: "Backup & Restore" }).click();
+ await expectModalVisible(backupModal);
+
+ // prepare backup file
+ const downloadPath = await download.path();
+ const dbPath = path.join(path.dirname(downloadPath), "backup.db");
+ fs.copyFileSync(downloadPath, dbPath);
+
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await backupModal.getByText("Browse").click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(dbPath);
+ const restoreButton = backupModal.getByRole("button", { name: "Restore..." });
+ await expect(restoreButton).toBeEnabled({ timeout: 3000 });
+ await restoreButton.click();
+
+ // confirm restore
+ const restoreConfirmModal = page.getByTestId("backup-restore-confirm-modal");
+ await expectModalVisible(restoreConfirmModal);
+ await restoreConfirmModal.getByRole("button", { name: "Restore & restart" }).click();
+ await expectModalHidden(restoreConfirmModal);
+
+ // restart after restore
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "false");
+ await restart(undefined, undefined, true);
+ await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "true");
+ await page.getByRole("link", { name: "Let's start configuration" }).click();
+
+ // verify initial state
+ await expect(page.getByTestId("grid")).toBeVisible();
+ await expect(page.getByTestId("generalconfig-title")).toContainText(initialTitle);
+ await stop();
+ });
+
+ test("backup with authentication", async ({ page }) => {
+ // start with auth enabled
+ await start(undefined, "password.sql", "");
+ await page.goto("/#/config");
+
+ // login to access config
+ const loginModal = page.getByTestId("login-modal");
+ await expectModalVisible(loginModal);
+ await loginModal.getByLabel("Password").fill("secret");
+ await loginModal.getByRole("button", { name: "Login" }).click();
+ await expectModalHidden(loginModal);
+
+ await enableExperimental(page);
+
+ // open backup & restore modal
+ await page.getByRole("button", { name: "Backup & Restore" }).click();
+ const backupModal = page.getByTestId("backup-restore-modal");
+ await expectModalVisible(backupModal);
+
+ // try wrong password
+ await backupModal.getByRole("button", { name: "Download backup..." }).click();
+ const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal");
+ await expectModalVisible(backupConfirmModal);
+ const passwordField = backupConfirmModal.getByLabel("Password");
+ await passwordField.fill("wrongpassword");
+ await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
+ await expect(backupConfirmModal.getByText("Login failed: Password is invalid.")).toBeVisible();
+ await passwordField.clear();
+ await passwordField.fill("secret");
+ const downloadPromise = page.waitForEvent("download");
+ await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
+ await expectModalHidden(backupConfirmModal);
+
+ // verify backup was downloaded successfully
+ const download = await downloadPromise;
+ await expect(download.suggestedFilename()).toContain("evcc-backup");
+ await stop();
+ });
+});
diff --git a/tests/evcc.ts b/tests/evcc.ts
index c71fe9925..92debf360 100644
--- a/tests/evcc.ts
+++ b/tests/evcc.ts
@@ -63,8 +63,10 @@ export async function stop(instance?: ChildProcess) {
await _clean();
}
-export async function restart(config?: string, flags = "--disable-auth") {
- await _stop();
+export async function restart(config?: string, flags = "--disable-auth", alreadyStopped = false) {
+ if (!alreadyStopped) {
+ await _stop();
+ }
await _start(config, flags);
}
diff --git a/tests/vehicle-settings.spec.ts b/tests/vehicle-settings.spec.ts
index c88489303..e7bf8b3af 100644
--- a/tests/vehicle-settings.spec.ts
+++ b/tests/vehicle-settings.spec.ts
@@ -80,6 +80,7 @@ test.describe("limitSoc", async () => {
await page.getByRole("combobox", { name: "Default limit" }).selectOption("80%");
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByTestId("limit-soc-value")).toContainText("80%");
+ await page.waitForLoadState("networkidle");
await restart(simulatorConfig());
await page.reload();