├── .dockerignore ├── .editorconfig ├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── docker-compose.yml ├── main.go ├── monitor ├── data │ ├── data.go │ ├── db.go │ ├── target.go │ └── track.go ├── docker.go ├── email.go ├── entities │ ├── event.go │ ├── target.go │ └── track.go ├── http.go └── job.go ├── monitor_api_suite_test.go └── server ├── api ├── target.go └── track.go ├── router.go ├── router_test.go ├── server_suite_test.go └── websocket.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.go] 12 | indent_style = tab 13 | indent_size = 8 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gin-bin -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.4 4 | env: 5 | global: 6 | - MONGO_PORT=tcp://127.0.0.1:27017 7 | - MONGODB_DATABASE=go-uptime-api 8 | - VIRTUAL_PORT=3000 9 | - VIRTUAL_HOST=fake-domain.com 10 | - CHECK_TARGETS_AT_EVERY=1d 11 | services: 12 | - mongodb 13 | install: 14 | - go get -d -v ./... 15 | - go get -v github.com/onsi/ginkgo/ginkgo 16 | - go get -v github.com/onsi/gomega 17 | script: 18 | - ginkgo -r -v --randomizeAllSpecs --randomizeSuites --failOnPending --trace --race --compilers=2 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.4 2 | 3 | WORKDIR /go/src/github.com/maxcnunes/go-uptime-api 4 | 5 | ADD . /go/src/github.com/maxcnunes/go-uptime-api 6 | 7 | RUN go get -d -v ./... 8 | 9 | # dev environment 10 | RUN go get github.com/codegangsta/gin \ 11 | github.com/onsi/ginkgo/ginkgo \ 12 | github.com/onsi/gomega 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-uptime 2 | 3 | [![Build Status](https://travis-ci.org/maxcnunes/go-uptime-api.svg?branch=master)](https://travis-ci.org/maxcnunes/go-uptime-api) 4 | 5 | Simple monitor server to check uptime of any target reachable through HTTP. 6 | 7 | The Go Uptime is composed of an API and [APP](https://github.com/maxcnunes/go-uptime-app) separated in different projects. 8 | 9 | ## API 10 | 11 | This current project is responsible for manipulating the targets data and polling all targets' URL to check if each one is up or down. Also the monitor will listen to all Docker events and capture the URL from all containers that has the `VIRTUAL_HOST` environment variable. 12 | Concerned in a better user experience the monitor uses web socket to notify the connected clients when a target has been created or updated. 13 | 14 | 15 | # Developing 16 | 17 | The simplest way is using Dockito vagrant box and docker-compose to provide a configured environment for you. 18 | 19 | Setup the [Dockito vagrant box](https://github.com/dockito/devbox#dockito-vagrant-box) then inside the VM execute the command below to the docker-compose start the container: 20 | 21 | ```bash 22 | docker-compose run local 23 | ``` 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | mongo: 2 | image: mongo:2.6 3 | 4 | local: &LOCAL 5 | build: . 6 | command: gin 7 | ports: 8 | - 3000 9 | volumes: 10 | - .:/go/src/github.com/maxcnunes/go-uptime-api 11 | - /var/run/docker.sock:/tmp/docker.sock 12 | environment: &LOCAL-ENVIRONMENT 13 | MONGODB_DATABASE: go-uptime-api 14 | VIRTUAL_PORT: 3000 15 | VIRTUAL_HOST: go-uptime-api.local.dockito.org 16 | PORT_BEHIND_PROXY: 3001 17 | CHECK_TARGETS_AT_EVERY: 1m # 1 minute 18 | EMAIL_FROM: # gets environment variable from machine 19 | EMAIL_USERNAME: # gets environment variable from machine 20 | EMAIL_PASSWORD: # gets environment variable from machine 21 | EMAIL_HOST: smtp.gmail.com 22 | links: 23 | - mongo:mongo 24 | 25 | test: 26 | <<: *LOCAL 27 | command: ginkgo watch -v -p -r -race -failOnPending -randomizeAllSpecs 28 | environment: 29 | <<: *LOCAL-ENVIRONMENT 30 | MONGODB_DATABASE: go-uptime-api-test 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/maxcnunes/go-uptime-api/monitor" 9 | dm "github.com/maxcnunes/go-uptime-api/monitor/data" 10 | "github.com/maxcnunes/go-uptime-api/server" 11 | ) 12 | 13 | var ( 14 | db = dm.DB{} 15 | data = dm.DataMonitor{} 16 | job = monitor.Job{} 17 | router = server.Router{} 18 | websocket = server.Websocket{} 19 | ) 20 | 21 | func main() { 22 | db.Start() 23 | defer db.Close() 24 | 25 | data.Start(db) 26 | 27 | http.Handle("/", router.Start(&data)) 28 | http.HandleFunc("/ws", websocket.Start(&data)) 29 | 30 | job.Start(&data, getTimeTargetsVerification()) 31 | 32 | addr := getServiceAddress() 33 | log.Printf("Server running on http://%s", addr) 34 | if err := http.ListenAndServe(addr, nil); err != nil { 35 | log.Fatal("ListenAndServe: ", err) 36 | } 37 | } 38 | 39 | func getTimeTargetsVerification() string { 40 | if env := os.Getenv("CHECK_TARGETS_AT_EVERY"); env != "" { 41 | return env 42 | } 43 | 44 | return "10m" 45 | } 46 | 47 | func getServiceAddress() string { 48 | if env := os.Getenv("PORT_BEHIND_PROXY"); env != "" { 49 | return ":" + env 50 | } 51 | if env := os.Getenv("VIRTUAL_PORT"); env != "" { 52 | return ":" + env 53 | } 54 | 55 | return ":3000" 56 | } 57 | -------------------------------------------------------------------------------- /monitor/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/maxcnunes/go-uptime-api/monitor/entities" 4 | 5 | // DataMonitor aggregates the data configuration 6 | // like the database configuration and connections to MongoDB collections 7 | type DataMonitor struct { 8 | DB DB 9 | Events chan entities.Event 10 | Target *DataTarget 11 | Track *DataTrack 12 | } 13 | 14 | // Start ... 15 | func (d *DataMonitor) Start(db DB) { 16 | d.DB = db 17 | d.Events = make(chan entities.Event) 18 | d.Target = &DataTarget{} 19 | d.Track = &DataTrack{} 20 | 21 | d.Target.Start(db, d.Events) 22 | d.Track.Start(db, d.Events) 23 | } 24 | -------------------------------------------------------------------------------- /monitor/data/db.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "net/url" 7 | "os" 8 | 9 | "gopkg.in/mgo.v2" 10 | ) 11 | 12 | // DB has the database configuration 13 | type DB struct { 14 | connectionURI string 15 | DBName string 16 | Session *mgo.Session 17 | } 18 | 19 | func (db *DB) setConnectionURIFromEnvConfig() { 20 | errMsg := "no connection string provided" 21 | 22 | mongoConn, err := url.Parse(os.Getenv("MONGO_PORT")) 23 | if err != nil { 24 | log.Fatalln(errMsg) 25 | } 26 | 27 | dbHost, dbPort, err := net.SplitHostPort(mongoConn.Host) 28 | if err != nil { 29 | log.Fatalln(errMsg) 30 | } 31 | 32 | db.DBName = os.Getenv("MONGODB_DATABASE") 33 | if db.DBName == "" || dbHost == "" || dbPort == "" { 34 | log.Fatalln(errMsg) 35 | } 36 | 37 | db.connectionURI = "mongodb://" + dbHost + ":" + dbPort + "/" + db.DBName 38 | } 39 | 40 | // Start a new instance of database 41 | func (db *DB) Start() { 42 | var err error 43 | db.setConnectionURIFromEnvConfig() 44 | 45 | db.Session, err = mgo.Dial(db.connectionURI) 46 | if err != nil { 47 | log.Fatalf("Can't connect to mongo, go error %v\n", err) 48 | } 49 | 50 | db.Session.SetSafe(&mgo.Safe{}) 51 | db.Session.SetMode(mgo.Monotonic, true) 52 | } 53 | 54 | // Close the current database session 55 | func (db *DB) Close() { 56 | db.Session.Close() 57 | } 58 | 59 | // Wipe the whole database. Use it only in test environment. 60 | func (db *DB) Wipe() { 61 | db.Session.DB(db.DBName).C("target").RemoveAll(nil) 62 | db.Session.DB(db.DBName).C("track").RemoveAll(nil) 63 | } 64 | -------------------------------------------------------------------------------- /monitor/data/target.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/maxcnunes/go-uptime-api/monitor/entities" 7 | "gopkg.in/mgo.v2" 8 | "gopkg.in/mgo.v2/bson" 9 | ) 10 | 11 | // DataTarget is the data configuration related to Target collection 12 | type DataTarget struct { 13 | collection *mgo.Collection 14 | events chan entities.Event 15 | } 16 | 17 | // FindOneByURL ... 18 | func (d *DataTarget) FindOneByURL(url string) *entities.Target { 19 | var target entities.Target 20 | err := d.collection.Find(bson.M{"url": url}).One(&target) 21 | if err != nil { 22 | if err == mgo.ErrNotFound { 23 | return nil 24 | } 25 | 26 | log.Printf("got an error finding a doc %v\n", err) 27 | } 28 | 29 | return &target 30 | } 31 | 32 | // FindOneByID finds a single target by the id field 33 | func (d *DataTarget) FindOneByID(id string) *entities.Target { 34 | _id := bson.ObjectIdHex(id) 35 | var target entities.Target 36 | 37 | err := d.collection.Find(bson.M{"_id": _id}).One(&target) 38 | if err != nil { 39 | if err == mgo.ErrNotFound { 40 | return nil 41 | } 42 | 43 | log.Printf("got an error finding a doc %v\n", err) 44 | } 45 | 46 | return &target 47 | } 48 | 49 | // Create a new target in the database 50 | func (d *DataTarget) Create(url string, emails []string) *entities.Target { 51 | target := d.FindOneByURL(url) 52 | if target != nil { 53 | return target 54 | } 55 | 56 | log.Printf("Adding target %s", url) 57 | 58 | doc := entities.Target{ID: bson.NewObjectId(), URL: url, Status: 0, Emails: emails} 59 | if err := d.collection.Insert(doc); err != nil { 60 | log.Printf("Can't insert document: %v\n", err) 61 | } 62 | 63 | go func() { d.events <- entities.Event{Event: entities.Added, Target: &doc} }() 64 | 65 | return &doc 66 | } 67 | 68 | // Remove an existing target by the URL field 69 | func (d *DataTarget) Remove(url string) { 70 | target := d.FindOneByURL(url) 71 | if target == nil { 72 | log.Printf("Can't find document with url: %s\n", url) 73 | return 74 | } 75 | 76 | log.Printf("Removing url %s", url) 77 | 78 | err := d.collection.Remove(bson.M{"url": url}) 79 | if err != nil { 80 | log.Printf("Can't delete document: %v\n", err) 81 | } 82 | 83 | go func() { d.events <- entities.Event{Event: entities.Removed, Target: target} }() 84 | } 85 | 86 | // RemoveByID removes an existing target by the ID field 87 | func (d *DataTarget) RemoveByID(id string) { 88 | target := d.FindOneByID(id) 89 | if target == nil { 90 | log.Printf("Can't find document with id: %s\n", id) 91 | return 92 | } 93 | 94 | log.Printf("Removing url %s", target.URL) 95 | 96 | err := d.collection.Remove(bson.M{"_id": target.ID}) 97 | if err != nil { 98 | log.Printf("Can't delete document: %v\n", err) 99 | } 100 | 101 | go func() { d.events <- entities.Event{Event: entities.Removed, Target: target} }() 102 | } 103 | 104 | // UpdateStatusByURL updates a existing target by the URL field 105 | func (d *DataTarget) UpdateStatusByURL(url string, status string) { 106 | target := d.FindOneByURL(url) 107 | if target != nil { 108 | log.Printf("Can't find document with url: %s\n", url) 109 | return 110 | } 111 | 112 | log.Printf("Updating url %s to status %s", url, status) 113 | err := d.collection.Update(bson.M{"url": url}, bson.M{"status": status}) 114 | if err != nil { 115 | log.Printf("Can't update document: %v\n", err) 116 | } 117 | 118 | go func() { d.events <- entities.Event{Event: entities.Updated, Target: target} }() 119 | } 120 | 121 | // Update an existing target 122 | func (d *DataTarget) Update(id string, data entities.Target) { 123 | target := d.FindOneByID(id) 124 | if target == nil { 125 | log.Printf("Can't find document with id: %s\n", id) 126 | return 127 | } 128 | 129 | attrs := bson.M{"url": data.URL, "emails": data.Emails} 130 | if data.Status != 0 { 131 | attrs["status"] = data.Status 132 | } 133 | 134 | log.Printf("Updating url %s to status %d", target.URL, target.Status) 135 | err := d.collection.UpdateId(target.ID, bson.M{"$set": attrs}) 136 | if err != nil { 137 | log.Printf("Can't update document: %v\n", err) 138 | } 139 | 140 | go func() { d.events <- entities.Event{Event: entities.Updated, Target: target} }() 141 | } 142 | 143 | // GetAllURLS returns all existing target's URLs in the databse 144 | func (d *DataTarget) GetAllURLS() []string { 145 | urls := []string{} 146 | 147 | targets := d.GetAll() 148 | 149 | for _, target := range targets { 150 | urls = append(urls, target.URL) 151 | } 152 | 153 | return urls 154 | } 155 | 156 | // GetAll returns all targets 157 | func (d *DataTarget) GetAll() []entities.Target { 158 | targets := []entities.Target{} 159 | 160 | err := d.collection.Find(nil).All(&targets) 161 | if err != nil { 162 | log.Printf("got an error finding a doc %v\n", err) 163 | } 164 | 165 | return targets 166 | } 167 | 168 | // Start a new instance of data target 169 | func (d *DataTarget) Start(db DB, events chan entities.Event) { 170 | d.collection = db.Session.DB(db.DBName).C("target") 171 | d.events = events 172 | } 173 | -------------------------------------------------------------------------------- /monitor/data/track.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/maxcnunes/go-uptime-api/monitor/entities" 8 | "gopkg.in/mgo.v2" 9 | "gopkg.in/mgo.v2/bson" 10 | ) 11 | 12 | // DataTrack is the data configuration related to Track collection 13 | type DataTrack struct { 14 | collection *mgo.Collection 15 | events chan entities.Event 16 | } 17 | 18 | // Find all tracks 19 | func (d *DataTrack) Find(targetID string) []entities.Track { 20 | tracks := []entities.Track{} 21 | 22 | query := bson.M{} 23 | if targetID != "" { 24 | query["targetId"] = bson.ObjectIdHex(targetID) 25 | } 26 | 27 | err := d.collection.Find(query).Limit(50).Sort("-createdAt").All(&tracks) 28 | if err != nil { 29 | log.Printf("got an error finding a doc %v\n", err) 30 | } 31 | 32 | return tracks 33 | } 34 | 35 | // Create a new track 36 | func (d *DataTrack) Create(target entities.Target, status int) *entities.Track { 37 | doc := entities.Track{ 38 | ID: bson.NewObjectId(), 39 | TargetID: target.ID, 40 | Status: status, 41 | CreatedAt: time.Now(), 42 | } 43 | if err := d.collection.Insert(doc); err != nil { 44 | log.Printf("Can't insert document: %v\n", err) 45 | } 46 | 47 | return &doc 48 | } 49 | 50 | // RemoveByTargetID removes a track by the targetId field 51 | func (d *DataTrack) RemoveByTargetID(id string) { 52 | err := d.collection.Remove(bson.M{"targetId": bson.ObjectIdHex(id)}) 53 | if err != nil { 54 | log.Printf("Can't delete document: %v\n", err) 55 | } 56 | } 57 | 58 | // Start a new instance of data track 59 | func (d *DataTrack) Start(db DB, events chan entities.Event) { 60 | d.collection = db.Session.DB(db.DBName).C("track") 61 | d.events = events 62 | } 63 | -------------------------------------------------------------------------------- /monitor/docker.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | docker "github.com/fsouza/go-dockerclient" 8 | "github.com/maxcnunes/go-uptime-api/monitor/data" 9 | ) 10 | 11 | var ( 12 | client *docker.Client 13 | ) 14 | 15 | const ( 16 | dockerCreate = "create" 17 | endpoint = "unix:///tmp/docker.sock" 18 | ) 19 | 20 | // Container has informations about a Docker container that will be used as a target 21 | type Container struct { 22 | URL string 23 | Name string 24 | } 25 | 26 | // StartEventListener starts to listen for all events from Docker. 27 | // But only cares to created containers. 28 | func StartEventListener(data *data.DataMonitor) { 29 | client, _ = docker.NewClient(endpoint) 30 | 31 | dockerEvents := make(chan *docker.APIEvents) 32 | log.Println("Starting docker events...") 33 | go func() { 34 | // add our channel as an event listener for docker events 35 | if err := client.AddEventListener(dockerEvents); err != nil { 36 | log.Fatalf("Unable to register docker events listener, error: %s", err) 37 | } 38 | 39 | // start the event loop and wait for docker events 40 | log.Print("Entering into the docker events loop") 41 | for { 42 | select { 43 | case event := <-dockerEvents: 44 | log.Printf("Received docker event status: %s, id: %s", event.Status, event.ID) 45 | 46 | // only cares to created containers 47 | if event.Status == "create" { 48 | virtualHost := getVirtualHost(event.ID) 49 | if virtualHost != "" { 50 | // assumes all virtual host are http for while 51 | data.Target.Create("http://"+virtualHost, nil) 52 | } 53 | } 54 | } 55 | } 56 | }() 57 | } 58 | 59 | // LoadAllVirtualHosts gets all VIRTUAL_HOST environment variables from all Docker containers 60 | func LoadAllVirtualHosts(data *data.DataMonitor) { 61 | filters := make(map[string][]string) 62 | filters["status"] = []string{"running"} 63 | 64 | containers, _ := client.ListContainers(docker.ListContainersOptions{All: false, Filters: filters}) 65 | for _, container := range containers { 66 | log.Printf("ID: %s", container.ID) 67 | log.Printf("Names: %s", container.Names) 68 | 69 | virtualHost := getVirtualHost(container.ID) 70 | 71 | if virtualHost != "" { 72 | // assumes all virtual host are http for while 73 | data.Target.Create("http://"+virtualHost, nil) 74 | } 75 | } 76 | } 77 | 78 | func getVirtualHost(containerID string) string { 79 | info, err := client.InspectContainer(containerID) 80 | if err != nil { 81 | log.Fatalf("Unable to inspect container %s, error: %s", containerID, err) 82 | } 83 | 84 | var virtualHost string 85 | for _, env := range info.Config.Env { 86 | if strings.Contains(env, "VIRTUAL_HOST=") { 87 | virtualHost = strings.Replace(env, "VIRTUAL_HOST=", "", -1) 88 | break 89 | } 90 | } 91 | 92 | return virtualHost 93 | } 94 | -------------------------------------------------------------------------------- /monitor/email.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/maxcnunes/go-uptime-api/monitor/entities" 9 | "gopkg.in/gomail.v1" 10 | ) 11 | 12 | type emailConfig struct { 13 | from string 14 | username string 15 | password string 16 | host string 17 | port int 18 | } 19 | 20 | func getEmailConfig() emailConfig { 21 | conf := emailConfig{ 22 | from: os.Getenv("EMAIL_FROM"), 23 | username: os.Getenv("EMAIL_USERNAME"), 24 | password: os.Getenv("EMAIL_PASSWORD"), 25 | host: os.Getenv("EMAIL_HOST"), 26 | } 27 | 28 | if conf.from == "" || conf.username == "" || conf.password == "" { 29 | log.Fatalln("Missing email configurations") 30 | } 31 | if conf.host == "" { 32 | conf.host = "smtp.gmail.com" 33 | } 34 | if os.Getenv("EMAIL_PORT") == "" { 35 | conf.port = 587 36 | } else { 37 | if port, err := strconv.Atoi(os.Getenv("EMAIL_PORT")); err == nil { 38 | conf.port = port 39 | } 40 | } 41 | 42 | return conf 43 | } 44 | 45 | // SendNotificaton sends notifications to all emails related to a target 46 | // The notification can be about a uptime or downtime depending in the current target's status 47 | func SendNotificaton(target entities.Target) { 48 | if len(target.Emails) == 0 { 49 | return 50 | } 51 | 52 | conf := getEmailConfig() 53 | 54 | msg := gomail.NewMessage() 55 | msg.SetHeader("From", conf.from) 56 | msg.SetHeader("To", target.Emails...) 57 | if target.Status < 500 { 58 | msg.SetHeader("Subject", "Target is UP: "+target.URL) 59 | msg.SetBody("text/html", "Hi,
The target "+target.URL+" is back UP (HTTP "+strconv.Itoa(target.Status)+").") 60 | } else { 61 | msg.SetHeader("Subject", "Target is DOWN: "+target.URL) 62 | msg.SetBody("text/html", "The target "+target.URL+" is currently DOWN (HTTP "+strconv.Itoa(target.Status)+").
Uptime Robot will alert you when it is back up.") 63 | } 64 | 65 | mailer := gomail.NewMailer(conf.host, conf.username, conf.password, conf.port) 66 | if err := mailer.Send(msg); err != nil { 67 | panic(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /monitor/entities/event.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | // Event has the event type triggered target data manipulation actions 4 | // and also has the related target data 5 | type Event struct { 6 | Event string `json:"event"` 7 | Target *Target `json:"target"` 8 | } 9 | 10 | const ( 11 | // Added is the event type triggered when a new has been created 12 | Added = "added" 13 | // Removed is the event type triggered when an existing target has been removed 14 | Removed = "removed" 15 | // Updated is the event type triggered when an existing target has been updated 16 | Updated = "updated" 17 | ) 18 | -------------------------------------------------------------------------------- /monitor/entities/target.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import "gopkg.in/mgo.v2/bson" 4 | 5 | // Target data structure 6 | type Target struct { 7 | ID bson.ObjectId `bson:"_id" json:"id"` 8 | URL string `bson:"url" json:"url"` 9 | Status int `bson:"status" json:"status"` 10 | Emails []string `bson:"emails" json:"emails"` 11 | } 12 | -------------------------------------------------------------------------------- /monitor/entities/track.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/mgo.v2/bson" 7 | ) 8 | 9 | // Track data structure 10 | type Track struct { 11 | ID bson.ObjectId `bson:"_id" json:"id"` 12 | TargetID bson.ObjectId `bson:"targetId" json:"targetId"` 13 | Status int `bson:"status" json:"status"` 14 | CreatedAt time.Time `bson:"createdAt" json:"createdAt"` 15 | } 16 | -------------------------------------------------------------------------------- /monitor/http.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // HTTPResponse defines the results from a http request 10 | type HTTPResponse struct { 11 | URL string 12 | Response *http.Response 13 | Error error 14 | } 15 | 16 | // AsyncHTTPGets requests urls concurrently 17 | func AsyncHTTPGets(urls []string) []*HTTPResponse { 18 | ch := make(chan *HTTPResponse) 19 | responses := []*HTTPResponse{} 20 | 21 | // executes concurrently the request to all urls 22 | for _, url := range urls { 23 | go func(url string) { 24 | fmt.Printf("Fetching %s \n", url) 25 | resp, err := http.Get(url) 26 | ch <- &HTTPResponse{url, resp, err} 27 | }(url) 28 | } 29 | 30 | // waits finish all requests 31 | for { 32 | select { 33 | case r := <-ch: 34 | fmt.Printf("%s was fetched: %v\n", r.URL, r) 35 | responses = append(responses, r) 36 | if len(responses) == len(urls) { 37 | return responses 38 | } 39 | case <-time.After(50 * time.Millisecond): 40 | // fmt.Println(".") 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /monitor/job.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/maxcnunes/go-uptime-api/monitor/data" 9 | ) 10 | 11 | // Job is responsible for running the polling request for all targets 12 | type Job struct { 13 | data *data.DataMonitor 14 | checkAtEvery time.Duration 15 | } 16 | 17 | func (j Job) checkTargetsStatus() { 18 | results := AsyncHTTPGets(j.data.Target.GetAllURLS()) 19 | for _, result := range results { 20 | status := http.StatusBadGateway 21 | if result.Response != nil { 22 | log.Printf("%s status: %s\n", result.URL, result.Response.Status) 23 | status = result.Response.StatusCode 24 | } 25 | 26 | j.saveTracking(result.URL, status) 27 | } 28 | } 29 | 30 | func (j Job) saveTracking(url string, status int) { 31 | target := j.data.Target.FindOneByURL(url) 32 | oldStatus := target.Status 33 | j.data.Track.Create(*target, status) 34 | target.Status = status 35 | j.data.Target.Update(target.ID.Hex(), *target) 36 | 37 | // sends notification only if the status has changed 38 | if oldStatus != status { 39 | SendNotificaton(*target) 40 | } 41 | } 42 | 43 | func (j Job) checkTargetsPeriodically() { 44 | StartEventListener(j.data) 45 | 46 | ticker := time.NewTicker(j.checkAtEvery) 47 | go func() { 48 | for { 49 | select { 50 | case <-ticker.C: 51 | log.Printf("Checking %d URLs status...", len(j.data.Target.GetAll())) 52 | j.checkTargetsStatus() 53 | } 54 | } 55 | }() 56 | } 57 | 58 | // Start a new instance of Job 59 | func (j Job) Start(data *data.DataMonitor, checkAtEvery string) { 60 | j.data = data 61 | 62 | duration, err := time.ParseDuration(checkAtEvery) 63 | if err != nil { 64 | log.Fatalf("Value %v is not a valid duration time", checkAtEvery) 65 | } 66 | j.checkAtEvery = duration 67 | 68 | log.Printf("Starting targets checking async (every %s)", checkAtEvery) 69 | j.checkTargetsPeriodically() 70 | } 71 | -------------------------------------------------------------------------------- /monitor_api_suite_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestMonitorApi(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "MonitorApi Suite") 13 | } 14 | -------------------------------------------------------------------------------- /server/api/target.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/maxcnunes/go-uptime-api/monitor/data" 10 | "github.com/maxcnunes/go-uptime-api/monitor/entities" 11 | ) 12 | 13 | // TargetAPI has all routes related to target data manipulation 14 | type TargetAPI struct { 15 | data *data.DataMonitor 16 | } 17 | 18 | // Start a new instance of target api 19 | func (api *TargetAPI) Start(data *data.DataMonitor) { 20 | api.data = data 21 | } 22 | 23 | // ListHandler handles GET request returning all targets 24 | func (api *TargetAPI) ListHandler(rw http.ResponseWriter, req *http.Request) { 25 | j, err := json.Marshal(api.data.Target.GetAll()) 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | rw.Header().Set("Content-Type", "application/json") 31 | rw.Write(j) 32 | } 33 | 34 | // DetailHandler handles GET request returning a single target 35 | func (api *TargetAPI) DetailHandler(rw http.ResponseWriter, req *http.Request) { 36 | vars := mux.Vars(req) 37 | j, err := json.Marshal(api.data.Target.FindOneByID(vars["id"])) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | rw.Header().Set("Content-Type", "application/json") 43 | rw.Write(j) 44 | } 45 | 46 | // CreateHanler handles POST request creating a new target 47 | func (api *TargetAPI) CreateHanler(rw http.ResponseWriter, req *http.Request) { 48 | var target entities.Target 49 | 50 | err := json.NewDecoder(req.Body).Decode(&target) 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | api.data.Target.Create(target.URL, target.Emails) 56 | 57 | rw.WriteHeader(http.StatusCreated) 58 | } 59 | 60 | // DeleteHandler handles DELETE request deleting the proper target 61 | func (api *TargetAPI) DeleteHandler(rw http.ResponseWriter, req *http.Request) { 62 | vars := mux.Vars(req) 63 | 64 | targetID := vars["id"] 65 | api.data.Target.RemoveByID(targetID) 66 | api.data.Track.RemoveByTargetID(targetID) 67 | 68 | rw.WriteHeader(http.StatusOK) 69 | } 70 | 71 | // UpdateHandler handles PUT request updating the proper target 72 | func (api *TargetAPI) UpdateHandler(rw http.ResponseWriter, req *http.Request) { 73 | vars := mux.Vars(req) 74 | var target entities.Target 75 | 76 | err := json.NewDecoder(req.Body).Decode(&target) 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | api.data.Target.Update(vars["id"], target) 82 | 83 | if err != nil { 84 | panic(err) 85 | } 86 | 87 | log.Printf("Updated target %s with new URL %s", vars["id"], target.URL) 88 | 89 | rw.WriteHeader(http.StatusOK) 90 | } 91 | -------------------------------------------------------------------------------- /server/api/track.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/maxcnunes/go-uptime-api/monitor/data" 8 | ) 9 | 10 | // TrackAPI has all routes related to track data manipulation 11 | type TrackAPI struct { 12 | data *data.DataMonitor 13 | } 14 | 15 | // Start a new instance of track api 16 | func (api *TrackAPI) Start(data *data.DataMonitor) { 17 | api.data = data 18 | } 19 | 20 | // ListHandler handles GET request returning all tracks 21 | func (api *TrackAPI) ListHandler(rw http.ResponseWriter, req *http.Request) { 22 | query := req.URL.Query() 23 | targetID := "" 24 | if len(query["targetId"]) > 0 { 25 | targetID = query["targetId"][0] 26 | } 27 | 28 | j, err := json.Marshal(api.data.Track.Find(targetID)) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | rw.Header().Set("Content-Type", "application/json") 34 | rw.Write(j) 35 | } 36 | -------------------------------------------------------------------------------- /server/router.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/maxcnunes/go-uptime-api/server/api" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/maxcnunes/go-uptime-api/monitor/data" 11 | ) 12 | 13 | // Router aggregates all API routes 14 | type Router struct{} 15 | 16 | var ( 17 | targetAPI = api.TargetAPI{} 18 | trackAPI = api.TrackAPI{} 19 | ) 20 | 21 | // Start the API router 22 | func (r *Router) Start(data *data.DataMonitor) *mux.Router { 23 | log.Print("Starting API server") 24 | router := mux.NewRouter() 25 | 26 | // targets api 27 | targetAPI.Start(data) 28 | router.HandleFunc("/targets", addDefaultHeaders(targetAPI.ListHandler)).Methods("GET", "OPTIONS") 29 | router.HandleFunc("/targets", addDefaultHeaders(targetAPI.CreateHanler)).Methods("POST") 30 | router.HandleFunc("/targets/{id}", addDefaultHeaders(targetAPI.DetailHandler)).Methods("GET", "OPTIONS") 31 | router.HandleFunc("/targets/{id}", addDefaultHeaders(targetAPI.UpdateHandler)).Methods("PUT") 32 | router.HandleFunc("/targets/{id}", addDefaultHeaders(targetAPI.DeleteHandler)).Methods("DELETE") 33 | 34 | // tracks api 35 | trackAPI.Start(data) 36 | router.HandleFunc("/tracks", addDefaultHeaders(trackAPI.ListHandler)).Methods("GET", "OPTIONS") 37 | 38 | return router 39 | } 40 | 41 | func addDefaultHeaders(fn http.HandlerFunc) http.HandlerFunc { 42 | return func(w http.ResponseWriter, r *http.Request) { 43 | if origin := r.Header.Get("Origin"); origin != "" { 44 | w.Header().Set("Access-Control-Allow-Origin", origin) 45 | } 46 | w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") 47 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token") 48 | w.Header().Set("Access-Control-Allow-Credentials", "true") 49 | fn(w, r) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /server/router_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "net/http" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | 9 | "github.com/maxcnunes/go-uptime-api/monitor/entities" 10 | . "github.com/maxcnunes/go-uptime-api/server" 11 | ) 12 | 13 | var ( 14 | router = Router{} 15 | ) 16 | 17 | var _ = Describe("server", func() { 18 | Context("Performing GET request to '/targets' route", func() { 19 | BeforeEach(func() { 20 | dataMonitor.Target.Create("http://first-targe.com", nil) 21 | }) 22 | 23 | It("returns a 200 Status Code", func() { 24 | response := Request("GET", "/targets", nil, nil) 25 | Expect(response.Code).To(Equal(http.StatusOK)) 26 | }) 27 | 28 | It("returns a list of targets", func() { 29 | var result []entities.Target 30 | _ = Request("GET", "/targets", &result, nil) 31 | 32 | Expect(len(result)).To(Equal(1)) 33 | Expect(result[0].URL).To(Equal("http://first-targe.com")) 34 | }) 35 | }) 36 | 37 | Context("Performing POST request to '/targets' route", func() { 38 | It("returns a 201 Status Code", func() { 39 | target := entities.Target{URL: "http://second-targe.com"} 40 | response := Request("POST", "/targets", nil, target) 41 | Expect(response.Code).To(Equal(http.StatusCreated)) 42 | }) 43 | }) 44 | 45 | Context("Performing PUT request to '/targets' route", func() { 46 | var target *entities.Target 47 | var result entities.Target 48 | 49 | BeforeEach(func() { 50 | target = dataMonitor.Target.Create("http://first-targe.com", nil) 51 | 52 | target.URL = "http://updated-targe.com" 53 | target.Status = http.StatusBadGateway 54 | }) 55 | 56 | It("returns a 200 Status Code", func() { 57 | response := Request("PUT", "/targets/"+target.ID.Hex(), &result, target) 58 | Expect(response.Code).To(Equal(http.StatusOK)) 59 | }) 60 | 61 | It("updates the target in the database", func() { 62 | _ = Request("PUT", "/targets/"+target.ID.Hex(), &result, target) 63 | 64 | item := dataMonitor.Target.FindOneByID(target.ID.Hex()) 65 | 66 | Expect(item.URL).To(Equal(target.URL)) 67 | Expect(item.Status).To(Equal(target.Status)) 68 | }) 69 | }) 70 | 71 | Context("Performing DELETE request to '/targets' route", func() { 72 | var target *entities.Target 73 | BeforeEach(func() { 74 | target = dataMonitor.Target.Create("http://first-targe.com", nil) 75 | }) 76 | 77 | It("returns a 200 Status Code", func() { 78 | response := Request("DELETE", "/targets/"+target.ID.Hex(), nil, nil) 79 | Expect(response.Code).To(Equal(http.StatusOK)) 80 | }) 81 | }) 82 | 83 | Context("Performing GET request to '/tracks' route", func() { 84 | var target *entities.Target 85 | 86 | BeforeEach(func() { 87 | target = dataMonitor.Target.Create("http://first-targe.com", nil) 88 | dataMonitor.Track.Create(*target, http.StatusCreated) 89 | }) 90 | 91 | It("returns a 200 Status Code", func() { 92 | response := Request("GET", "/tracks", nil, nil) 93 | Expect(response.Code).To(Equal(http.StatusOK)) 94 | }) 95 | 96 | It("returns a list of tracks", func() { 97 | var result []entities.Track 98 | _ = Request("GET", "/tracks", &result, nil) 99 | 100 | Expect(len(result)).To(Equal(1)) 101 | Expect(result[0].TargetID).To(Equal(target.ID)) 102 | Expect(result[0].Status).To(Equal(http.StatusCreated)) 103 | }) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /server/server_suite_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/gorilla/mux" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | 17 | "github.com/maxcnunes/go-uptime-api/monitor/data" 18 | ) 19 | 20 | var ( 21 | db = data.DB{} 22 | dataMonitor = data.DataMonitor{} 23 | handlers *mux.Router 24 | ) 25 | 26 | func TestMonitorApi(t *testing.T) { 27 | RegisterFailHandler(Fail) 28 | RunSpecs(t, "Monitor-API-Server Suite") 29 | } 30 | 31 | // Setup server's tests 32 | var _ = BeforeSuite(func() { 33 | db.Start() 34 | dataMonitor.Start(db) 35 | 36 | handlers = router.Start(&dataMonitor) 37 | }) 38 | 39 | // Teardown server's tests 40 | var _ = AfterSuite(func() { 41 | db.Close() 42 | }) 43 | 44 | // Cleans the databse after each test 45 | var _ = AfterEach(func() { 46 | db.Wipe() 47 | }) 48 | 49 | // 50 | // Helper functions for server's test 51 | // 52 | 53 | // Request sends a new request to the server been tested 54 | func Request(method string, route string, result interface{}, body interface{}) *httptest.ResponseRecorder { 55 | bodyRequest := parseBodyRequest(body) 56 | 57 | request, _ := http.NewRequest(method, route, bodyRequest) 58 | response := httptest.NewRecorder() 59 | 60 | handlers.ServeHTTP(response, request) 61 | 62 | if result != nil { 63 | json.Unmarshal(response.Body.Bytes(), &result) 64 | } 65 | 66 | return response 67 | } 68 | 69 | func parseBodyRequest(item interface{}) io.Reader { 70 | if item == nil { 71 | return nil 72 | } 73 | 74 | body, err := json.Marshal(item) 75 | if err != nil { 76 | log.Println("Unable to marshal item") 77 | } 78 | 79 | return bytes.NewReader(body) 80 | } 81 | -------------------------------------------------------------------------------- /server/websocket.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/websocket" 8 | "github.com/maxcnunes/go-uptime-api/monitor/data" 9 | "github.com/maxcnunes/go-uptime-api/monitor/entities" 10 | ) 11 | 12 | // Websocket aggregates all the web socket configuration and actions 13 | type Websocket struct { 14 | data *data.DataMonitor 15 | } 16 | 17 | type wsConnection struct { 18 | conn *websocket.Conn 19 | } 20 | 21 | var ( 22 | upgrader = websocket.Upgrader{ 23 | ReadBufferSize: 1024, 24 | WriteBufferSize: 1024, 25 | CheckOrigin: func(r *http.Request) bool { return true }, // accepts any origin 26 | } 27 | ) 28 | 29 | // SendText ... 30 | func (ws wsConnection) SendText(msg string) { 31 | log.Printf("Sends ws message [%s]", msg) 32 | ws.conn.WriteMessage(websocket.TextMessage, []byte(msg)) 33 | } 34 | 35 | // SendJSON ... 36 | func (ws wsConnection) SendJSON(json interface{}) { 37 | log.Printf("Sends ws object [%v]", json) 38 | if err := ws.conn.WriteJSON(json); err != nil { 39 | log.Printf(" Error sending ws object [%v]: %v", json, err) 40 | } 41 | } 42 | 43 | // Start ... 44 | func (ws Websocket) Start(dm *data.DataMonitor) func(http.ResponseWriter, *http.Request) { 45 | log.Print("Starting websocket server") 46 | ws.data = dm 47 | 48 | return func(rw http.ResponseWriter, req *http.Request) { 49 | 50 | conn, err := upgrader.Upgrade(rw, req, nil) 51 | if err != nil { 52 | log.Println(err) 53 | return 54 | } 55 | wsConn := &wsConnection{conn: conn} 56 | 57 | log.Printf("WS Connnection entering into the data events loop %s", conn.LocalAddr().String()) 58 | for { 59 | select { 60 | case event := <-ws.data.Events: 61 | switch event.Event { 62 | case entities.Added: 63 | // wsConn.SendText("WS: Added new target") 64 | wsConn.SendJSON(event) 65 | case entities.Removed: 66 | wsConn.SendJSON(event) 67 | // wsConn.SendText("WS: Removed old target") 68 | } 69 | } 70 | } 71 | } 72 | } 73 | --------------------------------------------------------------------------------