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!
+
+
+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 }}
+