UI: persist experimental flag (BC) (#27074)

This commit is contained in:
Maschga 2026-02-07 17:15:59 +01:00 • committed by GitHub
parent 3f1df220b5
commit 4c5935b60d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 137 additions and 112 deletions

View file

@ -34,7 +34,6 @@ export default {
} satisfies Preview;
setup((app) => {
app.config.globalProperties.$hiddenFeatures = () => true;
app.use(setupI18n());
// Mock router-link for Storybook

View file

@ -40,9 +40,9 @@ const errorInterceptor = (error: any) => {
message.push(`${error.response.data.error}.`);
}
if (error.config) {
const method = error.config.method.toUpperCase();
const url = error.request.responseURL;
message.push(`${method} ${url}`);
const { baseURL, url, method } = error.config;
const fullUrl = error.request.responseURL || `${baseURL}${url}`;
message.push(`${method.toUpperCase()} ${fullUrl}`);
}
window.app.raise({ message });
return Promise.reject(error);

View file

@ -5,7 +5,6 @@ import { VueHeadMixin, createHead } from "@unhead/vue/client";
import App from "./views/App.vue";
import setupRouter from "./router.ts";
import setupI18n from "./i18n.ts";
import featureflags from "./featureflags.ts";
import { watchThemeChanges } from "./theme.ts";
import { appDetection, sendToApp } from "./utils/native";
import type { Notification } from "./types/evcc";
@ -74,7 +73,6 @@ const head = createHead();
app.use(i18n);
app.use(setupRouter(i18n.global));
app.use(featureflags);
app.use(head);
app.mixin(VueHeadMixin);
window.app = app.mount("#app");

View file

@ -8,7 +8,7 @@
@open="open"
@closed="closed"
>
<p v-if="error" class="text-danger">{{ $t("passwordModal.error") }} "{{ error }}"</p>
<ErrorMessage :error="error" />
<p v-if="isSetupMode" class="mb-4">{{ $t("passwordModal.description") }}</p>
<form
v-if="modalVisible"
@ -87,6 +87,7 @@
<script lang="ts">
import GenericModal from "../Helper/GenericModal.vue";
import ErrorMessage from "../Helper/ErrorMessage.vue";
import FormRow from "../Helper/FormRow.vue";
import api from "@/api";
import { updateAuthStatus } from "./auth";
@ -94,7 +95,7 @@ import { defineComponent } from "vue";
export default defineComponent({
name: "PasswordModal",
components: { FormRow, GenericModal },
components: { FormRow, GenericModal, ErrorMessage },
props: {
updateMode: Boolean,
},

View file

@ -66,7 +66,7 @@
/>
</div>
<p v-if="auth.error" class="text-danger">{{ auth.error }}</p>
<ErrorMessage :error="auth.error" />
<div
class="my-4 d-flex align-items-stretch justify-content-sm-between align-items-sm-baseline flex-column-reverse flex-sm-row gap-2"
@ -167,6 +167,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import GenericModal, { type ModalFade } from "../../Helper/GenericModal.vue";
import ErrorMessage from "../../Helper/ErrorMessage.vue";
import PropertyEntry from "../PropertyEntry.vue";
import PropertyCollapsible from "../PropertyCollapsible.vue";
import Modbus from "./Modbus.vue";
@ -203,6 +204,7 @@ export default defineComponent({
name: "DeviceModalBase",
components: {
GenericModal,
ErrorMessage,
PropertyEntry,
PropertyCollapsible,
Modbus,

View file

@ -1,46 +1,56 @@
<template>
<GenericModal
id="experimentalModal"
:title="$t('config.experimental.title')"
:title="`${$t('config.experimental.title')} 🧪`"
data-testid="experimental-modal"
>
<p>{{ $t("config.experimental.description") }}</p>
<ErrorMessage :error="error" />
<div class="form-check form-switch my-3">
<input
id="experimentalEnabled"
v-model="hiddenFeatures"
:checked="experimental"
class="form-check-input"
type="checkbox"
role="switch"
@change="change"
/>
<div class="form-check-label">
<label for="experimentalEnabled">
{{ $t("settings.hiddenFeatures.value") }} 🧪
{{ $t("settings.hiddenFeatures.value") }}
</label>
</div>
</div>
<div class="small text-muted">
{{ $t("settings.deviceInfo") }}
</div>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import { getHiddenFeatures, setHiddenFeatures } from "@/featureflags.ts";
import ErrorMessage from "../Helper/ErrorMessage.vue";
import api from "@/api";
import type { AxiosError } from "axios";
export default defineComponent({
name: "ExperimentalModal",
components: { GenericModal },
components: { GenericModal, ErrorMessage },
props: {
experimental: Boolean,
},
data() {
return {
hiddenFeatures: getHiddenFeatures(),
error: null as string | null,
};
},
watch: {
hiddenFeatures(value) {
setHiddenFeatures(value);
methods: {
async change(e: Event) {
try {
this.error = null;
await api.post(`config/experimental/${(e.target as HTMLInputElement).checked}`);
} catch (err) {
const e = err as AxiosError<{ error: string }>;
this.error = e.response?.data?.error || e.message;
}
},
},
});

View file

@ -25,7 +25,7 @@
<GeneralConfigEntry
test-id="generalconfig-experimental"
:label="$t('config.general.experimental')"
:text="$t(`config.general.${hiddenFeatures ? 'on' : 'off'}`)"
:text="$t(`config.general.${experimental ? 'on' : 'off'}`)"
modal-id="experimentalModal"
/>
@ -66,7 +66,6 @@
import TitleModal from "./TitleModal.vue";
import GeneralConfigEntry from "./GeneralConfigEntry.vue";
import api from "@/api";
import settings from "@/settings";
import store from "@/store";
import formatter from "@/mixins/formatter";
@ -76,6 +75,7 @@ export default {
mixins: [formatter],
props: {
sponsorError: Boolean,
experimental: Boolean,
},
emits: ["site-changed"],
data() {
@ -87,9 +87,6 @@ export default {
telemetryEnabled() {
return store.state?.telemetry === true;
},
hiddenFeatures() {
return settings.hiddenFeatures === true;
},
networkStatus() {
return `${store.state?.network?.port ?? ""}`;
},

View file

@ -7,7 +7,7 @@
</a>
</p>
<slot name="afterDescription" />
<p v-if="error" class="text-danger" data-testid="error">{{ error }}</p>
<ErrorMessage :error="error" data-testid="error" />
<form ref="form" class="container mx-0 px-0">
<div v-if="!noYamlEditor" class="editor-container">
<YamlEditorContainer
@ -49,13 +49,14 @@
<script>
import GenericModal from "../Helper/GenericModal.vue";
import ErrorMessage from "../Helper/ErrorMessage.vue";
import api from "@/api";
import { docsPrefix } from "@/i18n";
import YamlEditorContainer from "./YamlEditorContainer.vue";
export default {
name: "YamlModal",
components: { GenericModal, YamlEditorContainer },
components: { GenericModal, ErrorMessage, YamlEditorContainer },
props: {
title: String,
description: String,

View file

@ -333,6 +333,7 @@ export default defineComponent({
mixins: [formatter, collector],
props: {
gridConfigured: Boolean,
experimental: Boolean,
gridPower: { type: Number, default: 0 },
homePower: { type: Number, default: 0 },
pvConfigured: Boolean,
@ -510,7 +511,7 @@ export default defineComponent({
return undefined;
}
const { today, scale } = this.forecast.solar || {};
const factor = this.$hiddenFeatures() && settings.solarAdjusted && scale ? scale : 1;
const factor = this.experimental && settings.solarAdjusted && scale ? scale : 1;
const energy = today?.energy || 0;
return energy * factor;
},

View file

@ -77,6 +77,7 @@ export default defineComponent({
props: {
forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
currency: { type: String as PropType<CURRENCY> },
experimental: Boolean,
},
data(): {
isModalVisible: boolean;
@ -94,7 +95,7 @@ export default defineComponent({
return settings.solarAdjusted;
},
showSolarAdjust() {
return !!this.forecast.solar && this.$hiddenFeatures();
return !!this.forecast.solar && this.experimental;
},
solar() {
return this.showSolarAdjust && this.solarAdjusted

View file

@ -0,0 +1,19 @@
<template>
<p v-if="error" class="text-danger text-break">
<strong>{{ $t("config.general.error") }}:</strong> {{ error }}
</p>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "ErrorMessage",
props: {
error: {
type: String as PropType<string | null>,
default: null,
},
},
});
</script>

View file

@ -163,6 +163,7 @@ export default defineComponent({
forecast: Object as PropType<Forecast>,
circuits: Object as PropType<Record<string, Circuit>>,
telemetry: Boolean,
experimental: Boolean,
evopt: { type: Object as PropType<EvOpt> },
},
computed: {

View file

@ -1,5 +1,5 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<ErrorMessage :error="error" />
<div class="form-check form-switch my-3">
<input
id="telemetryEnabled"
@ -25,27 +25,20 @@
</i18n-t>
<span v-else>{{ $t("footer.telemetry.optInSponsorship") }}</span>
</label>
<div v-if="error" class="errorMessage my-1 text-danger" v-html="error" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import ErrorMessage from "./Helper/ErrorMessage.vue";
import api from "../api";
import { docsPrefix } from "../i18n";
import type { AxiosError } from "axios";
function parseMarkdown(markdownText: string) {
const htmlText = markdownText
.replace(/\*\*(.*)\*\*/gim, "<b>$1</b>")
.replace(/\*(.*)\*/gim, "<i>$1</i>")
.replace(/`(.*)`/gim, "<pre>$1</pre>");
return htmlText.trim();
}
export default defineComponent({
name: "TelemetrySettings",
components: { ErrorMessage },
props: { sponsorActive: Boolean, telemetry: Boolean },
data() {
return {
@ -64,9 +57,7 @@ export default defineComponent({
await api.post(`settings/telemetry/${(e.target as HTMLInputElement).checked}`);
} catch (err) {
const e = err as AxiosError<{ error: string }>;
if (e.response) {
this.error = parseMarkdown("**Error:** " + e.response.data.error);
}
this.error = e.response?.data?.error || e.message;
}
},
},
@ -79,8 +70,4 @@ export default defineComponent({
.form-check-label {
max-width: 100%;
}
.errorMessage :deep(pre) {
text-overflow: ellipsis;
font-size: 1em;
}
</style>

View file

@ -166,6 +166,7 @@ export default defineComponent({
battery: { type: Object as PropType<Battery> },
evopt: { type: Object as PropType<EvOpt>, required: false },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
experimental: Boolean,
},
emits: ["auth-required"],
data() {
@ -213,7 +214,7 @@ export default defineComponent({
return grid || solar || co2;
},
optimizeAvailable() {
return !!this.evopt && this.$hiddenFeatures();
return !!this.evopt && this.experimental;
},
showLogout() {
return isLoggedIn();

View file

@ -1,16 +0,0 @@
import type { App } from "vue";
import settings from "./settings";
export function setHiddenFeatures(value: boolean) {
settings.hiddenFeatures = value;
}
export function getHiddenFeatures() {
return settings.hiddenFeatures;
}
export default {
install: (app: App) => {
app.config.globalProperties.$hiddenFeatures = getHiddenFeatures;
},
};

View file

@ -6,7 +6,6 @@ const SETTINGS_LOCALE = "settings_locale";
const SETTINGS_THEME = "settings_theme";
const SETTINGS_UNIT = "settings_unit";
const SETTINGS_12H_FORMAT = "settings_12h_format";
const SETTINGS_HIDDEN_FEATURES = "settings_hidden_features";
const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
const SETTINGS_ENERGYFLOW_CO2 = "settings_energyflow_co2";
const SETTINGS_ENERGYFLOW_PV = "settings_energyflow_pv";
@ -108,7 +107,6 @@ export interface Settings {
theme: THEME | null;
unit: string;
is12hFormat: boolean;
hiddenFeatures: boolean;
energyflowDetails: boolean;
energyflowCo2: boolean;
energyflowPv: boolean;
@ -133,7 +131,6 @@ const settings: Settings = reactive({
theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT),
is12hFormat: readBool(SETTINGS_12H_FORMAT),
hiddenFeatures: readBool(SETTINGS_HIDDEN_FEATURES),
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
@ -157,7 +154,6 @@ watch(() => settings.locale, save(SETTINGS_LOCALE));
watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT));
watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT));
watch(() => settings.hiddenFeatures, saveBool(SETTINGS_HIDDEN_FEATURES));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));

View file

@ -60,6 +60,8 @@ export interface FatalError {
export interface State {
offline: boolean;
telemetry?: boolean;
experimental?: boolean;
setupRequired?: boolean;
startupCompleted?: boolean;
loadpoints: Loadpoint[];

View file

@ -3,10 +3,6 @@ import type { ComponentCustomProperties } from "vue";
declare module "vue" {
interface ComponentCustomProperties {
/**
* Whether experimental UI features should be shown.
*/
$hiddenFeatures: () => boolean;
$refs: { [key: string]: HTMLElement | undefined };
}
}

View file

@ -16,6 +16,7 @@
<h2 class="my-4 mt-5">{{ $t("config.section.general") }}</h2>
<GeneralConfig
:experimental="experimental"
:sponsor-error="hasClassError('sponsorship')"
@site-changed="siteChanged"
/>
@ -371,7 +372,7 @@
<MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" />
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<ExperimentalModal />
<ExperimentalModal :experimental="experimental" />
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
<CircuitsModal
:gridMeter="gridMeter"
@ -684,8 +685,10 @@ export default defineComponent({
return store.state?.ocpp;
},
telemetry() {
// @ts-expect-error: telemetry property exists but not in TypeScript definitions
return store.state?.telemetry === true;
return store.state?.telemetry;
},
experimental() {
return store.state?.experimental;
},
eebus() {
return store.state?.eebus;

View file

@ -533,12 +533,14 @@ export default defineComponent({
}
// read essential config data from state
["modbusproxy", "mqtt", "influx", "shm", "interval"].forEach((key) => {
const value = store.state[key as keyof State];
if (value) {
configs[key] = value;
["modbusproxy", "mqtt", "influx", "shm", "interval", "experimental"].forEach(
(key) => {
const value = store.state[key as keyof State];
if (value !== undefined && value !== null) {
configs[key] = value;
}
}
});
);
this.sections.uiConfig.content = formatJson(configs, EXPAND_KEYS);
} catch (error) {

View file

@ -378,6 +378,7 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.Database, Val: db.FilePath}
valueChan <- util.Param{Key: keys.System, Val: util.System()}
valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")}
valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()}
// run shutdown functions on stop
var once sync.Once
@ -427,7 +428,7 @@ func runRoot(cmd *cobra.Command, args []string) {
site.DumpConfig()
site.Prepare(valueChan, pushChan)
httpd.RegisterSiteHandlers(site, valueChan)
httpd.RegisterSiteHandlers(site)
go func() {
site.Run(stopC, conf.Interval)

View file

@ -1175,3 +1175,9 @@ func configureAuth(router *mux.Router, paramC chan<- util.Param) {
// wire the handler
providerauth.Setup(auth, paramC)
}
// isExperimental returns if experimental features are enabled
func isExperimental() bool {
b, _ := settings.Bool(keys.Experimental)
return b
}

View file

@ -2,6 +2,7 @@ package keys
const (
Interval = "interval"
Experimental = "experimental"
PasswordConfigured = "passwordConfigured"
Sponsor = "sponsor"
SponsorToken = "sponsorToken"

View file

@ -1111,7 +1111,6 @@
"passwordModal": {
"description": "Задайте парола за защита на настройките на конфигурацията. Използването на основния екран е възможно и без влизане в системата.",
"empty": "Паролата не трябва да бъде празна",
"error": "Грешка: ",
"labelCurrent": "Текуща парола",
"labelNew": "Нова парола",
"labelRepeat": "Повторете паролата",

View file

@ -1116,7 +1116,6 @@
"passwordModal": {
"description": "Nastavte heslo pro zabezpečení konfiguračního menu. Obrazovka přehledu bude stále dostupná i bez ověření heslem.",
"empty": "Heslo nesmí být prázdné",
"error": "Chyba: ",
"labelCurrent": "Současné heslo",
"labelNew": "Nové heslo",
"labelRepeat": "Opakujte heslo",

View file

@ -1118,7 +1118,6 @@
"passwordModal": {
"description": "Angiv adgangskode for at beskytte konfigurations indstillinger. Det er stadig mulig at benytte hovedskærmen uden at logge på.",
"empty": "Adgangskode må ikke være tomt",
"error": "Fejl: ",
"labelCurrent": "Nuværende adgangskode",
"labelNew": "Ny adgangskode",
"labelRepeat": "Gentag adgangskoden",

View file

@ -192,7 +192,7 @@
"title": "EEBus"
},
"experimental": {
"description": "Aktiviert die Benutzeroberfläche für Funktionen, die sich noch in der Testphase befinden und sich in zukünftigen Versionen ändern können.",
"description": "Bietet frühzeitigen Zugriff auf Funktionen, die sich noch in der Testphase befinden. Diese können instabil sein und in zukünftigen Versionen geändert oder entfernt werden.",
"title": "Experimentell"
},
"ext": {
@ -223,6 +223,7 @@
"dragHandle": "Verschieben",
"dragItem": "Verschiebbar: {title}",
"dragList": "Sortierbare Liste",
"error": "Fehler",
"experimental": "Experimentell",
"forceSave": "Trotzdem speichern",
"fromYamlHint": "Hinweis: Konfiguriert über evcc.yaml. Entferne den Eintrag aus der Datei, um die Bearbeitung hier zu aktivieren.",
@ -1124,7 +1125,6 @@
"passwordModal": {
"description": "Setze ein Passwort, um die Konfiguration zu schützen. Die Hauptansicht bleibt ohne Login zugänglich.",
"empty": "Passwort darf nicht leer sein",
"error": "Fehler: ",
"labelCurrent": "Aktuelles Passwort",
"labelNew": "Neues Passwort",
"labelRepeat": "Neues Passwort wiederholen",
@ -1238,7 +1238,7 @@
},
"hiddenFeatures": {
"label": "Experimentell",
"value": "Experimentelle Funktionen anzeigen."
"value": "Experimentelle Funktionen aktivieren."
},
"language": {
"auto": "Automatisch",

View file

@ -1116,7 +1116,6 @@
"passwordModal": {
"description": "Ορίστε έναν κωδικό πρόσβασης για την προστασία των ρυθμίσεων διαμόρφωσης. Η χρήση της κύριας οθόνης εξακολουθεί να είναι δυνατή χωρίς σύνδεση.",
"empty": "Ο κωδικός πρόσβασης δεν πρέπει να είναι κενός",
"error": "Σφάλμα: ",
"labelCurrent": "Τρέχων κωδικός",
"labelNew": "Νέος κωδικός",
"labelRepeat": "Επανάληψη κωδικού",

View file

@ -192,7 +192,7 @@
"title": "EEBus"
},
"experimental": {
"description": "Enable user interface for features that are still being tested and may change in future versions.",
"description": "Provides early access to features that are still being tested. These may be unstable and could change or be removed in future versions.",
"title": "Experimental"
},
"ext": {
@ -223,6 +223,7 @@
"dragHandle": "Drag handle",
"dragItem": "Draggable: {title}",
"dragList": "Reorderable list",
"error": "Error",
"experimental": "Experimental",
"forceSave": "Save anyway",
"fromYamlHint": "Note: Configured via evcc.yaml. Remove the entry from the file to enable editing here.",
@ -1124,7 +1125,6 @@
"passwordModal": {
"description": "Set a password to protect the configuration settings. Using the main screen is still possible without login.",
"empty": "Password should not be empty",
"error": "Error: ",
"labelCurrent": "Current password",
"labelNew": "New password",
"labelRepeat": "Repeat password",
@ -1238,7 +1238,7 @@
},
"hiddenFeatures": {
"label": "Experimental",
"value": "Show experimental features."
"value": "Enable experimental features."
},
"language": {
"auto": "Automatic",

View file

@ -1111,7 +1111,6 @@
"passwordModal": {
"description": "Establezca una contraseña para proteger los ajustes de configuración. Se puede seguir utilizando la pantalla principal sin necesidad de iniciar sesión.",
"empty": "La contraseña no debe estar vacía",
"error": "Error: ",
"labelCurrent": "Contraseña actual",
"labelNew": "Nueva contraseña",
"labelRepeat": "Repita la contraseña",

View file

@ -1117,7 +1117,6 @@
"passwordModal": {
"description": "Aseta salasana suojellaksesi määritys asetuksia. Pääikkunan käyttö on silti mahdollista ilman kirjautumista.",
"empty": "Salasana ei tulisi olla tyhjä",
"error": "Virhe: ",
"labelCurrent": "Nykyinen salasana",
"labelNew": "Uusi salasana",
"labelRepeat": "Toista salasana",

View file

@ -1117,7 +1117,6 @@
"passwordModal": {
"description": "Configurez un mot de passe pour protéger la configuration. L’utilisation de l’écran principal et toujours possible sans authentification.",
"empty": "Le mot de passe ne doit pas être vide",
"error": "Erreur : ",
"labelCurrent": "Mot de passe actuel",
"labelNew": "Nouveau mot de passe",
"labelRepeat": "Répétez le mot de passe",

View file

@ -1117,7 +1117,6 @@
"passwordModal": {
"description": "Postavite lozinku za zaštitu postavki. Početna stranica se može koristiti i bez prijave.",
"empty": "Lozinka ne bi trebala biti prazna",
"error": "Greška: ",
"labelCurrent": "Trenutna lozinka",
"labelNew": "Nova lozinka",
"labelRepeat": "Ponovite lozinku",

View file

@ -1116,7 +1116,6 @@
"passwordModal": {
"description": "Állítson be egy jelszót a konfigurációs beállítások védelmére. A főképernyő használata továbbra is lehetséges bejelentkezés nélkül.",
"empty": "A jelszó nem lehet üres",
"error": "Hiba: ",
"labelCurrent": "Jelenlegi jelszó",
"labelNew": "Új jelszó",
"labelRepeat": "Jelszó megismétlése",

View file

@ -1111,7 +1111,6 @@
"passwordModal": {
"description": "Imposta una password per proteggere le impostazioni di configurazione. È comunque possibile utilizzare la schermata principale senza effettuare il login.",
"empty": "La password non deve essere vuota",
"error": "Errore: ",
"labelCurrent": "Password attuale",
"labelNew": "Nuova password",
"labelRepeat": "Ripeti la password",

View file

@ -1116,7 +1116,6 @@
"passwordModal": {
"description": "Definéiert e Passwuert fir d'Konfiguratiounsastellungen ze schützen. Et kann een den Haaptbildschierm och ouni Login nach ëmmer benotzen.",
"empty": "Passwuert duerf net eidel sinn",
"error": "Feeler: ",
"labelCurrent": "Aktuellt Passwuert",
"labelNew": "Neit Passwuert",
"labelRepeat": "Neit Passwuert widderhuelen",

View file

@ -1118,7 +1118,6 @@
"passwordModal": {
"description": "Nustatykite slaptažodį konfigūracijos apsaugai. Pagrindiniu ekranu galima naudotis ir neprisijungus.",
"empty": "Slaptažodis negali būti tuščias",
"error": "Klaida: ",
"labelCurrent": "Dabartinis slaptažodis",
"labelNew": "Naujas slaptažodis",
"labelRepeat": "Pakartoti slaptažodį",

View file

@ -1117,7 +1117,6 @@
"passwordModal": {
"description": "Stel een wachtwoord in voor het afschermen van de instellingen. Het hoofdscherm blijft beschikbaar zonder wachtwoord.",
"empty": "Wachtwoord mag niet leeg zijn",
"error": "Fout: ",
"labelCurrent": "Huidig wachtwoord",
"labelNew": "Nieuw wachtwoord",
"labelRepeat": "Wachtwoord herhalen",

View file

@ -1108,7 +1108,6 @@
"passwordModal": {
"description": "Angi et passord for å beskytte konfigurasjonsinnstillingene. Det er fortsatt mulig å bruke hovedskjermen uten å logge inn.",
"empty": "Passordet må ikke være tomt",
"error": "Feil: ",
"labelCurrent": "Gjeldende passord",
"labelNew": "Nytt passord",
"labelRepeat": "Gjenta passord",

View file

@ -1109,7 +1109,6 @@
"passwordModal": {
"description": "Ustaw hasło, aby chronić ustawienia konfiguracji. Korzystanie z ekranu głównego jest nadal możliwe bez logowania.",
"empty": "Hasło nie powinno być puste",
"error": "Błąd: ",
"labelCurrent": "Aktualne hasło",
"labelNew": "Nowe hasło",
"labelRepeat": "Powtórz hasło",

View file

@ -1116,7 +1116,6 @@
"passwordModal": {
"description": "Defina uma Palavra-passe para proteger as definições de configuração. Continua a ser possível utilizar o menu principal sem iniciar sessão.",
"empty": "A Palavra-passe não deve ficar por preencher",
"error": "Erro: ",
"labelCurrent": "Palavra-passe atual",
"labelNew": "Nova Palavra-passe",
"labelRepeat": "Repetir palavra-passe",

View file

@ -1108,7 +1108,6 @@
"passwordModal": {
"description": "Setați o parolă pentru a proteja setările de configurare. Utilizarea ecranului principal este în continuare posibilă fără autentificare.",
"empty": "Parola nu trebuie să fie goală",
"error": "Eroare: ",
"labelCurrent": "Parola actuală",
"labelNew": "Parolă nouă",
"labelRepeat": "Repetați parola",

View file

@ -1107,7 +1107,6 @@
"passwordModal": {
"description": "Установите пароль для защиты настроек конфигурации. Использование главного экрана по-прежнему возможно без входа в систему.",
"empty": "Пароль не должен быть пустым",
"error": "Ошибка: ",
"labelCurrent": "Текущий пароль",
"labelNew": "Новый пароль",
"labelRepeat": "Повторите пароль",

View file

@ -1121,7 +1121,6 @@
"passwordModal": {
"description": "Nastavite geslo za zaščito konfiguracijskih nastavitev. Uporaba glavnega vmesnika je še vedno možna brez prijave.",
"empty": "Geslo ne sme biti prazno",
"error": "Napaka: ",
"labelCurrent": "Trenutno geslo",
"labelNew": "Novo geslo",
"labelRepeat": "Ponovi geslo",

View file

@ -1118,7 +1118,6 @@
"passwordModal": {
"description": "Använd lösenord för att skydda inställningarna. Huvudskärmen kan användas utan att logga in.",
"empty": "Lösenordet får inte vara tomt",
"error": "Fel: ",
"labelCurrent": "Nuvarande lösenord",
"labelNew": "Nytt lösenord",
"labelRepeat": "Upprepa lösenord",

View file

@ -895,7 +895,6 @@
"passwordModal": {
"description": "உள்ளமைவு அமைப்புகளைப் பாதுகாக்க கடவுச்சொல்லை அமைக்கவும். உள்நுழைவு இல்லாமல் முதன்மையான திரையைப் பயன்படுத்துவது இன்னும் சாத்தியமாகும்.",
"empty": "கடவுச்சொல் காலியாக இருக்கக்கூடாது",
"error": "பிழை: ",
"labelCurrent": "தற்போதைய கடவுச்சொல்",
"labelNew": "புதிய கடவுச்சொல்",
"labelRepeat": "கடவுச்சொல்லை மீண்டும்",

View file

@ -1116,7 +1116,6 @@
"passwordModal": {
"description": "Yapılandırma ayarlarını korumak için bir şifre belirle. Ana görünüme erişim oturum açmadan da mümkün.",
"empty": "Şifre boş olamaz",
"error": "Hata: ",
"labelCurrent": "Mevcut şifre",
"labelNew": "Yeni şifre",
"labelRepeat": "Yeni şifreyi tekrarla",

View file

@ -1118,7 +1118,6 @@
"passwordModal": {
"description": "Встановіть пароль для захисту параметрів конфігурації. Використання головного екрана все ще можливо без входу.",
"empty": "Пароль не повинен бути порожнім",
"error": "Помилка: ",
"labelCurrent": "Поточний пароль",
"labelNew": "Новий пароль",
"labelRepeat": "Повторіть пароль",

View file

@ -849,7 +849,6 @@
"passwordModal": {
"description": "设置密码以保护配置设置。主屏幕仍可在未登录情况下使用。",
"empty": "密码不能为空",
"error": "错误: ",
"labelCurrent": "当前密码",
"labelNew": "新密码",
"labelRepeat": "重复新密码",

View file

@ -120,7 +120,7 @@ func (s *HTTPd) Router() *mux.Router {
}
// RegisterSiteHandlers connects the http handlers to the site
func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param) {
func (s *HTTPd) RegisterSiteHandlers(site site.API) {
router := s.Server.Handler.(*mux.Router)
// api
@ -293,6 +293,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
"experimental": {"POST", "/experimental/{value:[01truefalse]+}", boolHandler(setExperimental(pub), getExperimental)},
}
// yaml handlers

View file

@ -10,6 +10,19 @@ import (
"github.com/evcc-io/evcc/util/sponsor"
)
func setExperimental(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Experimental, b)
pub(keys.Experimental, b)
return nil
}
}
func getExperimental() bool {
b, _ := settings.Bool(keys.Experimental)
return b
}
func updateSponsortokenHandler(pub publisher) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {

View file

@ -62,6 +62,32 @@ test.describe("general", async () => {
await page.getByTestId("home-link").click();
await expect(page.getByRole("heading", { name: "Ahoy World" })).toBeVisible();
});
test("enable experimental", async ({ page }) => {
await page.goto("/#/config");
const experimentalEntry = page.getByTestId("generalconfig-experimental");
await expect(experimentalEntry).toContainText("off");
await experimentalEntry.getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("experimental-modal");
await expectModalVisible(modal);
const experimentalInput = modal.getByLabel("Enable experimental features.");
await expect(experimentalInput).not.toBeChecked();
await experimentalInput.click();
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(experimentalEntry).toContainText("on");
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(experimentalEntry).toContainText("on");
await experimentalEntry.getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await expect(experimentalInput).toBeChecked();
});
});
test.describe("network modal", async () => {