Start UI early (#23767)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
andig 2025-11-14 10:19:08 +01:00 • committed by GitHub
parent 6a0cdbb724
commit bb61acc99c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 108 additions and 47 deletions

View file

@ -108,7 +108,13 @@ function debounedUpdateAuthStatus() {
}
// update auth status on reconnect or server restart
watch(() => store.state.offline, debounedUpdateAuthStatus);
watch(() => store.state.startup, debounedUpdateAuthStatus);
watch(
() => store.state.offline,
(offline) => offline && debounedUpdateAuthStatus()
);
watch(
() => store.state.startupCompleted,
(startupCompleted) => startupCompleted && debounedUpdateAuthStatus()
);
export default auth;

View file

@ -1,6 +1,6 @@
<template>
<div data-testid="offline-indicator" :aria-hidden="!visible">
<div v-if="offline" class="modal-backdrop" />
<div v-if="offline || starting" class="modal-backdrop" />
<div
class="fixed-bottom alert d-flex justify-content-center align-items-center mb-0 rounded-0 p-2"
:class="{ visible: visible, 'alert-danger': showError, 'alert-secondary': !showError }"
@ -23,6 +23,14 @@
<CloudOffline class="m-2" />
{{ $t("offline.message") }}
</div>
<div v-else-if="starting" class="d-flex align-items-center">
<span
class="spinner-border spinner-border-sm m-1 me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("offline.starting") }}
</div>
<div
v-else-if="showError"
class="d-flex align-items-center container px-4 justify-content-center"
@ -67,6 +75,7 @@ export default defineComponent({
props: {
offline: Boolean,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
startupCompleted: Boolean,
},
data() {
return { dismissed: false };
@ -78,8 +87,17 @@ export default defineComponent({
restarting() {
return restart.restarting;
},
starting() {
return this.startupCompleted === false;
},
visible() {
return this.offline || this.restartNeeded || this.restarting || this.showError;
return (
this.starting ||
this.offline ||
this.restartNeeded ||
this.restarting ||
this.showError
);
},
showError() {
return (
@ -115,16 +133,19 @@ export default defineComponent({
.alert {
opacity: 0;
transform: translateY(100%);
min-height: 58px;
transition:
transform var(--evcc-transition-fast) ease-in,
opacity var(--evcc-transition-fast) ease-in;
min-height: 58px;
/* above backdrop, below modal https://getbootstrap.com/docs/5.3/layout/z-index/ */
z-index: 1054 !important;
}
.alert.visible {
opacity: 1;
transform: translateY(0);
transition:
transform var(--evcc-transition-medium) ease-in,
opacity var(--evcc-transition-medium) ease-in;
}
.fatal-icon {

View file

@ -6,7 +6,7 @@
data-testid="header"
>
<h1 class="d-block my-0">
<span v-if="!isInitialSetup">
<span v-if="!setupRequired">
{{ siteTitle || "evcc" }}
</span>
</h1>
@ -20,17 +20,25 @@
</div>
</div>
<HemsWarning :circuits="circuits" />
<Energyflow v-if="loadpoints.length > 0" v-bind="energyflow" />
<Energyflow v-if="!setupRequired" v-bind="energyflow" />
</div>
<div class="d-flex flex-column justify-content-between content-area">
<div
v-if="hasFatalError"
class="flex-grow-1 align-items-center d-flex justify-content-center"
>
<h1 class="mb-5 text-gray fs-4">{{ $t("startupError.title") }}</h1>
<div class="d-flex flex-column align-items-center mb-5 gap-4 mx-4 text-center">
<h1 class="text-gray fs-4 my-0">{{ $t("startupError.title") }}</h1>
<p v-for="fatalText in fatalTexts" :key="fatalText" class="text-break my-0">
{{ fatalText }}
</p>
<router-link class="btn btn-secondary" to="/config">
{{ $t("startupError.editConfiguration") }}
</router-link>
</div>
</div>
<div
v-else-if="isInitialSetup"
v-else-if="setupRequired"
class="flex-grow-1 d-flex align-items-center justify-content-center p-3"
>
<div
@ -118,6 +126,7 @@ export default defineComponent({
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
offline: Boolean,
setupRequired: Boolean,
// details
gridConfigured: Boolean,
@ -199,9 +208,6 @@ export default defineComponent({
// work in progess
return false;
},
isInitialSetup() {
return this.loadpoints.length === 0;
},
footer() {
return {
version: {
@ -226,6 +232,11 @@ export default defineComponent({
hasFatalError() {
return this.fatal.length > 0;
},
fatalTexts() {
return this.fatal.map(({ error, class: errorClass }) =>
errorClass ? `${errorClass}: ${error}` : error
);
},
},
methods: {
selectedLoadpointChanged(id: string | undefined) {

View file

@ -50,7 +50,8 @@ export interface FatalError {
export interface State {
offline: boolean;
startup?: boolean;
setupRequired?: boolean;
startupCompleted?: boolean;
loadpoints: Loadpoint[];
forecast: Forecast;
currency?: CURRENCY;

View file

@ -1,6 +1,10 @@
<template>
<div class="app">
<router-view :notifications="notifications" :offline="offline"></router-view>
<router-view
v-if="showRoutes"
:notifications="notifications"
:offline="offline"
></router-view>
<GlobalSettingsModal v-bind="globalSettingsProps" />
<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
@ -67,6 +71,9 @@ export default defineComponent({
batteryModalAvailabe() {
return store.state.battery?.length;
},
showRoutes() {
return this.state.startupCompleted;
},
state() {
const { state, uiLoadpoints } = store;
return { ...state, uiLoadpoints: uiLoadpoints.value };
@ -182,7 +189,7 @@ export default defineComponent({
this.ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.startup) {
if (msg.startupCompleted) {
store.reset();
}
store.update(msg);

View file

@ -1,6 +1,5 @@
<template>
<Site
v-if="state.startup"
:notifications="notifications"
v-bind="state"
:selected-loadpoint-index="selectedLoadpointIndex"

View file

@ -157,8 +157,6 @@ func runRoot(cmd *cobra.Command, args []string) {
err = networkSettings(&conf.Network)
}
log.INFO.Printf("UI listening at :%d", conf.Network.Port)
// start broadcasting values
tee := new(util.Tee)
valueChan := make(chan util.Param, 64)
@ -172,6 +170,21 @@ func runRoot(cmd *cobra.Command, args []string) {
socketHub := server.NewSocketHub()
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customCssFile)
// start serving in background, watch for “routine‐only” errors
go func() {
if err := wrapFatalError(httpd.Server.ListenAndServe()); err != nil && err != http.ErrServerClosed {
log.FATAL.Println(err)
os.Exit(1)
}
}()
log.INFO.Printf("UI listening at :%d", conf.Network.Port)
// publish to UI
go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
// signal ui listening
valueChan <- util.Param{Key: keys.StartupCompleted, Val: false}
// metrics
if viper.GetBool("metrics") {
httpd.Router().Handle("/metrics", promhttp.Handler())
@ -182,9 +195,6 @@ func runRoot(cmd *cobra.Command, args []string) {
httpd.Router().PathPrefix("/debug/").Handler(http.DefaultServeMux)
}
// publish to UI
go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
// capture log messages for UI
util.CaptureLogs(valueChan)
@ -236,8 +246,10 @@ func runRoot(cmd *cobra.Command, args []string) {
}
}
// signal restart
valueChan <- util.Param{Key: keys.Startup, Val: true}
// signal devices initialized
valueChan <- util.Param{Key: keys.StartupCompleted, Val: true}
// show onboarding UI
valueChan <- util.Param{Key: keys.SetupRequired, Val: site == nil || len(site.Loadpoints()) == 0}
// setup mqtt publisher
if err == nil && conf.Mqtt.Broker != "" && conf.Mqtt.Topic != "" {
@ -310,19 +322,6 @@ func runRoot(cmd *cobra.Command, args []string) {
once.Do(func() { close(stopC) }) // signal loop to end
}()
// wait for shutdown
go func() {
<-stopC
select {
case <-shutdownDoneC(): // wait for shutdown
case <-time.After(conf.Interval):
}
// exit code 1 on error
os.Exit(cast.ToInt(err != nil))
}()
// allow web access for vehicles
configureAuth(httpd.Router(), valueChan)
@ -383,5 +382,14 @@ func runRoot(cmd *cobra.Command, args []string) {
// uds health check listener
go server.HealthListener(site)
log.FATAL.Println(wrapFatalError(httpd.ListenAndServe()))
// wait for shutdown
<-stopC
select {
case <-shutdownDoneC(): // wait for shutdown
case <-time.After(conf.Interval):
}
// exit code 1 on error
os.Exit(cast.ToInt(err != nil))
}

View file

@ -18,7 +18,8 @@ const (
Config = "config"
Database = "database"
Fatal = "fatal"
Startup = "startup"
StartupCompleted = "startupCompleted" // false: starting, true: started
SetupRequired = "setupRequired" // initial setup is required (lp = 0), fresh installation
Plant = "plant"
Telemetry = "telemetry"
DemoMode = "demoMode"

View file

@ -1017,7 +1017,8 @@
"message": "Keine Verbindung zum Server.",
"restart": "Neustart",
"restartNeeded": "Erforderlich, um Änderungen zu übernehmen.",
"restarting": "Server ist gleich wieder verfügbar."
"restarting": "Server ist gleich wieder verfügbar.",
"starting": "Server wird gestartet..."
},
"passwordModal": {
"description": "Setze ein Passwort, um die Konfiguration zu schützen. Die Hauptansicht bleibt ohne Login zugänglich.",
@ -1206,6 +1207,7 @@
"configuration": "Konfiguration",
"description": "Bitte überprüfe deine Konfigurationsdatei. Sollte dir die Fehlermeldung nicht weiterhelfen, suche in unseren {0} nach einer Lösung.",
"discussions": "GitHub Diskussionen",
"editConfiguration": "Konfiguration bearbeiten",
"fixAndRestart": "Behebe das Problem und starte den Server neu.",
"hint": "Hinweis: Ein weiterer Grund könnte ein fehlerhaftes Gerät (Wechselrichter, Zähler, …) sein. Überprüfe deine Netzwerkverbindungen.",
"lineError": "In {0} wurde ein Fehler gefunden.",

View file

@ -1018,7 +1018,8 @@
"message": "Not connected to a server.",
"restart": "Restart",
"restartNeeded": "Required to apply changes.",
"restarting": "Server will be back in a moment."
"restarting": "Server will be back in a moment.",
"starting": "Starting server..."
},
"passwordModal": {
"description": "Set a password to protect the configuration settings. Using the main screen is still possible without login.",
@ -1207,6 +1208,7 @@
"configuration": "Config",
"description": "Please check your configuration file. If the error message does not help, check out the {0}.",
"discussions": "GitHub Discussions",
"editConfiguration": "Edit configuration",
"fixAndRestart": "Please fix the problem and restart the server.",
"hint": "Note: It could also be you have a faulty device (inverter, meter, …). Check your network connections.",
"lineError": "Error in {0}.",

View file

@ -32,7 +32,7 @@ test.describe("modbus fields", async () => {
const modal = await openMeterModal(page, "TCP Test");
await expect(page.getByLabel("Network")).toBeChecked();
await expect(page.getByLabel("TCP")).toBeChecked();
await expect(modal.getByLabel("IP address or hostname")).toHaveValue("192.168.1.10");
await expect(modal.getByLabel("IP address or hostname")).toHaveValue("192.0.2.1");
await expect(modal.getByLabel("Port", { exact: true })).toHaveValue("5020");
await expect(modal.getByLabel("Modbus ID")).toHaveValue("10");
});
@ -41,7 +41,7 @@ test.describe("modbus fields", async () => {
const modal = await openMeterModal(page, "RTU/IP Test");
await expect(page.getByLabel("Network")).toBeChecked();
await expect(page.getByLabel("RTU")).toBeChecked();
await expect(modal.getByLabel("IP address or hostname")).toHaveValue("192.168.1.20");
await expect(modal.getByLabel("IP address or hostname")).toHaveValue("198.51.100.1");
await expect(modal.getByLabel("Port", { exact: true })).toHaveValue("8899");
await expect(modal.getByLabel("Modbus ID")).toHaveValue("20");
});

View file

@ -15,8 +15,9 @@ CREATE TABLE `settings` (
, PRIMARY KEY(`key`)
);
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(1, 2, 'template', 'TCP Test', '', 'SunSpec Inverter', '{"host":"192.168.1.10","id":10,"modbus":"tcpip","port":5020,"template":"sunspec-inverter","usage":"pv"}');
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(2, 2, 'template', 'RTU/IP Test', '', 'SunSpec Inverter', '{"host":"192.168.1.20","id":20,"modbus":"rs485tcpip","port":8899,"template":"sunspec-inverter","usage":"pv"}');
-- using RFC 5737 TEST-NET addresses (192.0.2.0/24, 198.51.100.0/24) that are guaranteed to fail connection attempts
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(1, 2, 'template', 'TCP Test', '', 'SunSpec Inverter', '{"host":"192.0.2.1","id":10,"modbus":"tcpip","port":5020,"template":"sunspec-inverter","usage":"pv"}');
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(2, 2, 'template', 'RTU/IP Test', '', 'SunSpec Inverter', '{"host":"198.51.100.1","id":20,"modbus":"rs485tcpip","port":8899,"template":"sunspec-inverter","usage":"pv"}');
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(3, 2, 'template', 'Serial Test', '', 'SunSpec Inverter', '{"baudrate":19200,"comset":"8E1","device":"/dev/ttyUSB5","id":30,"modbus":"rs485serial","template":"sunspec-inverter","usage":"pv"}');
INSERT INTO settings("key", value) VALUES('pvMeters', 'db:1,db:2,db:3');

View file

@ -1,11 +1,13 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
test.beforeEach(async () => {
await start("basics.evcc.yaml");
});
test.afterAll(async () => {
test.afterEach(async () => {
await stop();
});