Skip to content
Open
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ If you want to test it locally, see [Docker](#docker).
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
| `ui.config-refresh-interval` | Interval for the UI config update used to update announcements (e.g. `1h`, `30m`) | `10m` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |

If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Expand Down
2 changes: 1 addition & 1 deletion config/maintenance/maintenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

var (
errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)")
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)")
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 and smaller than 24h (e.g. 30m)")
errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames)
errInvalidTimezone = errors.New("invalid timezone specified or format not supported. Use IANA timezone format (e.g. America/Sao_Paulo)")

Expand Down
10 changes: 9 additions & 1 deletion config/maintenance/maintenance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,21 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-duration",
name: "invalid-duration-zero",
cfg: &Config{
Start: "23:00",
Duration: 0,
},
expectedError: errInvalidMaintenanceDuration,
},
{
name: "invalid-duration-too-long",
cfg: &Config{
Start: "23:00",
Duration: 25 * time.Hour,
},
expectedError: errInvalidMaintenanceDuration,
},
{
name: "invalid-timezone",
cfg: &Config{
Expand Down
80 changes: 45 additions & 35 deletions config/ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,24 @@ import (
"bytes"
"errors"
"html/template"
"time"

"github.com/TwiN/gatus/v5/storage"
static "github.com/TwiN/gatus/v5/web"
)

const (
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Gatus"
defaultDashboardHeading = "Health Dashboard"
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Gatus"
defaultDashboardHeading = "Health Dashboard"
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
defaultConfigRefreshInterval = 10 * time.Minute
)

var (
Expand All @@ -32,22 +34,24 @@ var (

// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
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
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
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
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
ConfigRefreshInterval time.Duration `yaml:"config-refresh-interval,omitempty"` // ConfigRefreshInterval is the interval at which to refresh the UI configuration via the API
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
//////////////////////////////////////////////
MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
ConfigRefreshIntervalMs int64 `yaml:"-"` // ConfigRefreshIntervalMs Internal interval to be able to convert to milliseconds for the frontend before templating
}

func (cfg *Config) IsDarkMode() bool {
Expand All @@ -74,18 +78,20 @@ func (btn *Button) Validate() error {
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Description: defaultDescription,
DashboardHeading: defaultDashboardHeading,
DashboardSubheading: defaultDashboardSubheading,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
Title: defaultTitle,
Description: defaultDescription,
DashboardHeading: defaultDashboardHeading,
DashboardSubheading: defaultDashboardSubheading,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
ConfigRefreshInterval: defaultConfigRefreshInterval,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
ConfigRefreshIntervalMs: int64(defaultConfigRefreshInterval / time.Millisecond),
}
}

Expand Down Expand Up @@ -128,6 +134,10 @@ func (cfg *Config) ValidateAndSetDefaults() error {
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
if cfg.ConfigRefreshInterval == 0 {
cfg.ConfigRefreshInterval = defaultConfigRefreshInterval
}
cfg.ConfigRefreshIntervalMs = int64(cfg.ConfigRefreshInterval / time.Millisecond)
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err
Expand Down
76 changes: 67 additions & 9 deletions config/ui/ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"strconv"
"testing"
"time"
)

func TestConfig_ValidateAndSetDefaults(t *testing.T) {
Expand Down Expand Up @@ -41,18 +42,26 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
if cfg.ConfigRefreshInterval != defaultConfigRefreshInterval {
t.Errorf("expected ConfigRefreshInterval to be %s, got %s", defaultConfigRefreshInterval, cfg.ConfigRefreshInterval)
}
var expectedInterval = int64(defaultConfigRefreshInterval.Milliseconds())
if cfg.ConfigRefreshIntervalMs != expectedInterval {
t.Errorf("expected ConfigRefreshIntervalMs to be %d, got %d", expectedInterval, cfg.ConfigRefreshIntervalMs)
}
})
t.Run("custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
Description: "Custom Description",
DashboardHeading: "Production Status",
DashboardSubheading: "Monitor all production endpoints",
Header: "My Company",
Logo: "https://example.com/logo.png",
Link: "https://example.com",
DefaultSortBy: "health",
DefaultFilterBy: "failing",
Title: "Custom Title",
Description: "Custom Description",
DashboardHeading: "Production Status",
DashboardSubheading: "Monitor all production endpoints",
Header: "My Company",
Logo: "https://example.com/logo.png",
Link: "https://example.com",
DefaultSortBy: "health",
DefaultFilterBy: "failing",
ConfigRefreshInterval: time.Hour * 2,
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
Expand Down Expand Up @@ -84,6 +93,13 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.DefaultFilterBy != "failing" {
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
}
if cfg.ConfigRefreshInterval != time.Hour*2 {
t.Errorf("expected ConfigRefreshInterval to be preserved, got %s", cfg.ConfigRefreshInterval)
}
var expectedIntervalMs = int64((time.Hour * 2).Milliseconds())
if cfg.ConfigRefreshIntervalMs != expectedIntervalMs {
t.Errorf("expected ConfigRefreshIntervalMs to be %d, got %d", expectedIntervalMs, cfg.ConfigRefreshIntervalMs)
}
})
t.Run("partial-custom-values", func(t *testing.T) {
cfg := &Config{
Expand All @@ -110,6 +126,10 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Description != defaultDescription {
t.Errorf("expected description to use default, got %s", cfg.Description)
}
var expectedIntervalMs = int64(defaultConfigRefreshInterval.Milliseconds())
if cfg.ConfigRefreshIntervalMs != expectedIntervalMs {
t.Errorf("expected ConfigRefreshIntervalMs to be %d, got %d", expectedIntervalMs, cfg.ConfigRefreshIntervalMs)
}
})
}

Expand Down Expand Up @@ -277,3 +297,41 @@ func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
})
}
}

