diff --git a/README.md b/README.md index 01a1d11..6cf9d57 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ # Statuspage -> Framework used to create a personalized statuspage. +> Self hosted status page written in golang! +![Dashboard](screenshot.png?raw=true "Dashboard") + +This is a small status page project with Postgres as the backing datastore. +There should be no need to change the go code to customize the page. +We use environment variables for configuration, this also includes the logo. + +## Todo + +* API authorization +* API payload validation +* Incident API +* Customize page with env variables diff --git a/docker-compose.yaml b/docker-compose.yaml index febeb05..3327826 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,9 @@ version: '2' services: - redis: - image: redis:latest + postgres: + image: postgres:9.5 ports: - - '127.0.0.1:6379:6379' \ No newline at end of file + - '127.0.0.1:5432:5432' + environment: + - POSTGRES_USER=statuspage \ No newline at end of file diff --git a/main.go b/main.go index 3af5d58..bffbf0a 100644 --- a/main.go +++ b/main.go @@ -2,56 +2,70 @@ package main import ( "github.com/gin-gonic/gin" - "net/http" "github.com/eirsyl/statuspage/src" - "github.com/go-redis/redis" + "github.com/eirsyl/statuspage/src/routes" "os" - "strconv" + "github.com/go-pg/pg" + "runtime" "log" ) func main() { - redisAddr := os.Getenv("REDIS_ADDRESS") - redisPassword := os.Getenv("REDIS_PASSWORD") - redisDB := os.Getenv("REDIS_DB") + ConfigRuntime() - redisDBInt, err := strconv.Atoi(redisDB) - if err != nil { - log.Print("Could not parse Redis DB, using 0 as default.") - redisDBInt = 0 - } - - db := redis.NewClient(&redis.Options{ - Addr: redisAddr, - Password: redisPassword, - DB: redisDBInt, - }) - - services := src.Services{} - services.Initialize(*db) - - incidents := src.Incidents{} - incidents.Initialize(*db) + gin.SetMode(gin.ReleaseMode) router := gin.Default() + router.Use(State()) + router.Static("/static", "./static") router.LoadHTMLGlob("templates/*") - router.GET("/", func(c *gin.Context) { + router.GET("/", routes.Dashboard) - res, err := services.GetServices(true) - if err != nil { - panic(err) - } - - c.HTML(http.StatusOK, "index.tmpl", gin.H{ - "owner": "Abakus", - "services": src.AggregateServices(res), - "mostCriticalStatus": src.MostCriticalStatus(res), - }) - }) + router.GET("/api/services", routes.ServiceList) + router.POST("/api/services", routes.ServicePost) + router.GET("/api/services/:id", routes.ServiceGet) + router.PATCH("/api/services/:id", routes.ServicePatch) + router.DELETE("/api/services/:id", routes.ServiceDelete) router.Run() } + +func ConfigRuntime() { + nuCPU := runtime.NumCPU() + runtime.GOMAXPROCS(nuCPU) + log.Printf("Running with %d CPUs\n", nuCPU) +} + +func State() gin.HandlerFunc { + pgAddr := os.Getenv("POSTGRES_ADDRESS") + pgUser := os.Getenv("POSTGRES_USER") + pgPassword := os.Getenv("POSTGRES_PASSWORD") + pgDB := os.Getenv("POSTGRES_DB") + + db := pg.Connect(&pg.Options{ + Addr: pgAddr, + User: pgUser, + Password: pgPassword, + Database: pgDB, + }) + + if err := src.CreateSchema(db); err != nil { + panic(err) + } + + services := src.Services{} + services.Initialize(*db) + + incidents := src.Incidents{} + incidents.Initialize(*db) + + return func(c *gin.Context) { + c.Set("services", services) + c.Set("incidents", incidents) + c.Next() + } +} \ No newline at end of file diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..c974465 Binary files /dev/null and b/screenshot.png differ diff --git a/src/incidents.go b/src/incidents.go index 8c9dd91..3bb48a9 100644 --- a/src/incidents.go +++ b/src/incidents.go @@ -1,36 +1,65 @@ package src import ( - "github.com/go-redis/redis" "time" + "github.com/go-pg/pg" ) type Incident struct { - Id string + Id int64 Time time.Time Title string - Resolved time.Time + Updates []*IncidentUpdate } type IncidentUpdate struct { - Id string - Incident string + Id int64 + Time time.Time + IncidentId int64 Status string Message string } type Incidents struct { - db redis.Client + db pg.DB } -func (i *Incidents) Initialize(db redis.Client) { +func (i *Incidents) Initialize(db pg.DB) { i.db = db } -func (i *Incidents) CreateIncident(incident Incident) error { - return nil +func (i *Incidents) InsertIncident(incident Incident) error { + if incident.Time.IsZero() { + now := time.Now() + incident.Time = now + } + err := i.db.Insert(&incident) + return err +} + +func (i *Incidents) InsertIncidentUpdate(incident int64, update IncidentUpdate) error { + update.IncidentId = incident + + if update.Time.IsZero() { + now := time.Now() + update.Time = now + } + + err := i.db.Insert(&update) + return err } -func (i *Incidents) CreateIncidentUpdate(incident string, update IncidentUpdate) error { - return nil +func (i *Incidents) GetLatestIncidents() ([]Incident, error) { + to := time.Now() + from := to.Add(-14 * 24 * time.Hour).Truncate(24 * time.Hour) + + var incidents []Incident + + err := i.db.Model(&incidents). + Column("incident.*", "Updates"). + Where("time > ?", from). + Where("time < ?", to). + Select() + + return incidents, err } \ No newline at end of file diff --git a/src/routes/api.go b/src/routes/api.go new file mode 100644 index 0000000..57fc2d9 --- /dev/null +++ b/src/routes/api.go @@ -0,0 +1,102 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + "github.com/eirsyl/statuspage/src" + "net/http" + "strconv" +) + +func ServiceList(c *gin.Context) { + services, _ := c.Keys["services"].(src.Services) + + s, err := services.GetServices(true) + if err != nil { + panic(err) + } + + c.JSON(http.StatusOK, s) +} + +func ServicePost(c *gin.Context) { + var service src.Service + err := c.BindJSON(&service) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + services, _ := c.Keys["services"].(src.Services) + + err = services.InsertService(service) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + c.JSON(http.StatusCreated, service) +} + +func ServiceGet(c *gin.Context) { + serviceId := c.Param("id") + id, err := strconv.Atoi(serviceId) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + services, _ := c.Keys["services"].(src.Services) + + s, err := services.GetService(int64(id)) + if err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + c.JSON(http.StatusOK, s) +} + +func ServicePatch(c *gin.Context) { + serviceId := c.Param("id") + id, err := strconv.Atoi(serviceId) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + var service src.Service + err = c.BindJSON(&service) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + services, _ := c.Keys["services"].(src.Services) + + err = services.UpdateService(int64(id), service) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + c.JSON(http.StatusCreated, service) +} + +func ServiceDelete(c *gin.Context) { + serviceId := c.Param("id") + id, err := strconv.Atoi(serviceId) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + services, _ := c.Keys["services"].(src.Services) + + err = services.DeleteService(int64(id)) + if err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + c.AbortWithStatus(http.StatusNoContent) +} \ No newline at end of file diff --git a/src/routes/dashboard.go b/src/routes/dashboard.go new file mode 100644 index 0000000..c78017d --- /dev/null +++ b/src/routes/dashboard.go @@ -0,0 +1,29 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + "github.com/eirsyl/statuspage/src" + "net/http" +) + +func Dashboard(c *gin.Context) { + services, _ := c.Keys["services"].(src.Services) + incidents, _ := c.Keys["incidents"].(src.Incidents) + + res, err := services.GetServices(true) + if err != nil { + panic(err) + } + + inc, err := incidents.GetLatestIncidents() + if err != nil { + panic(err) + } + + c.HTML(http.StatusOK, "index.tmpl", gin.H{ + "owner": "Abakus", + "services": src.AggregateServices(res), + "mostCriticalStatus": src.MostCriticalStatus(res), + "incidents": src.AggregateIncidents(inc), + }) +} diff --git a/src/services.go b/src/services.go index 45bfa00..91ca83a 100644 --- a/src/services.go +++ b/src/services.go @@ -1,14 +1,12 @@ package src import ( - "github.com/go-redis/redis" - "encoding/json" + "github.com/go-pg/pg" ) -const REDIS_MAP = "statuspage_services" type Service struct { - ID string + ID int64 Name string Status string Description string @@ -19,62 +17,58 @@ type Service struct { } type Services struct { - db redis.Client + db pg.DB } -func (s *Services) Initialize(db redis.Client) { +func (s *Services) Initialize(db pg.DB) { s.db = db } -func (s *Services) InsertService(id string, service Service) error { - serialized, err := json.Marshal(service) - if err != nil { return err } - - if err := s.db.HSet(REDIS_MAP, id, serialized).Err(); err != nil { - return err +func (s *Services) InsertService(service Service) error { + if service.Group == "" { + service.Group = "Other" } - return nil -} - -func (s *Services) RemoveService(id string) error { - err := s.db.HDel(REDIS_MAP, id).Err() + err := s.db.Insert(&service) return err } func (s *Services) GetServices(enabled bool) ([]Service, error) { - services := []Service{} + var services []Service - results, err := s.db.HGetAll(REDIS_MAP).Result() - if err != err { - return nil, err - } + err := s.db.Model(&services). + Where("service.enabled = ?", true). + Select() - for id, result := range results { - var service Service - if err := json.Unmarshal([]byte(result), &service); err != nil { - return nil, err - } - service.ID = id + return services, err +} - if service.Enabled == enabled { - services = append(services, service) - } +func (s *Services) GetService(id int64) (Service, error){ + service := Service{ + ID: id, } - return services, nil + err := s.db.Select(&service) + + return service, err } -func (s *Services) GetService(id string) (Service, error){ - result, err := s.db.HGet(REDIS_MAP, id).Result() - if err != err { - return Service{}, err +func (s *Services) UpdateService(id int64, service Service) error { + service.ID = id + if service.Group == "" { + service.Group = "Other" } - var service Service - if err := json.Unmarshal([]byte(result), &service); err != nil { - return Service{}, err + err := s.db.Update(&service) + return err +} + +func (s *Services) DeleteService(id int64) error { + service := Service{ + ID: id, } - return service, nil + err := s.db.Delete(&service) + + return err } \ No newline at end of file diff --git a/src/utils.go b/src/utils.go index cfb956c..2cba742 100644 --- a/src/utils.go +++ b/src/utils.go @@ -1,5 +1,10 @@ package src +import ( + "github.com/go-pg/pg" + "github.com/go-pg/pg/orm" + "time" +) /* * Aggregate and group services by service group. @@ -47,4 +52,55 @@ func MostCriticalStatus(services []Service) int { } return mostCritical +} + +/* + * Aggregate incidents + */ + +type AggregatedIncident struct { + Time time.Time + Incidents []Incident +} + +type AggregatedIncidents []AggregatedIncident + +func AggregateIncidents(incidents []Incident) AggregatedIncidents { + days := 14 + aggregatedIncidents := AggregatedIncidents{} + + for i := 0; i < days; i++ { + t := time.Now().Add(-time.Duration(i) * 24 * time.Hour) + filteredIncidents := []Incident{} + + for _, incident := range incidents { + if incident.Time.Day() == t.Day() { + filteredIncidents = append(filteredIncidents, incident) + } + } + + aggregatedIncidents = append(aggregatedIncidents, AggregatedIncident{ + Time: t, + Incidents: filteredIncidents, + }) + } + + return aggregatedIncidents +} + +/* + * Migrate DB + */ +func CreateSchema(db *pg.DB) error { + for _, model := range []interface{}{ + &Service{}, + &Incident{}, + &IncidentUpdate{}, + } { + err := db.CreateTable(model, &orm.CreateTableOptions{IfNotExists: true}) + if err != nil { + return err + } + } + return nil } \ No newline at end of file diff --git a/static/css/app.css b/static/css/app.css index 3c0ab22..a7a4bc0 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -216,6 +216,11 @@ h1 { color: #000; font-size: 18px; margin-bottom: 10px; + margin-top: 20px; +} + +.incidents > .line .content h3:first-child { + margin-top: 0; } .incidents > .line .content b { diff --git a/templates/index.tmpl b/templates/index.tmpl index b9cfd78..f468377 100644 --- a/templates/index.tmpl +++ b/templates/index.tmpl @@ -48,39 +48,30 @@

