#77: Make page title customizable

This commit is contained in:
TwinProduction 2021-09-11 01:51:14 -04:00
parent effad21c64
commit 7a68920889
12 changed files with 136 additions and 27 deletions

View File

@ -41,6 +41,10 @@ var (
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid // ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
ErrInvalidSecurityConfig = errors.New("invalid security configuration") ErrInvalidSecurityConfig = errors.New("invalid security configuration")
// StaticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
StaticFolder = "./web/static"
) )
// Config is the main configuration structure // Config is the main configuration structure
@ -75,6 +79,9 @@ type Config struct {
// Web is the configuration for the web listener // Web is the configuration for the web listener
Web *WebConfig `yaml:"web"` Web *WebConfig `yaml:"web"`
// UI is the configuration for the UI
UI *UIConfig `yaml:"ui"`
filePath string // path to the file from which config was loaded from filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
@ -162,6 +169,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateWebConfig(config); err != nil { if err := validateWebConfig(config); err != nil {
return nil, err return nil, err
} }
if err := validateUIConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil { if err := validateStorageConfig(config); err != nil {
return nil, err return nil, err
} }
@ -191,9 +201,20 @@ func validateStorageConfig(config *Config) error {
return nil return nil
} }
func validateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = GetDefaultUIConfig()
} else {
if err := config.UI.validateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateWebConfig(config *Config) error { func validateWebConfig(config *Config) error {
if config.Web == nil { if config.Web == nil {
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort} config.Web = GetDefaultWebConfig()
} else { } else {
return config.Web.validateAndSetDefaults() return config.Web.validateAndSetDefaults()
} }

View File

@ -36,10 +36,15 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
func TestParseAndValidateConfigBytes(t *testing.T) { func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db" file := t.TempDir() + "/test.db"
StaticFolder = "../web/static"
defer func() {
StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage: storage:
file: %s file: %s
ui:
title: Test
services: services:
- name: twinnation - name: twinnation
url: https://twinnation.org/health url: https://twinnation.org/health
@ -71,6 +76,9 @@ services:
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("Config shouldn't have been nil")
} }
if config.UI == nil || config.UI.Title != "Test" {
t.Error("Expected Config.UI.Title to be Test")
}
if len(config.Services) != 3 { if len(config.Services) != 3 {
t.Error("Should have returned two services") t.Error("Should have returned two services")
} }

36
config/ui.go Normal file
View File

@ -0,0 +1,36 @@
package config
import (
"bytes"
"html/template"
)
const defaultTitle = "Health Dashboard | Gatus"
// UIConfig is the configuration for the UI of Gatus
type UIConfig struct {
Title string `yaml:"title"` // Title of the page
}
// GetDefaultUIConfig returns a UIConfig struct with the default values
func GetDefaultUIConfig() *UIConfig {
return &UIConfig{
Title: defaultTitle,
}
}
func (cfg *UIConfig) validateAndSetDefaults() error {
if len(cfg.Title) == 0 {
cfg.Title = defaultTitle
}
t, err := template.ParseFiles(StaticFolder + "/index.html")
if err != nil {
return err
}
var buffer bytes.Buffer
err = t.Execute(&buffer, cfg)
if err != nil {
return err
}
return nil
}

21
config/ui_test.go Normal file
View File

@ -0,0 +1,21 @@
package config
import "testing"
func TestUIConfig_validateAndSetDefaults(t *testing.T) {
StaticFolder = "../web/static"
defer func() {
StaticFolder = "./web/static"
}()
uiConfig := &UIConfig{Title: ""}
if err := uiConfig.validateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
}
func TestGetDefaultUIConfig(t *testing.T) {
defaultUIConfig := GetDefaultUIConfig()
if defaultUIConfig.Title != defaultTitle {
t.Error("expected GetDefaultUIConfig() to return defaultTitle, got", defaultUIConfig.Title)
}
}

View File

@ -15,6 +15,11 @@ type WebConfig struct {
Port int `yaml:"port"` Port int `yaml:"port"`
} }
// GetDefaultWebConfig returns a WebConfig struct with the default values
func GetDefaultWebConfig() *WebConfig {
return &WebConfig{Address: DefaultAddress, Port: DefaultPort}
}
// validateAndSetDefaults checks and sets the default values for fields that are not set // validateAndSetDefaults checks and sets the default values for fields that are not set
func (web *WebConfig) validateAndSetDefaults() error { func (web *WebConfig) validateAndSetDefaults() error {
// Validate the Address // Validate the Address

View File

@ -30,18 +30,14 @@ const (
var ( var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut) cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
// staticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
staticFolder = "./web/static"
// server is the http.Server created by Handle. // server is the http.Server created by Handle.
// The only reason it exists is for testing purposes. // The only reason it exists is for testing purposes.
server *http.Server server *http.Server
) )
// Handle creates the router and starts the server // Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) { func Handle(securityConfig *security.Config, webConfig *config.WebConfig, uiConfig *config.UIConfig, enableMetrics bool) {
var router http.Handler = CreateRouter(securityConfig, enableMetrics) var router http.Handler = CreateRouter(config.StaticFolder, securityConfig, uiConfig, enableMetrics)
if os.Getenv("ENVIRONMENT") == "dev" { if os.Getenv("ENVIRONMENT") == "dev" {
router = developmentCorsHandler(router) router = developmentCorsHandler(router)
} }
@ -68,14 +64,14 @@ func Shutdown() {
} }
// CreateRouter creates the router for the http server // CreateRouter creates the router for the http server
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router { func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *config.UIConfig, enabledMetrics bool) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
if enabledMetrics { if enabledMetrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET") router.Handle("/metrics", promhttp.Handler()).Methods("GET")
} }
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") router.HandleFunc("/favicon.ico", favIconHandler(staticFolder)).Methods("GET")
// New endpoints // Endpoints
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET") router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
// TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET") // TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET")
@ -84,7 +80,8 @@ func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Rou
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", responseTimeBadgeHandler).Methods("GET") router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", responseTimeBadgeHandler).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", responseTimeChartHandler).Methods("GET") router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", responseTimeChartHandler).Methods("GET")
// SPA // SPA
router.HandleFunc("/services/{service}", spaHandler).Methods("GET") router.HandleFunc("/services/{service}", spaHandler(staticFolder, uiConfig)).Methods("GET")
router.HandleFunc("/", spaHandler(staticFolder, uiConfig)).Methods("GET")
// Everything else falls back on static content // Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router return router

