UI: add custom CSS support (#21263)

This commit is contained in:
Michael Geers 2025-05-20 10:16:14 +02:00 • committed by GitHub
parent 7330fdff63
commit 92ef228af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 96 additions and 14 deletions

View file

@ -30,6 +30,7 @@
version: "[[.Version]]",
configured: "[[.Configured]]",
commit: "[[.Commit]]",
customCss: [[.CustomCss]],
};
</script>

View file

@ -75,3 +75,10 @@ window.app = app.mount("#app");
watchThemeChanges();
appDetection();
if (window.evcc.customCss) {
const link = document.createElement("link");
link.href = `./custom.css`;
link.rel = "stylesheet";
document.head.appendChild(link);
}

View file

@ -1,5 +1,5 @@
<template>
<footer class="footer">
<footer class="footer" data-testid="footer">
<div class="container py-2">
<div class="d-flex justify-content-between">
<Version v-bind="version" />

View file

@ -1,7 +1,10 @@
<template>
<div class="d-flex flex-column site safe-area-inset">
<div class="container px-4 top-area">
<div class="d-flex justify-content-between align-items-center my-3 my-md-4">
<div
class="d-flex justify-content-between align-items-center my-3 my-md-4"
data-testid="header"
>
<h1 class="d-block my-0">
<span v-if="!isInitialSetup">
{{ siteTitle || "evcc" }}

View file

@ -1,5 +1,8 @@
<template>
<header class="d-flex justify-content-between align-items-center py-3 py-md-4">
<header
class="d-flex justify-content-between align-items-center py-3 py-md-4"
data-testid="header"
>
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
<router-link class="evcc-default-text" to="/" data-testid="home-link">
<shopicon-regular-home size="s" class="icon"></shopicon-regular-home>

View file

@ -22,6 +22,9 @@ const (
flagDisableAuth = "disable-auth"
flagDisableAuthDescription = "Disable authentication (dangerous)"
flagCustomCss = "custom-css"
flagCustomCssDescription = "Additional user-defined CSS file for custom styling. No compatibility guarantees."
flagBatteryMode = "battery-mode"
flagBatteryModeDescription = "Set battery mode (normal, hold, charge)"
flagBatteryModeWait = "battery-mode-wait"

View file

@ -39,7 +39,7 @@ var (
log = util.NewLogger("main")
cfgFile string
cfgDatabase string
customCssFile string
ignoreEmpty = "" // ignore empty keys
ignoreLogs = []string{"log"} // ignore log messages, including warn/error
ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers
@ -70,6 +70,7 @@ func init() {
rootCmd.PersistentFlags().Bool(flagIgnoreDatabase, false, flagIgnoreDatabaseDescription)
rootCmd.PersistentFlags().String(flagTemplate, "", flagTemplateDescription)
rootCmd.PersistentFlags().String(flagTemplateType, "", flagTemplateTypeDescription)
rootCmd.PersistentFlags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription)
// config file options
rootCmd.PersistentFlags().StringP("log", "l", "info", "Log level (fatal, error, warn, info, debug, trace)")
@ -159,7 +160,7 @@ func runRoot(cmd *cobra.Command, args []string) {
// create web server
socketHub := server.NewSocketHub()
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub)
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customCssFile)
// metrics
if viper.GetBool("metrics") {

View file

@ -3,6 +3,7 @@ package server
import (
"fmt"
"net/http"
"os"
"time"
eapi "github.com/evcc-io/evcc/api"
@ -40,7 +41,7 @@ type HTTPd struct {
}
// NewHTTPd creates HTTP server with configured routes for loadpoint
func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
func NewHTTPd(addr string, hub *SocketHub, customCssFile string) *HTTPd {
router := mux.NewRouter().StrictSlash(true)
log := util.NewLogger("httpd")
@ -72,10 +73,25 @@ func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
})
})
static.HandleFunc("/", indexHandler())
if customCssFile != "" {
log.WARN.Printf("❗ using custom CSS: %s", customCssFile)
if _, err := os.Stat(customCssFile); os.IsNotExist(err) {
log.FATAL.Fatalf("custom CSS file does not exist: %s", customCssFile)
}
static.HandleFunc("/custom.css", func(w http.ResponseWriter, r *http.Request) {
// disable caching
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
http.ServeFile(w, r, customCssFile)
})
}
static.HandleFunc("/", indexHandler(customCssFile != ""))
for _, dir := range []string{"assets", "meta"} {
static.PathPrefix("/" + dir).Handler(http.FileServer(http.FS(assets.Web)))
}
static.PathPrefix("/i18n").Handler(http.StripPrefix("/i18n", http.FileServer(http.FS(assets.I18n))))
srv := &HTTPd{

View file

@ -37,7 +37,7 @@ func getPreferredLanguage(header string) string {
return base.String()
}
func indexHandler() http.HandlerFunc {
func indexHandler(customCss bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
@ -60,6 +60,7 @@ func indexHandler() http.HandlerFunc {
"Version": util.Version,
"Commit": util.Commit,
"DefaultLang": defaultLang,
"CustomCss": customCss,
}); err != nil {
log.ERROR.Println("httpd: failed to render main page:", err.Error())
}

View file

@ -81,3 +81,15 @@ test.describe("loadpoint settings", async () => {
await expect(page.getByText("~ 1.4 kW")).toBeVisible();
});
});
test.describe("network requests", async () => {
test("no failed requests", async ({ page }) => {
const failedRequests = [];
page.on("requestfailed", (request) => failedRequests.push(request.url()));
await page.reload();
await page.waitForLoadState("networkidle");
expect(failedRequests).toHaveLength(0);
});
});

12
tests/custom-css.css Normal file
View file

@ -0,0 +1,12 @@
:root {
--evcc-dark-green: rebeccapurple;
--evcc-yellow: hotpink;
}
[data-testid="header"],
[data-testid="footer"] {
display: none !important;
}
/* test against markup injection */
</style><script>alert("hello");</script><style>

23
tests/custom-css.spec.js Normal file
View file

@ -0,0 +1,23 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start("basics.evcc.yaml", null, ["--disable-auth", "--custom-css", "tests/custom-css.css"]);
});
test.afterAll(async () => {
await stop();
});
test.describe("custom-css", async () => {
test("header and footer are hidden", async ({ page }) => {
page.on("dialog", () => {
throw new Error("XSS: inline script detected");
});
await page.goto("/");
await expect(page.getByTestId("header")).toBeHidden();
await expect(page.getByTestId("footer")).toBeHidden();
});
});