Incidents

-
- Sep 29, 2017 -
-
- Sep 28, 2017 -
-
-
- Sep 27, 2017 -
-

Intermittent issues with Spaces

-

Resolved - Our engineers have resolved the issue with our Spaces platform at this time. Please open a ticket with our support team if you continue to experience any issues with the service.

-

Monitoring - Our engineers have resolved the issue with our Spaces platform at this time. Please open a ticket with our support team if you continue to experience any issues with the service.

- - Resolved Sep 25, 19:59 UTC + {{ range $index, $aggregatedIncident := .incidents }} + {{ $length := len $aggregatedIncident.Incidents }} + {{ if eq $length 0 }} +
+ {{ $aggregatedIncident.Time.Format "Jan 02" }}
-
-
-
- Sep 26, 2017 -
-
-
- Sep 25, 2017 -
-

Intermittent issues with Spaces

-

Resolved - Our engineers have resolved the issue with our Spaces platform at this time. Please open a ticket with our support team if you continue to experience any issues with the service.

-

Monitoring - Our engineers have resolved the issue with our Spaces platform at this time. Please open a ticket with our support team if you continue to experience any issues with the service.

-

Investigating - We are investigating a small number of requests with high latency.

- Resolved Sep 25, 19:59 UTC + {{ else }} +
+
+ {{ $aggregatedIncident.Time.Format "Jan 02" }} +
+ {{ range $incident := $aggregatedIncident.Incidents }} +

{{ $incident.Title }}

+ + {{ range $update := $incident.Updates }} +

{{ $update.Status }} - {{$update.Message}}

+ {{ end }} + {{ end }} +
+
-
-
+ {{ end }} + {{ end }} +