Config UI: Add backup & restore (BC) (#22071)
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
7ce12d1e08
commit
ea64f76202
19 changed files with 1068 additions and 45 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
},
|
||||
|
|
|
|||
56
assets/js/components/Auth/PasswordInput.vue
Normal file
56
assets/js/components/Auth/PasswordInput.vue
Normal 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>
|
||||
356
assets/js/components/Config/BackupRestoreModal.vue
Normal file
356
assets/js/components/Config/BackupRestoreModal.vue
Normal 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>
|
||||
69
assets/js/components/Config/PropertyFileField.vue
Normal file
69
assets/js/components/Config/PropertyFileField.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -20,4 +20,8 @@ export function restartComplete() {
|
|||
restart.restartNeeded = false;
|
||||
}
|
||||
|
||||
export function showRestarting() {
|
||||
restart.restarting = true;
|
||||
}
|
||||
|
||||
export default restart;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
36
i18n/de.json
36
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.",
|
||||
|
|
|
|||
36
i18n/en.json
36
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.",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
250
tests/backup-restore.spec.ts
Normal file
250
tests/backup-restore.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue