#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 = 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
@ -75,6 +79,9 @@ type Config struct {
// Web is the configuration for the web listener
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
lastFileModTime time.Time // last modification time
}
@ -162,6 +169,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateWebConfig(config); err != nil {
return nil, err
}
if err := validateUIConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
return nil, err
}
@ -191,9 +201,20 @@ func validateStorageConfig(config *Config) error {
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 {
if config.Web == nil {
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
config.Web = GetDefaultWebConfig()
} else {
return config.Web.validateAndSetDefaults()
}

View File

@ -36,10 +36,15 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db"
StaticFolder = "../web/static"
defer func() {
StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
file: %s
ui:
title: Test
services:
- name: twinnation
url: https://twinnation.org/health
@ -71,6 +76,9 @@ services:
if config == 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 {
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"`
}
// 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
func (web *WebConfig) validateAndSetDefaults() error {
// Validate the Address

View File

@ -30,18 +30,14 @@ const (
var (
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.
// The only reason it exists is for testing purposes.
server *http.Server
)
// Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, uiConfig *config.UIConfig, enableMetrics bool) {
var router http.Handler = CreateRouter(config.StaticFolder, securityConfig, uiConfig, enableMetrics)
if os.Getenv("ENVIRONMENT") == "dev" {
router = developmentCorsHandler(router)
}
@ -68,14 +64,14 @@ func Shutdown() {
}
// 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()
if enabledMetrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
}
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
// New endpoints
router.HandleFunc("/favicon.ico", favIconHandler(staticFolder)).Methods("GET")
// 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/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).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}/chart.svg", responseTimeChartHandler).Methods("GET")
// 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
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router

View File

@ -88,7 +88,6 @@ var (
func TestCreateRouter(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
staticFolder = "../web/static"
cfg := &config.Config{
Metrics: true,
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[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 {
Name string
Path string
@ -287,7 +286,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.Metrics)
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
defer Shutdown()
request, _ := http.NewRequest("GET", "/health", nil)
responseRecorder := httptest.NewRecorder()
@ -312,7 +311,6 @@ func TestShutdown(t *testing.T) {
func TestServiceStatusesHandler(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
staticFolder = "../web/static"
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
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
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter(nil, false)
router := CreateRouter("../web/static", nil, nil, false)
type Scenario struct {
Name string

View File

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

View File

@ -1,8 +1,27 @@
package controller
import "net/http"
import (
"html/template"
"log"
"net/http"
// spaHandler handles requests for /
func spaHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/index.html")
"github.com/TwinProduction/gatus/config"
)
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) {
go controller.Handle(cfg.Security, cfg.Web, cfg.Metrics)
go controller.Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg)
}

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Health Dashboard | Gatus</title>
<title>{{ .Title }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<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>