View File

@ -88,7 +88,6 @@ var (
func TestCreateRouter(t *testing.T) { func TestCreateRouter(t *testing.T) {
defer storage.Get().Clear() defer storage.Get().Clear()
defer cache.Clear() defer cache.Clear()
staticFolder = "../web/static"
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Services: []*core.Service{ Services: []*core.Service{
@ -104,7 +103,7 @@ func TestCreateRouter(t *testing.T) {
} }
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter(cfg.Security, cfg.Metrics) router := CreateRouter("../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@ -287,7 +286,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true") _ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev") _ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv() defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.Metrics) Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
defer Shutdown() defer Shutdown()
request, _ := http.NewRequest("GET", "/health", nil) request, _ := http.NewRequest("GET", "/health", nil)
responseRecorder := httptest.NewRecorder() responseRecorder := httptest.NewRecorder()
@ -312,7 +311,6 @@ func TestShutdown(t *testing.T) {
func TestServiceStatusesHandler(t *testing.T) { func TestServiceStatusesHandler(t *testing.T) {
defer storage.Get().Clear() defer storage.Get().Clear()
defer cache.Clear() defer cache.Clear()
staticFolder = "../web/static"
firstResult := &testSuccessfulResult firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult secondResult := &testUnsuccessfulResult
storage.Get().Insert(&testService, firstResult) storage.Get().Insert(&testService, firstResult)
@ -320,7 +318,7 @@ func TestServiceStatusesHandler(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests // Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{} firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{}
router := CreateRouter(nil, false) router := CreateRouter("../web/static", nil, nil, false)
type Scenario struct { type Scenario struct {
Name string Name string

View File

@ -1,8 +1,12 @@
package controller package controller
import "net/http" import (
"net/http"
)
// favIconHandler handles requests for /favicon.ico // favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) { func favIconHandler(staticFolder string) http.HandlerFunc {
http.ServeFile(writer, request, staticFolder+"/favicon.ico") return func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}
} }

View File

@ -1,8 +1,27 @@
package controller package controller
import "net/http" import (
"html/template"
"log"
"net/http"
// spaHandler handles requests for / "github.com/TwinProduction/gatus/config"
func spaHandler(writer http.ResponseWriter, request *http.Request) { )
http.ServeFile(writer, request, staticFolder+"/index.html")
func spaHandler(staticFolder string, ui *config.UIConfig) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
t, err := template.ParseFiles(staticFolder + "/index.html")
if err != nil {
log.Println("[controller][spaHandler] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
return
}
writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui)
if err != nil {
log.Println("[controller][spaHandler] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
return
}
}
} }

View File

@ -35,7 +35,7 @@ func main() {
} }
func start(cfg *config.Config) { func start(cfg *config.Config) {
go controller.Handle(cfg.Security, cfg.Web, cfg.Metrics) go controller.Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
watchdog.Monitor(cfg) watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg) go listenToConfigurationFileChanges(cfg)
} }

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Health Dashboard | Gatus</title> <title>{{ .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="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>Health Dashboard | Gatus</title><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><script defer="defer" src="/js/chunk-vendors.js" type="module"></script><script defer="defer" src="/js/app.js" type="module"></script><link href="/css/app.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.js" nomodule></script><script defer="defer" src="/js/app-legacy.js" nomodule></script></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"><head><meta charset="utf-8"/><title>{{ .Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><script defer="defer" src="/js/chunk-vendors.js" type="module"></script><script defer="defer" src="/js/app.js" type="module"></script><link href="/css/app.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.js" nomodule></script><script defer="defer" src="/js/app-legacy.js" nomodule></script></head><body class="dark:bg-gray-900"><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html>