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[].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`.
|
||||||
|
15
api/spa.go
15
api/spa.go
@ -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())
|
||||||
|
@ -39,29 +39,50 @@ func TestSinglePageApplication(t *testing.T) {
|
|||||||
api := New(cfg)
|
api := New(cfg)
|
||||||
router := api.Router()
|
router := api.Router()
|
||||||
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: "/",
|
||||||
ExpectedCode: 200,
|
CookieDarkMode: true,
|
||||||
|
UIDarkMode: false,
|
||||||
|
ExpectedDarkTheme: true,
|
||||||
|
ExpectedCode: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "frontend-endpoint",
|
Name: "frontend-endpoint-light",
|
||||||
Path: "/endpoints/core_frontend",
|
Path: "/endpoints/core_frontend",
|
||||||
ExpectedCode: 200,
|
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 {
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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 : [];
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -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