feat(ui): Allow configuring default dark-mode value (#1015)

* fix: theme flickering

* chore(ui): added dark mode tests

* feat(ui): Expose new ui.dark-mode parameter to set default theme

* refactor(ui): Rename theme variable to themeFromCookie for clarity

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: TwiN <chris@twin.sh>
This commit is contained in:
Xetera 2025-03-08 04:32:05 +03:00 committed by GitHub
parent fc07f15b67
commit e0bdda5225
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 94 additions and 31 deletions

View File

@ -242,6 +242,7 @@ If you want to test it locally, see [Docker](#docker).
| `ui.buttons[].name` | Text to display on the button. | Required `""` | | `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` | | `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` | | `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` | | `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`. If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.

View File

@ -10,8 +10,19 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func SinglePageApplication(ui *ui.Config) fiber.Handler { func SinglePageApplication(uiConfig *ui.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
vd := ui.ViewData{UI: uiConfig}
{
themeFromCookie := string(c.Request().Header.Cookie("theme"))
if len(themeFromCookie) > 0 {
if themeFromCookie == "dark" {
vd.Theme = "dark"
}
} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode
vd.Theme = "dark"
}
}
t, err := template.ParseFS(static.FileSystem, static.IndexPath) t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
@ -19,7 +30,7 @@ func SinglePageApplication(ui *ui.Config) fiber.Handler {
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
err = t.Execute(c, ui) err = t.Execute(c, vd)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error()) logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())

View File

@ -41,27 +41,48 @@ func TestSinglePageApplication(t *testing.T) {
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
ExpectedCode int
Gzip bool Gzip bool
CookieDarkMode bool
UIDarkMode bool
ExpectedCode int
ExpectedDarkTheme bool
} }
scenarios := []Scenario{ scenarios := []Scenario{
{ {
Name: "frontend-home", Name: "frontend-home",
Path: "/", Path: "/",
CookieDarkMode: true,
UIDarkMode: false,
ExpectedDarkTheme: true,
ExpectedCode: 200, ExpectedCode: 200,
}, },
{ {
Name: "frontend-endpoint", Name: "frontend-endpoint-light",
Path: "/endpoints/core_frontend", Path: "/endpoints/core_frontend",
CookieDarkMode: false,
UIDarkMode: false,
ExpectedDarkTheme: false,
ExpectedCode: 200,
},
{
Name: "frontend-endpoint-dark",
Path: "/endpoints/core_frontend",
CookieDarkMode: false,
UIDarkMode: true,
ExpectedDarkTheme: true,
ExpectedCode: 200, ExpectedCode: 200,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg.UI.DarkMode = &scenario.UIDarkMode
request := httptest.NewRequest("GET", scenario.Path, http.NoBody) request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip { if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip") request.Header.Set("Accept-Encoding", "gzip")
} }
if scenario.CookieDarkMode {
request.Header.Set("Cookie", "theme=dark")
}
response, err := router.Test(request) response, err := router.Test(request)
if err != nil { if err != nil {
return return
@ -71,9 +92,16 @@ func TestSinglePageApplication(t *testing.T) {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode) t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
} }
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
if !strings.Contains(string(body), cfg.UI.Title) { strBody := string(body)
if !strings.Contains(strBody, cfg.UI.Title) {
t.Errorf("%s %s should have contained the title", request.Method, request.URL) t.Errorf("%s %s should have contained the title", request.Method, request.URL)
} }
if scenario.ExpectedDarkTheme && !strings.Contains(strBody, "class=\"dark\"") {
t.Errorf("%s %s should have responded with dark mode headers", request.Method, request.URL)
}
if !scenario.ExpectedDarkTheme && strings.Contains(strBody, "class=\"dark\"") {
t.Errorf("%s %s should not have responded with dark mode headers", request.Method, request.URL)
}
}) })
} }
} }

View File

