Start UI early (#23767)
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
6a0cdbb724
commit
bb61acc99c
13 changed files with 108 additions and 47 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export interface FatalError {
|
|||
|
||||
export interface State {
|
||||
offline: boolean;
|
||||
startup?: boolean;
|
||||
setupRequired?: boolean;
|
||||
startupCompleted?: boolean;
|
||||
loadpoints: Loadpoint[];
|
||||
forecast: Forecast;
|
||||
currency?: CURRENCY;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<template>
|
||||
<Site
|
||||
v-if="state.startup"
|
||||
:notifications="notifications"
|
||||
v-bind="state"
|
||||
:selected-loadpoint-index="selectedLoadpointIndex"
|
||||
|
|
|
|||
50
cmd/root.go
50
cmd/root.go
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}.",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue