main.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. package main
  2. import (
  3. "fmt"
  4. "log"
  5. "net/http"
  6. "os"
  7. "strings"
  8. "time"
  9. "github.com/gin-gonic/gin"
  10. "gopkg.in/yaml.v3"
  11. )
  12. // serviceMonitor defines one configured service endpoint to check.
  13. type serviceMonitor struct {
  14. Name string `yaml:"name"`
  15. URL string `yaml:"url"`
  16. Category string `yaml:"category"`
  17. }
  18. // appConfig contains the monitor definitions loaded from the YAML config file.
  19. type appConfig struct {
  20. Monitors []serviceMonitor `yaml:"monitors"`
  21. }
  22. // serviceStatus represents the latest observed health information for a monitor.
  23. type serviceStatus struct {
  24. Name string `json:"name"`
  25. URL string `json:"url"`
  26. Category string `json:"category"`
  27. Protocol string `json:"protocol"`
  28. Healthy bool `json:"healthy"`
  29. StatusCode int `json:"status_code"`
  30. ResponseTime string `json:"response_time"`
  31. LastChecked time.Time `json:"last_checked"`
  32. Message string `json:"message"`
  33. Details string `json:"details"`
  34. }
  35. // main starts the status server, schedules monitor refreshes, and wires routes.
  36. func main() {
  37. store := &statusStore{}
  38. hub := newWSHub()
  39. client := &http.Client{Timeout: 5 * time.Second}
  40. addr := serverAddr()
  41. tlsCertFile, tlsKeyFile, err := serverTLSFiles()
  42. if err != nil {
  43. log.Fatal(err)
  44. }
  45. theme := serverTheme()
  46. monitors, err := loadMonitors(configPath())
  47. if err != nil {
  48. log.Fatal(err)
  49. }
  50. refreshStatuses(store, client, monitors, hub)
  51. go func() {
  52. ticker := time.NewTicker(30 * time.Second)
  53. defer ticker.Stop()
  54. for range ticker.C {
  55. refreshStatuses(store, client, monitors, hub)
  56. }
  57. }()
  58. router := gin.Default()
  59. router.LoadHTMLGlob("templates/*.html")
  60. router.Static("/static", "./static")
  61. router.GET("/", func(c *gin.Context) {
  62. c.HTML(http.StatusOK, "status.html", gin.H{
  63. "title": "Status Board",
  64. "theme": theme,
  65. })
  66. })
  67. router.GET("/api/status", func(c *gin.Context) {
  68. c.JSON(http.StatusOK, gin.H{
  69. "generated_at": time.Now(),
  70. "services": store.list(),
  71. })
  72. })
  73. router.GET("/ws", func(c *gin.Context) {
  74. serveWebSocket(c, hub)
  75. })
  76. scheme := "http"
  77. if tlsCertFile != "" {
  78. scheme = "https"
  79. }
  80. log.Printf("status page listening on %s://%s", scheme, addr)
  81. if err := runServer(router, addr, tlsCertFile, tlsKeyFile); err != nil {
  82. log.Fatal(err)
  83. }
  84. }
  85. // configPath returns the monitor configuration file path.
  86. func configPath() string {
  87. if path := os.Getenv("CONFIG_PATH"); path != "" {
  88. return path
  89. }
  90. return "config.yaml"
  91. }
  92. // serverAddr returns the HTTP listen address for the status server.
  93. func serverAddr() string {
  94. if addr := os.Getenv("ADDR"); addr != "" {
  95. return addr
  96. }
  97. if port := os.Getenv("PORT"); port != "" {
  98. return ":" + port
  99. }
  100. return "127.0.0.1:8080"
  101. }
  102. // serverTLSFiles returns the configured TLS certificate and key file paths.
  103. func serverTLSFiles() (string, string, error) {
  104. certFile := os.Getenv("TLS_CERT_FILE")
  105. keyFile := os.Getenv("TLS_KEY_FILE")
  106. if certFile == "" && keyFile == "" {
  107. return "", "", nil
  108. }
  109. if certFile == "" || keyFile == "" {
  110. return "", "", fmt.Errorf("both TLS_CERT_FILE and TLS_KEY_FILE must be set to enable HTTPS")
  111. }
  112. return certFile, keyFile, nil
  113. }
  114. // serverTheme returns the normalized UI theme name.
  115. func serverTheme() string {
  116. switch strings.ToLower(os.Getenv("THEME")) {
  117. case "light":
  118. return "light"
  119. case "dark", "":
  120. return "dark"
  121. default:
  122. return "dark"
  123. }
  124. }
  125. // runServer starts the Gin server with or without TLS based on configuration.
  126. func runServer(router *gin.Engine, addr, certFile, keyFile string) error {
  127. if certFile != "" {
  128. return router.RunTLS(addr, certFile, keyFile)
  129. }
  130. return router.Run(addr)
  131. }
  132. // loadMonitors reads, validates, and returns monitor definitions from disk.
  133. func loadMonitors(path string) ([]serviceMonitor, error) {
  134. data, err := os.ReadFile(path)
  135. if err != nil {
  136. return nil, fmt.Errorf("read config %q: %w", path, err)
  137. }
  138. var cfg appConfig
  139. if err := yaml.Unmarshal(data, &cfg); err != nil {
  140. return nil, fmt.Errorf("parse config %q: %w", path, err)
  141. }
  142. if len(cfg.Monitors) == 0 {
  143. return nil, fmt.Errorf("config %q does not define any monitors", path)
  144. }
  145. for i, monitor := range cfg.Monitors {
  146. if monitor.Name == "" || monitor.URL == "" || monitor.Category == "" {
  147. return nil, fmt.Errorf("config %q has an incomplete monitor at index %d", path, i)
  148. }
  149. if _, err := parseMonitorURL(monitor.URL); err != nil {
  150. return nil, fmt.Errorf("config %q has an invalid URL for monitor %q: %w", path, monitor.Name, err)
  151. }
  152. }
  153. return cfg.Monitors, nil
  154. }