@ -18,6 +18,8 @@ const (
) )
var ( var (
defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link") ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
) )
@ -30,6 +32,14 @@ type Config struct {
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
}
func (cfg *Config) IsDarkMode() bool {
if cfg.DarkMode != nil {
return *cfg.DarkMode
}
return defaultDarkMode
} }
// Button is the configuration for a button on the UI // Button is the configuration for a button on the UI
@ -55,6 +65,7 @@ func GetDefaultConfig() *Config {
Logo: defaultLogo, Logo: defaultLogo,
Link: defaultLink, Link: defaultLink,
CustomCSS: defaultCustomCSS, CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
} }
} }
@ -78,6 +89,9 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.CustomCSS) == 0 { if len(cfg.CustomCSS) == 0 {
cfg.CustomCSS = defaultCustomCSS cfg.CustomCSS = defaultCustomCSS
} }
if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}
for _, btn := range cfg.Buttons { for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil { if err := btn.Validate(); err != nil {
return err return err
@ -89,5 +103,10 @@ func (cfg *Config) ValidateAndSetDefaults() error {
return err return err
} }
var buffer bytes.Buffer var buffer bytes.Buffer
return t.Execute(&buffer, cfg) return t.Execute(&buffer, ViewData{UI: cfg, Theme: "dark"})
}
type ViewData struct {
UI *Config
Theme string
} }

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{{ .Theme }}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<script type="text/javascript"> <script type="text/javascript">
window.config = {logo: "{{ .Logo }}", header: "{{ .Header }}", link: "{{ .Link }}", buttons: []};{{- range .Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}} window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: []};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
</script> </script>
<title>{{ .Title }}</title> <title>{{ .UI.Title }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@ -14,10 +14,10 @@
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/custom.css" /> <link rel="stylesheet" href="/css/custom.css" />
<meta name="description" content="{{ .Description }}" /> <meta name="description" content="{{ .UI.Description }}" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="{{ .Title }}" /> <meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}" />
<meta name="application-name" content="{{ .Title }}" /> <meta name="application-name" content="{{ .UI.Title }}" />
<meta name="theme-color" content="#f7f9fb" /> <meta name="theme-color" content="#f7f9fb" />
</head> </head>
<body class="dark:bg-gray-900"> <body class="dark:bg-gray-900">

View File

@ -78,13 +78,13 @@ export default {
}, },
computed: { computed: {
logo() { logo() {
return window.config && window.config.logo && window.config.logo !== '{{ .Logo }}' ? window.config.logo : ""; return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : "";
}, },
header() { header() {
return window.config && window.config.header && window.config.header !== '{{ .Header }}' ? window.config.header : "Health Status"; return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Health Status";
}, },
link() { link() {
return window.config && window.config.link && window.config.link !== '{{ .Link }}' ? window.config.link : null; return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null;
}, },
buttons() { buttons() {
return window.config && window.config.buttons ? window.config.buttons : []; return window.config && window.config.buttons ? window.config.buttons : [];

View File

@ -23,6 +23,11 @@
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid' import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid'
import { ArrowPathIcon } from '@heroicons/vue/24/solid' import { ArrowPathIcon } from '@heroicons/vue/24/solid'
function wantsDarkMode() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
return themeFromCookie === 'dark' || !themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark"));
}
export default { export default {
name: 'Settings', name: 'Settings',
components: { components: {
@ -48,15 +53,15 @@ export default {
this.setRefreshInterval(this.$refs.refreshInterval.value); this.setRefreshInterval(this.$refs.refreshInterval.value);
}, },
toggleDarkMode() { toggleDarkMode() {
if (localStorage.theme === 'dark') { if (wantsDarkMode()) {
localStorage.theme = 'light'; document.cookie = `theme=light; path=/; max-age=31536000; samesite=strict`;
} else { } else {
localStorage.theme = 'dark'; document.cookie = `theme=dark; path=/; max-age=31536000; samesite=strict`;
} }
this.applyTheme(); this.applyTheme();
}, },
applyTheme() { applyTheme() {
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (wantsDarkMode()) {
this.darkMode = true; this.darkMode = true;
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} else { } else {
@ -70,7 +75,6 @@ export default {
this.refreshInterval = 300; this.refreshInterval = 300;
} }
this.setRefreshInterval(this.refreshInterval); this.setRefreshInterval(this.refreshInterval);
// dark mode
this.applyTheme(); this.applyTheme();
}, },
unmounted() { unmounted() {
@ -80,7 +84,7 @@ export default {
return { return {
refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')), refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')),
refreshIntervalHandler: 0, refreshIntervalHandler: 0,
darkMode: true darkMode: wantsDarkMode()
} }
}, },
} }

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .Logo }}", header: "{{ .Header }}", link: "{{ .Link }}", buttons: []};{{- range .Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}</script><title>{{ .Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,initial-scale=1"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"/><link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/><link rel="shortcut icon" href="/favicon.ico"/><link rel="stylesheet" href="/css/custom.css"/><meta name="description" content="{{ .Description }}"/><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/><meta name="apple-mobile-web-app-title" content="{{ .Title }}"/><meta name="application-name" content="{{ .Title }}"/><meta name="theme-color" content="#f7f9fb"/><script defer="defer" src="/js/chunk-vendors.js"></script><script defer="defer" src="/js/app.js"></script><link href="/css/app.css" rel="stylesheet"></head><body class="dark:bg-gray-900"><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html> <!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: []};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}</script><title>{{ .UI.Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,initial-scale=1"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"/><link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/><link rel="shortcut icon" href="/favicon.ico"/><link rel="stylesheet" href="/css/custom.css"/><meta name="description" content="{{ .UI.Description }}"/><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/><meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}"/><meta name="application-name" content="{{ .UI.Title }}"/><meta name="theme-color" content="#f7f9fb"/><script defer="defer" src="/js/chunk-vendors.js"></script><script defer="defer" src="/js/app.js"></script><link href="/css/app.css" rel="stylesheet"></head><body class="dark:bg-gray-900"><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long