Config UI: Add backup & restore (BC) (#22071)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2025-07-14 08:19:07 +02:00 • committed by GitHub
parent 7ce12d1e08
commit ea64f76202
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1068 additions and 45 deletions

View file

@ -545,10 +545,10 @@ input::-webkit-datetime-edit {
.form-control {
color: var(--evcc-default-text);
}
.form-control:disabled,
.form-control:read-only,
.dark .form-control:disabled,
.dark .form-control:read-only {
input.form-control:disabled,
input.form-control:read-only,
.dark input.form-control:disabled,
.dark input.form-control:read-only {
background-color: var(--evcc-gray-10);
color: var(--bs-default-text);
opacity: 1;

View file

@ -1,4 +1,4 @@
import axios from "axios";
import axios, { type AxiosResponse } from "axios";
import { openLoginModal } from "./components/Auth/auth";
const { protocol, hostname, port, pathname } = window.location;
@ -65,3 +65,25 @@ export const allowClientError = {
return status >= 200 && status < 500;
},
};
export function downloadFile(res: AxiosResponse) {
if (res.status === 200) {
const blob = new Blob([res.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
// Try to get filename from Content-Disposition header
let filename = "evcc.txt";
const disposition = res.headers["content-disposition"];
if (disposition && disposition.indexOf("filename=") !== -1) {
filename = disposition.split("filename=")[1].replace(/['"]/g, "").trim();
}
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
}

View file

@ -11,33 +11,7 @@
{{ $t("loginModal.demoMode") }}
</div>
<form v-else-if="modalVisible" @submit.prevent="login">
<div class="mb-4">
<label for="loginPassword" class="col-form-label">
<div class="w-100">
<span class="label">{{ $t("loginModal.password") }}</span>
</div>
</label>
<input
id="loginPassword"
ref="password"
v-model="password"
class="form-control"
autocomplete="current-password"
type="password"
required
/>
</div>
<p v-if="error" class="text-danger my-4">{{ $t("loginModal.error") }}{{ error }}</p>
<a
v-if="iframeHint"
class="text-muted my-4 d-block text-center"
:href="evccUrl"
target="_blank"
data-testid="login-iframe-hint"
>
{{ $t("loginModal.iframeHint") }}
</a>
<PasswordInput v-model:password="password" :error="error" :iframe-hint="iframeHint" />
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="loading">
<span
@ -67,10 +41,11 @@ import api from "@/api";
import { updateAuthStatus, getAndClearNextUrl, getAndClearNextModal, isLoggedIn } from "./auth";
import { docsPrefix } from "@/i18n";
import { defineComponent } from "vue";
import PasswordInput from "./PasswordInput.vue";
export default defineComponent({
name: "LoginModal",
components: { GenericModal },
components: { GenericModal, PasswordInput },
props: {
demoMode: Boolean,
},
@ -88,9 +63,6 @@ export default defineComponent({
resetUrl() {
return `${docsPrefix()}/docs/faq#password-reset`;
},
evccUrl() {
return window.location.href;
},
modalSize() {
return this.demoMode ? "md" : "sm";
},

View file

@ -0,0 +1,56 @@
<template>
<div>
<div class="mb-4">
<label for="loginPassword" class="col-form-label">
<div class="w-100">
<span class="label">{{ $t("loginModal.password") }}</span>
</div>
</label>
<input
id="loginPassword"
ref="password"
:value="password"
class="form-control"
autocomplete="current-password"
type="password"
:required="required"
@input="updatePassword"
/>
</div>
<p v-if="error" class="text-danger my-4">{{ $t("loginModal.error") }}{{ error }}</p>
<a
v-if="iframeHint"
class="text-muted my-4 d-block text-center"
:href="evccUrl"
target="_blank"
data-testid="login-iframe-hint"
>
{{ $t("loginModal.iframeHint") }}
</a>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "PasswordInput",
props: {
error: { type: String, default: "" },
password: { type: String, default: "" },
iframeHint: { type: Boolean, default: false },
required: { type: Boolean, default: true },
},
emits: ["update:password"],
computed: {
evccUrl() {
return window.location.href;
},
},
methods: {
updatePassword(e: Event) {
this.$emit("update:password", (e.target as HTMLInputElement).value);
},
},
});
</script>

View file

@ -0,0 +1,356 @@
<template>
<div>
<GenericModal
id="backupRestoreModal"
:title="$t('config.system.backupRestore.title')"
data-testid="backup-restore-modal"
@closed="backupRestoreModalClosed"
>
<p>
<span>{{ $t("config.system.backupRestore.description") }}</span>
</p>
<div class="mb-3">
<h6>
{{ $t("config.system.backupRestore.backup.title") }}
</h6>
<p>
{{ $t("config.system.backupRestore.backup.description") }}
</p>
<button
class="btn btn-outline-secondary"
@click="openBackupRestoreConfirmModal('backup')"
>
{{ $t("config.system.backupRestore.backup.action") }}
</button>
</div>
<div class="mb-3">
<h6>
{{ $t("config.system.backupRestore.restore.title") }}
</h6>
<p>
{{ $t("config.system.backupRestore.restore.description") }}
</p>
<form @submit.prevent="openBackupRestoreConfirmModal('restore')">
<FormRow
id="restoreFile"
:label="$t('config.system.backupRestore.restore.labelFile')"
>
<PropertyFileField
id="restoreFile"
ref="fileInput"
:accepted="['.db']"
required
@file-changed="fileChanged"
/>
</FormRow>
<button class="btn btn-outline-danger mt-2" :disabled="file === null">
{{ $t("config.system.backupRestore.restore.action") }}
</button>
</form>
</div>
<div class="mb-3">
<h6>
{{ $t("config.system.backupRestore.reset.title") }}
</h6>
<p>{{ $t("config.system.backupRestore.reset.description") }}</p>
<form @submit.prevent="openBackupRestoreConfirmModal('reset')">
<div class="d-flex flex-column mb-2">
<div class="d-flex mb-1">
<input
id="resetSessions"
v-model="selectedReset.sessions"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label ms-2" for="resetSessions">
<span>{{ $t("config.system.backupRestore.reset.sessions") }}</span>
<br />
<small>
{{
$t("config.system.backupRestore.reset.sessionsDescription")
}}
</small>
</label>
</div>
<div class="d-flex mb-1">
<input
id="resetSettings"
v-model="selectedReset.settings"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label ms-2" for="resetSettings">
<span>{{ $t("config.system.backupRestore.reset.settings") }}</span>
<br />
<small>
{{
$t("config.system.backupRestore.reset.settingsDescription")
}}
</small>
</label>
</div>
</div>
<button
class="btn btn-outline-danger mt-3"
:disabled="!selectedReset.sessions && !selectedReset.settings"
>
{{ $t("config.system.backupRestore.reset.action") }}
</button>
</form>
</div>
<p>
<small>
{{ $t("config.system.backupRestore.note") }}
</small>
</p>
</GenericModal>
<GenericModal
id="backupRestoreConfirmModal"
:title="$t(`config.system.backupRestore.${confirmType}.title`)"
size="md"
data-testid="backup-restore-confirm-modal"
@close="confirmModalClose"
@closed="confirmType = ''"
>
<form @submit.prevent="submit">
<p>
<span>{{
$t(`config.system.backupRestore.${confirmType}.confirmationText`)
}}</span>
</p>
<PasswordInput
v-model:password="password"
:error="error"
:iframe-hint="iframeHint"
:required="false"
/>
<div class="d-flex justify-content-between gap-2 flex-wrap">
<button
:disabled="loading"
type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal"
>
<span>{{ $t(`config.system.backupRestore.cancel`) }}</span>
</button>
<button
type="submit"
class="btn text-truncate"
:class="confirmType === 'backup' ? 'btn-primary' : 'btn-danger'"
:disabled="loading"
>
<span
v-if="loading"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t(`config.system.backupRestore.${confirmType}.confirmationButton`) }}
</button>
</div>
</form>
</GenericModal>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import api, { downloadFile } from "@/api";
import PropertyFileField from "./PropertyFileField.vue";
import FormRow from "./FormRow.vue";
import { isLoggedIn } from "../Auth/auth";
import type { AxiosResponse } from "axios";
import Modal from "bootstrap/js/dist/modal";
import PasswordInput from "../Auth/PasswordInput.vue";
import restart, { showRestarting } from "@/restart";
const validateStatus = (code: number) => [200, 204, 401, 403].includes(code);
export default defineComponent({
name: "BackupRestoreModal",
components: { GenericModal, PropertyFileField, FormRow, PasswordInput },
data() {
return {
selectedReset: {
sessions: false,
settings: false,
},
file: null as File | null,
confirmType: "" as "backup" | "restore" | "reset" | "",
password: "",
loading: false,
iframeHint: false,
error: "",
hideBackupRestoreModal: false,
navigateHomeAfterRestart: false,
};
},
computed: {
restarting() {
return restart.restarting;
},
},
watch: {
restarting(newVal) {
// wait for restarte before navigate (ensure lazy chunks are available)
if (!newVal && this.navigateHomeAfterRestart) {
this.$router.push({ path: "/" });
this.navigateHomeAfterRestart = false;
}
},
},
methods: {
resetBackupRestoreConfirmModal() {
this.password = "";
this.loading = false;
this.iframeHint = false;
this.error = "";
},
resetBackupRestoreModal() {
this.selectedReset = {
sessions: false,
settings: false,
};
this.file = null;
this.navigateHomeAfterRestart = false;
this.hideBackupRestoreModal = false;
(
this.$refs["fileInput"] as InstanceType<typeof PropertyFileField> | undefined
)?.reset();
},
reset() {
this.resetBackupRestoreConfirmModal();
this.resetBackupRestoreModal();
this.backupRestoreConfirmModal().hide();
this.backupRestoreModal().hide();
},
backupRestoreModal() {
return Modal.getOrCreateInstance(
document.getElementById("backupRestoreModal") as HTMLElement
);
},
backupRestoreConfirmModal() {
return Modal.getOrCreateInstance(
document.getElementById("backupRestoreConfirmModal") as HTMLElement
);
},
openBackupRestoreConfirmModal(type: typeof this.confirmType) {
this.resetBackupRestoreConfirmModal();
this.backupRestoreConfirmModal().show();
this.backupRestoreModal().hide();
this.confirmType = type;
},
closeModal() {
this.backupRestoreModal().hide();
},
showModal() {
this.backupRestoreModal().show();
},
closeConfirmModal() {
this.backupRestoreConfirmModal().hide();
},
fileChanged(file: File) {
this.file = file;
},
async call(action: Promise<AxiosResponse>): Promise<AxiosResponse | null> {
let r = null;
this.loading = true;
try {
const res = await action;
if (res.status === 200 || res.status === 204) {
if (!isLoggedIn()) {
this.iframeHint = true;
} else {
r = res;
}
} else if (res.status === 401) {
this.error = this.$t("loginModal.invalid");
}
} catch (err) {
console.error(err);
if (err instanceof Error) {
this.error = err.message;
}
} finally {
this.loading = false;
}
return r;
},
async downloadBackup() {
const res = await this.call(
api.post(
"/system/backup",
{ password: this.password },
{ responseType: "blob", validateStatus }
)
);
if (res) {
this.closeConfirmModal();
downloadFile(res);
}
},
async restoreDatabase() {
const formData = new FormData();
formData.append("password", this.password);
formData.append("file", this.file!);
const res = await this.call(api.post("/system/restore", formData, { validateStatus }));
if (res) {
this.hideBackupRestoreModal = true;
this.navigateHomeAfterRestart = true;
this.closeConfirmModal();
showRestarting();
}
},
async resetDatabase() {
const res = await this.call(
api.post(
"/system/reset",
{ password: this.password, ...this.selectedReset },
{ validateStatus }
)
);
if (res) {
this.hideBackupRestoreModal = true;
this.navigateHomeAfterRestart = true;
this.closeConfirmModal();
showRestarting();
}
},
async submit() {
if (this.confirmType === "backup") {
await this.downloadBackup();
} else if (this.confirmType === "restore") {
await this.restoreDatabase();
} else {
await this.resetDatabase();
}
},
confirmModalClose() {
if (!this.hideBackupRestoreModal) {
this.showModal();
}
},
backupRestoreModalClosed() {
if (this.confirmType === "") {
this.reset();
}
},
},
});
</script>

View file

@ -0,0 +1,69 @@
<template>
<div>
<label :for="id" class="form-control cursor-pointer">
<div class="hstack gap-3">
{{ $t("config.general.selectFile") }}
<div class="vr"></div>
<span class="text-truncate">{{ computedFileName }}</span>
</div>
</label>
<input
:id="id"
type="file"
class="d-none"
:accept="accepted.join(', ')"
:required="required"
@change="onFileChange"
/>
<div>
<span v-if="invalidFileSelected" class="text-danger">
{{ $t("config.general.invalidFileSelected") }}
</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "PropertyFileField",
props: {
id: String,
accepted: { type: Array as PropType<string[]>, default: () => [] },
required: Boolean,
},
emits: ["fileChanged"],
data() {
return {
file: null as File | null,
invalidFileSelected: false,
};
},
computed: {
computedFileName() {
return this.file ? this.file.name : this.$t("config.general.noFileSelected");
},
},
methods: {
reset() {
this.file = null;
this.invalidFileSelected = false;
},
onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.item(0);
if (file) {
if (this.accepted.some((a) => file.name.endsWith(a))) {
this.file = file;
this.invalidFileSelected = false;
this.$emit("fileChanged", file);
} else {
this.invalidFileSelected = true;
}
}
},
},
});
</script>

View file

@ -1,5 +1,5 @@
<template>
<div data-testid="offline-indicator">
<div data-testid="offline-indicator" :aria-hidden="!visible">
<div v-if="offline" class="modal-backdrop" />
<div
class="fixed-bottom alert d-flex justify-content-center align-items-center mb-0 rounded-0 p-2"

View file

@ -20,4 +20,8 @@ export function restartComplete() {
restart.restartNeeded = false;
}
export function showRestarting() {
restart.restarting = true;
}
export default restart;

View file

@ -309,10 +309,16 @@
<hr class="my-5" />
<h2 class="my-4 mt-5">{{ $t("config.section.system") }}</h2>
<div class="round-box p-4 d-flex gap-4 mb-5">
<div class="round-box p-4 d-flex gap-4 mb-5 flex-wrap">
<router-link to="/log" class="btn btn-outline-secondary">
{{ $t("config.system.logs") }}
</router-link>
<button
class="btn btn-outline-secondary text-truncate"
@click="openModal('backupRestoreModal')"
>
{{ $t("config.system.backupRestore.title") }}
</button>
<button class="btn btn-outline-danger" @click="restart">
{{ $t("config.system.restart") }}
</button>
@ -367,6 +373,7 @@
<ModbusProxyModal @changed="yamlChanged" />
<CircuitsModal @changed="yamlChanged" />
<EebusModal @changed="yamlChanged" />
<BackupRestoreModal />
</div>
</div>
</div>
@ -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,

View file

@ -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
}

View file

@ -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))

View file

@ -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.",

View file

@ -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.",

View file

@ -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()
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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();
});
});

View file

@ -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);
}

View file

@ -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();