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 // update auth status on reconnect or server restart
watch(() => store.state.offline, debounedUpdateAuthStatus); watch(
watch(() => store.state.startup, debounedUpdateAuthStatus); () => store.state.offline,
(offline) => offline && debounedUpdateAuthStatus()
);
watch(
() => store.state.startupCompleted,
(startupCompleted) => startupCompleted && debounedUpdateAuthStatus()
);
export default auth; export default auth;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -157,8 +157,6 @@ func runRoot(cmd *cobra.Command, args []string) {
err = networkSettings(&conf.Network) err = networkSettings(&conf.Network)
} }
log.INFO.Printf("UI listening at :%d", conf.Network.Port)
// start broadcasting values // start broadcasting values
tee := new(util.Tee) tee := new(util.Tee)
valueChan := make(chan util.Param, 64) valueChan := make(chan util.Param, 64)
@ -172,6 +170,21 @@ func runRoot(cmd *cobra.Command, args []string) {
socketHub := server.NewSocketHub() socketHub := server.NewSocketHub()
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customCssFile) 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 // metrics
if viper.GetBool("metrics") { if viper.GetBool("metrics") {
httpd.Router().Handle("/metrics", promhttp.Handler()) httpd.Router().Handle("/metrics", promhttp.Handler())
@ -182,9 +195,6 @@ func runRoot(cmd *cobra.Command, args []string) {
httpd.Router().PathPrefix("/debug/").Handler(http.DefaultServeMux) 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 // capture log messages for UI
util.CaptureLogs(valueChan) util.CaptureLogs(valueChan)
@ -236,8 +246,10 @@ func runRoot(cmd *cobra.Command, args []string) {
} }
} }
// signal restart // signal devices initialized
valueChan <- util.Param{Key: keys.Startup, Val: true} 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 // setup mqtt publisher
if err == nil && conf.Mqtt.Broker != "" && conf.Mqtt.Topic != "" { 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 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 // allow web access for vehicles
configureAuth(httpd.Router(), valueChan) configureAuth(httpd.Router(), valueChan)
@ -383,5 +382,14 @@ func runRoot(cmd *cobra.Command, args []string) {
// uds health check listener // uds health check listener
go server.HealthListener(site) 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" Config = "config"
Database = "database" Database = "database"
Fatal = "fatal" Fatal = "fatal"
Startup = "startup" StartupCompleted = "startupCompleted" // false: starting, true: started
SetupRequired = "setupRequired" // initial setup is required (lp = 0), fresh installation
Plant = "plant" Plant = "plant"
Telemetry = "telemetry" Telemetry = "telemetry"
DemoMode = "demoMode" DemoMode = "demoMode"

View file

@ -1017,7 +1017,8 @@
"message": "Keine Verbindung zum Server.", "message": "Keine Verbindung zum Server.",
"restart": "Neustart", "restart": "Neustart",
"restartNeeded": "Erforderlich, um Änderungen zu übernehmen.", "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": { "passwordModal": {
"description": "Setze ein Passwort, um die Konfiguration zu schützen. Die Hauptansicht bleibt ohne Login zugänglich.", "description": "Setze ein Passwort, um die Konfiguration zu schützen. Die Hauptansicht bleibt ohne Login zugänglich.",
@ -1206,6 +1207,7 @@
"configuration": "Konfiguration", "configuration": "Konfiguration",
"description": "Bitte überprüfe deine Konfigurationsdatei. Sollte dir die Fehlermeldung nicht weiterhelfen, suche in unseren {0} nach einer Lösung.", "description": "Bitte überprüfe deine Konfigurationsdatei. Sollte dir die Fehlermeldung nicht weiterhelfen, suche in unseren {0} nach einer Lösung.",
"discussions": "GitHub Diskussionen", "discussions": "GitHub Diskussionen",
"editConfiguration": "Konfiguration bearbeiten",
"fixAndRestart": "Behebe das Problem und starte den Server neu.", "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.", "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.", "lineError": "In {0} wurde ein Fehler gefunden.",

View file

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

View file

@ -32,7 +32,7 @@ test.describe("modbus fields", async () => {
const modal = await openMeterModal(page, "TCP Test"); const modal = await openMeterModal(page, "TCP Test");
await expect(page.getByLabel("Network")).toBeChecked(); await expect(page.getByLabel("Network")).toBeChecked();
await expect(page.getByLabel("TCP")).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("Port", { exact: true })).toHaveValue("5020");
await expect(modal.getByLabel("Modbus ID")).toHaveValue("10"); 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"); const modal = await openMeterModal(page, "RTU/IP Test");
await expect(page.getByLabel("Network")).toBeChecked(); await expect(page.getByLabel("Network")).toBeChecked();
await expect(page.getByLabel("RTU")).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("Port", { exact: true })).toHaveValue("8899");
await expect(modal.getByLabel("Modbus ID")).toHaveValue("20"); await expect(modal.getByLabel("Modbus ID")).toHaveValue("20");
}); });

View file

@ -15,8 +15,9 @@ CREATE TABLE `settings` (
, PRIMARY KEY(`key`) , 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"}'); -- 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(2, 2, 'template', 'RTU/IP Test', '', 'SunSpec Inverter', '{"host":"192.168.1.20","id":20,"modbus":"rs485tcpip","port":8899,"template":"sunspec-inverter","usage":"pv"}'); 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 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'); 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 { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc"; 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"); await start("basics.evcc.yaml");
}); });
test.afterAll(async () => { test.afterEach(async () => {
await stop(); await stop();
}); });