feat(ui): New status page UI (#1198)

* feat(ui): New status page UI

* docs: Rename labels to extra-labels

* Fix domain expiration test

* feat(ui): Add ui.default-sort-by and ui.default-filter-by

* Change ui.header default value to Gatus

* Re-use EndpointCard in Details.vue as well to avoid duplicate code

* Fix flaky metrics test

* Add subtle green color to "Gatus"

* Remove duplicate title (tooltip is sufficient, no need for title on top of that)

* Fix collapsed group user preferences

* Update status page screenshots
This commit is contained in:
TwiN
2025-08-14 09:15:34 -04:00
committed by GitHub
parent 8d63462fcd
commit 440b732c71
54 changed files with 4251 additions and 2226 deletions

View File

@@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -118,6 +119,9 @@ func (config *Config) GetUniqueExtraMetricLabels() []string {
labels = append(labels, label)
}
}
if len(labels) > 1 {
sort.Strings(labels)
}
return labels
}

View File

@@ -12,28 +12,34 @@ import (
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 = "Health Status"
defaultHeader = "Gatus"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "nothing"
)
var (
defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'")
ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'nothing', 'failing', or 'unstable'")
)
// 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
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
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
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 ('nothing', 'failing', 'unstable')
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
@@ -72,6 +78,8 @@ func GetDefaultConfig() *Config {
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
}
}
@@ -99,6 +107,16 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}
if len(cfg.DefaultSortBy) == 0 {
cfg.DefaultSortBy = defaultSortBy
} else if cfg.DefaultSortBy != "name" && cfg.DefaultSortBy != "group" && cfg.DefaultSortBy != "health" {
return ErrInvalidDefaultSortBy
}
if len(cfg.DefaultFilterBy) == 0 {
cfg.DefaultFilterBy = defaultFilterBy
} else if cfg.DefaultFilterBy != "nothing" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err

View File

@@ -1,6 +1,7 @@
package ui
import (
"errors"
"strconv"
"testing"
)
@@ -25,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
}
func TestButton_Validate(t *testing.T) {
@@ -74,4 +81,114 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
if defaultConfig.DefaultSortBy != defaultSortBy {
t.Error("expected GetDefaultConfig() to return defaultSortBy, got", defaultConfig.DefaultSortBy)
}
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultSortBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultSortBy",
DefaultSortBy: "",
ExpectedError: nil,
ExpectedValue: defaultSortBy,
},
{
Name: "ValidDefaultSortBy_name",
DefaultSortBy: "name",
ExpectedError: nil,
ExpectedValue: "name",
},
{
Name: "ValidDefaultSortBy_group",
DefaultSortBy: "group",
ExpectedError: nil,
ExpectedValue: "group",
},
{
Name: "ValidDefaultSortBy_health",
DefaultSortBy: "health",
ExpectedError: nil,
ExpectedValue: "health",
},
{
Name: "InvalidDefaultSortBy",
DefaultSortBy: "invalid",
ExpectedError: ErrInvalidDefaultSortBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultSortBy: scenario.DefaultSortBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultSortBy != scenario.ExpectedValue {
t.Errorf("expected DefaultSortBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultSortBy)
}
})
}
}
func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultFilterBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultFilterBy",
DefaultFilterBy: "",
ExpectedError: nil,
ExpectedValue: defaultFilterBy,
},
{
Name: "ValidDefaultFilterBy_nothing",
DefaultFilterBy: "nothing",
ExpectedError: nil,
ExpectedValue: "nothing",
},
{
Name: "ValidDefaultFilterBy_failing",
DefaultFilterBy: "failing",
ExpectedError: nil,
ExpectedValue: "failing",
},
{
Name: "ValidDefaultFilterBy_unstable",
DefaultFilterBy: "unstable",
ExpectedError: nil,
ExpectedValue: "unstable",
},
{
Name: "InvalidDefaultFilterBy",
DefaultFilterBy: "invalid",
ExpectedError: ErrInvalidDefaultFilterBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultFilterBy != scenario.ExpectedValue {
t.Errorf("expected DefaultFilterBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultFilterBy)
}
})
}
}