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:
parent
fc07f15b67
commit
e0bdda5225
@ -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[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `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). | `{}` |
|
||||
|
||||
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
||||
|
15
api/spa.go
15
api/spa.go
@ -10,8 +10,19 @@ import (
|
||||
"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 {
|
||||
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)
|
||||
if err != nil {
|
||||
// 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.")
|
||||
}
|
||||
c.Set("Content-Type", "text/html")
|
||||
err = t.Execute(c, ui)
|
||||
err = t.Execute(c, vd)
|
||||
if err != nil {
|
||||
// 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())
|
||||
|
@ -39,29 +39,50 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
Name string
|
||||
Path string
|
||||
Gzip bool
|
||||
CookieDarkMode bool
|
||||
UIDarkMode bool
|
||||
ExpectedCode int
|
||||
ExpectedDarkTheme bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: 200,
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
CookieDarkMode: true,
|
||||
UIDarkMode: false,
|
||||
ExpectedDarkTheme: true,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint",
|
||||
Path: "/endpoints/core_frontend",
|
||||
ExpectedCode: 200,
|
||||
Name: "frontend-endpoint-light",
|
||||
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,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg.UI.DarkMode = &scenario.UIDarkMode
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
if scenario.CookieDarkMode {
|
||||
request.Header.Set("Cookie", "theme=dark")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,8 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultDarkMode = true
|
||||
|
||||
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
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
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
|
||||
@ -55,6 +65,7 @@ func GetDefaultConfig() *Config {
|
||||
Logo: defaultLogo,
|
||||
Link: defaultLink,
|
||||
CustomCSS: defaultCustomCSS,
|
||||
DarkMode: &defaultDarkMode,
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,6 +89,9 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if len(cfg.CustomCSS) == 0 {
|
||||
cfg.CustomCSS = defaultCustomCSS
|
||||
}
|
||||
if cfg.DarkMode == nil {
|
||||
cfg.DarkMode = &defaultDarkMode
|
||||
}
|
||||
for _, btn := range cfg.Buttons {
|
||||
if err := btn.Validate(); err != nil {
|
||||
return err
|
||||
@ -89,5 +103,10 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="{{ .Theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
<title>{{ .Title }}</title>
|
||||
<title>{{ .UI.Title }}</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<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="shortcut icon" href="/favicon.ico" />
|
||||
<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-title" content="{{ .Title }}" />
|
||||
<meta name="application-name" content="{{ .Title }}" />
|
||||
<meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}" />
|
||||
<meta name="application-name" content="{{ .UI.Title }}" />
|
||||
<meta name="theme-color" content="#f7f9fb" />
|
||||
</head>
|
||||
<body class="dark:bg-gray-900">
|
||||
|
@ -78,13 +78,13 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
return window.config && window.config.buttons ? window.config.buttons : [];
|
||||
|
@ -23,6 +23,11 @@
|
||||
import { MoonIcon, SunIcon } from '@heroicons/vue/20/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 {
|
||||
name: 'Settings',
|
||||
components: {
|
||||
@ -48,15 +53,15 @@ export default {
|
||||
this.setRefreshInterval(this.$refs.refreshInterval.value);
|
||||
},
|
||||
toggleDarkMode() {
|
||||
if (localStorage.theme === 'dark') {
|
||||
localStorage.theme = 'light';
|
||||
if (wantsDarkMode()) {
|
||||
document.cookie = `theme=light; path=/; max-age=31536000; samesite=strict`;
|
||||
} else {
|
||||
localStorage.theme = 'dark';
|
||||
document.cookie = `theme=dark; path=/; max-age=31536000; samesite=strict`;
|
||||
}
|
||||
this.applyTheme();
|
||||
},
|
||||
applyTheme() {
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
if (wantsDarkMode()) {
|
||||
this.darkMode = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
@ -70,7 +75,6 @@ export default {
|
||||
this.refreshInterval = 300;
|
||||
}
|
||||
this.setRefreshInterval(this.refreshInterval);
|
||||
// dark mode
|
||||
this.applyTheme();
|
||||
},
|
||||
unmounted() {
|
||||
@ -80,7 +84,7 @@ export default {
|
||||
return {
|
||||
refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')),
|
||||
refreshIntervalHandler: 0,
|
||||
darkMode: true
|
||||
darkMode: wantsDarkMode()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user