func TestConfig_ValidateAndSetDefaults_ConfigRefreshInterval(t *testing.T) {
scenarios := []struct {
Name string
ConfigRefreshInterval time.Duration
ExpectedError error
ExpectedValue time.Duration
}{
{
Name: "ZeroConfigRefreshInterval",
ConfigRefreshInterval: 0,
ExpectedError: nil,
ExpectedValue: defaultConfigRefreshInterval,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if that is whats expected. Like this it behaves the same as the endpoint interval option, there setting the value to zero does not make the config invalid but instead just uses the 1m default

Copy link
Contributor Author

@PythonGermany PythonGermany Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue could be solved in a similar way to how config bool values are handled:

gatus/config/ui/ui.go

Lines 118 to 120 in 1df0801

if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}

For a duration this would be:

  • nil -> set default
  • zero -> return error

Only question would whether to make a new pr where this is also "solved" for endpoint interval config and possible other occurrences. Changing this would only be a breaking change for invalid configurations.

},
{
Name: "ValidConfigRefreshInterval",
ConfigRefreshInterval: time.Second * 30,
ExpectedError: nil,
ExpectedValue: time.Second * 30,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{ConfigRefreshInterval: scenario.ConfigRefreshInterval}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.ConfigRefreshInterval != scenario.ExpectedValue {
t.Errorf("expected ConfigRefreshInterval to be %s, got %s", scenario.ExpectedValue, cfg.ConfigRefreshInterval)
}
var expectedIntervalMs = int64(scenario.ExpectedValue.Milliseconds())
if cfg.ConfigRefreshIntervalMs != expectedIntervalMs {
t.Errorf("expected ConfigRefreshIntervalMs to be %d, got %d", expectedIntervalMs, cfg.ConfigRefreshIntervalMs)
}
})
}
}
2 changes: 1 addition & 1 deletion web/app/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<script type="text/javascript">
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}", configRefreshInterval: "{{ .UI.ConfigRefreshIntervalMs }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
Expand Down
12 changes: 8 additions & 4 deletions web/app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,13 @@ const tooltip = ref({})
const mobileMenuOpen = ref(false)
const isOidcLoading = ref(false)
const tooltipIsPersistent = ref(false)
let configInterval = null
let fetchConfigTimerId = null

// Computed properties
const configRefreshInterval = computed(() => {
return window.config.configRefreshInterval != '{{ .UI.ConfigRefreshIntervalMs }}' ? window.config.configRefreshInterval : 600000
})

const logo = computed(() => {
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : ""
})
Expand Down Expand Up @@ -247,15 +251,15 @@ const handleDocumentClick = (event) => {
onMounted(() => {
fetchConfig()
// Refresh config every 10 minutes for announcements
configInterval = setInterval(fetchConfig, 600000)
fetchConfigTimerId = setInterval(fetchConfig, configRefreshInterval.value)
// Add click listener for closing persistent tooltips
document.addEventListener('click', handleDocumentClick)
})

// Clean up interval on unmount
onUnmounted(() => {
if (configInterval) {
clearInterval(configInterval)
if (fetchConfigTimerId) {
clearInterval(fetchConfigTimerId)
configInterval = null
}
// Remove click listener
Expand Down
2 changes: 1 addition & 1 deletion web/static/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}", configRefreshInterval: "{{ .UI.ConfigRefreshIntervalMs }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
Expand Down
2 changes: 1 addition & 1 deletion web/static/js/app.js

Large diffs are not rendered by default.