├── .gitignore ├── LICENSE ├── README.md ├── broker.go ├── dashing.go ├── example ├── dashboards │ ├── layout.gerb │ └── sample.gerb ├── jobs │ ├── buzzwords.go │ ├── convergence.go │ └── sample.go ├── main.go └── widgets │ ├── graph │ └── graph.html │ ├── list │ └── list.html │ ├── meter │ └── meter.html │ ├── number │ └── number.html │ └── text │ └── text.html ├── server.go └── worker.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | example/example 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2016 Chris Heng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | dashing-go 2 | ========== 3 | 4 | A [Go][1] port of [shopify/dashing][2]. Now frameworkless! 5 | 6 | To generate a dashboard using this library, please use the [Yeoman dashing-go generator][3]. 7 | 8 | For a live demo, here's the default [sample dashboard][4]. 9 | 10 | ### Current Status 11 | 12 | * All endpoints have been ported over! Full functionality is available. 13 | * For an example of how to write jobs in dashing-go, please refer to the [demo dashboard source][5]. 14 | 15 | Credits 16 | ------- 17 | 18 | Much of the code is referenced from [golang-sse-todo][6] by @rwynn. 19 | 20 | [1]: http://golang.org 21 | [2]: http://shopify.github.io/dashing 22 | [3]: https://github.com/gigablah/generator-dashing-go 23 | [4]: http://dashing.kuanyen.net 24 | [5]: https://github.com/gigablah/dashing-go-demo 25 | [6]: https://github.com/rwynn/golang-sse-todo 26 | -------------------------------------------------------------------------------- /broker.go: -------------------------------------------------------------------------------- 1 | package dashing 2 | 3 | // An eventCache stores the latest event for each key, so that new clients can 4 | // catch up. 5 | type eventCache map[string]*Event 6 | 7 | // A Broker broadcasts events to multiple clients. 8 | type Broker struct { 9 | // Create a map of clients, the keys of the map are the channels 10 | // over which we can push messages to attached clients. (The values 11 | // are just booleans and are meaningless) 12 | clients map[chan *Event]bool 13 | 14 | // Channel into which new clients can be pushed 15 | newClients chan chan *Event 16 | 17 | // Channel into which disconnected clients should be pushed 18 | defunctClients chan chan *Event 19 | 20 | // Channel into which events are pushed to be broadcast out 21 | // to attached clients 22 | events chan *Event 23 | 24 | // Cache for most recent events with a certain ID 25 | cache eventCache 26 | } 27 | 28 | // Start managing client connections and event broadcasts. 29 | func (b *Broker) Start() { 30 | go func() { 31 | for { 32 | // Block until we receive from one of the 33 | // three following channels. 34 | select { 35 | case s := <-b.newClients: 36 | // There is a new client attached and we 37 | // want to start sending them events. 38 | b.clients[s] = true 39 | // Send all the cached events so that when a new client connects, it 40 | // doesn't miss previous events 41 | for _, e := range b.cache { 42 | s <- e 43 | } 44 | // log.Println("Added new client") 45 | case s := <-b.defunctClients: 46 | // A client has detached and we want to 47 | // stop sending them events. 48 | delete(b.clients, s) 49 | // log.Println("Removed client") 50 | case event := <-b.events: 51 | b.cache[event.ID] = event 52 | // There is a new event to send. For each 53 | // attached client, push the new event 54 | // into the client's channel. 55 | for s := range b.clients { 56 | s <- event 57 | } 58 | // log.Printf("Broadcast event to %d clients", len(b.clients)) 59 | } 60 | } 61 | }() 62 | } 63 | 64 | // NewBroker creates a Broker instance. 65 | func NewBroker() *Broker { 66 | return &Broker{ 67 | make(map[chan *Event]bool), 68 | make(chan (chan *Event)), 69 | make(chan (chan *Event)), 70 | make(chan *Event), 71 | map[string]*Event{}, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /dashing.go: -------------------------------------------------------------------------------- 1 | package dashing 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // An Event contains the widget ID, a body of data, 10 | // and an optional target (only "dashboard" for now). 11 | type Event struct { 12 | ID string 13 | Body map[string]interface{} 14 | Target string 15 | } 16 | 17 | // Dashing struct definition. 18 | type Dashing struct { 19 | started bool 20 | Broker *Broker 21 | Worker *Worker 22 | Server *Server 23 | Router http.Handler 24 | } 25 | 26 | // ServeHTTP implements the HTTP Handler. 27 | func (d *Dashing) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | if !d.started { 29 | panic("dashing.Start() has not been called") 30 | } 31 | d.Router.ServeHTTP(w, r) 32 | } 33 | 34 | // Start actives the broker and workers. 35 | func (d *Dashing) Start() *Dashing { 36 | if !d.started { 37 | if d.Router == nil { 38 | d.Router = d.Server.NewRouter() 39 | } 40 | d.Broker.Start() 41 | d.Worker.Start() 42 | d.started = true 43 | } 44 | return d 45 | } 46 | 47 | // NewDashing sets up the event broker, workers and webservice. 48 | func NewDashing() *Dashing { 49 | broker := NewBroker() 50 | worker := NewWorker(broker) 51 | server := NewServer(broker) 52 | 53 | if os.Getenv("WEBROOT") != "" { 54 | server.webroot = filepath.Clean(os.Getenv("WEBROOT")) + "/" 55 | } 56 | if os.Getenv("DEV") != "" { 57 | server.dev = true 58 | } 59 | 60 | return &Dashing{ 61 | started: false, 62 | Broker: broker, 63 | Worker: worker, 64 | Server: server, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /example/dashboards/layout.gerb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= yield("title") %> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | <%! yield %> 20 |
21 | 22 | <% if development { %> 23 |
24 |

Paste the following at the top of <%= dashboard %>.gerb

25 | 26 |
27 | Save this layout 28 | <% } %> 29 | 30 | 31 | -------------------------------------------------------------------------------- /example/dashboards/sample.gerb: -------------------------------------------------------------------------------- 1 | <% content "title" { %>My super sweet dashboard<% } %> 2 |
3 | 24 |
Try this: curl -d '{ "auth_token": "YOUR_AUTH_TOKEN", "text": "Hey, Look what I can do!" }' http://<%= request.host %>/widgets/welcome
25 |
26 | -------------------------------------------------------------------------------- /example/jobs/buzzwords.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "gopkg.in/gigablah/dashing-go.v1" 8 | ) 9 | 10 | type buzzwords struct { 11 | words []map[string]interface{} 12 | } 13 | 14 | func (j *buzzwords) Work(send chan *dashing.Event) { 15 | ticker := time.NewTicker(1 * time.Second) 16 | for { 17 | select { 18 | case <-ticker.C: 19 | for i := 0; i < len(j.words); i++ { 20 | if 1 < rand.Intn(3) { 21 | value := j.words[i]["value"].(int) 22 | j.words[i]["value"] = (value + 1) % 30 23 | } 24 | } 25 | send <- &dashing.Event{"buzzwords", map[string]interface{}{ 26 | "items": j.words, 27 | }, ""} 28 | } 29 | } 30 | } 31 | 32 | func init() { 33 | dashing.Register(&buzzwords{[]map[string]interface{}{ 34 | {"label": "Paradigm shift", "value": 0}, 35 | {"label": "Leverage", "value": 0}, 36 | {"label": "Pivoting", "value": 0}, 37 | {"label": "Turn-key", "value": 0}, 38 | {"label": "Streamlininess", "value": 0}, 39 | {"label": "Exit strategy", "value": 0}, 40 | {"label": "Synergy", "value": 0}, 41 | {"label": "Enterprise", "value": 0}, 42 | {"label": "Web 2.0", "value": 0}, 43 | }}) 44 | } 45 | -------------------------------------------------------------------------------- /example/jobs/convergence.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "gopkg.in/gigablah/dashing-go.v1" 8 | ) 9 | 10 | type convergence struct { 11 | points []map[string]int 12 | } 13 | 14 | func (j *convergence) Work(send chan *dashing.Event) { 15 | ticker := time.NewTicker(1 * time.Second) 16 | for { 17 | select { 18 | case <-ticker.C: 19 | j.points = j.points[1:] 20 | j.points = append(j.points, map[string]int{ 21 | "x": j.points[len(j.points)-1]["x"] + 1, 22 | "y": rand.Intn(50), 23 | }) 24 | send <- &dashing.Event{"convergence", map[string]interface{}{ 25 | "points": j.points, 26 | }, ""} 27 | } 28 | } 29 | } 30 | 31 | func init() { 32 | c := &convergence{} 33 | for i := 0; i < 10; i++ { 34 | c.points = append(c.points, map[string]int{ 35 | "x": i, 36 | "y": rand.Intn(50), 37 | }) 38 | } 39 | dashing.Register(c) 40 | } 41 | -------------------------------------------------------------------------------- /example/jobs/sample.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "gopkg.in/gigablah/dashing-go.v1" 8 | ) 9 | 10 | type sample struct{} 11 | 12 | func (j *sample) Work(send chan *dashing.Event) { 13 | ticker := time.NewTicker(1 * time.Second) 14 | var lastValuation, lastKarma, currentValuation, currentKarma int 15 | for { 16 | select { 17 | case <-ticker.C: 18 | lastValuation, currentValuation = currentValuation, rand.Intn(100) 19 | lastKarma, currentKarma = currentKarma, rand.Intn(200000) 20 | send <- &dashing.Event{"valuation", map[string]interface{}{ 21 | "current": currentValuation, 22 | "last": lastValuation, 23 | }, ""} 24 | send <- &dashing.Event{"karma", map[string]interface{}{ 25 | "current": currentKarma, 26 | "last": lastKarma, 27 | }, ""} 28 | send <- &dashing.Event{"synergy", map[string]interface{}{ 29 | "value": rand.Intn(100), 30 | }, ""} 31 | } 32 | } 33 | } 34 | 35 | func init() { 36 | dashing.Register(&sample{}) 37 | } 38 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/subtle" 6 | "encoding/json" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | 12 | "gopkg.in/gigablah/dashing-go.v1" 13 | _ "gopkg.in/gigablah/dashing-go.v1/example/jobs" 14 | ) 15 | 16 | func tokenAuthMiddleware(h http.Handler) http.Handler { 17 | auth := []byte(os.Getenv("TOKEN")) 18 | 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | if len(auth) == 0 { 21 | h.ServeHTTP(w, r) 22 | return 23 | } 24 | if r.Method == "POST" { 25 | body, _ := ioutil.ReadAll(r.Body) 26 | r.Body.Close() 27 | r.Body = ioutil.NopCloser(bytes.NewReader(body)) 28 | 29 | var data map[string]interface{} 30 | json.Unmarshal(body, &data) 31 | token, ok := data["auth_token"] 32 | if !ok { 33 | log.Printf("Auth token missing") 34 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 35 | return 36 | } 37 | 38 | if result := subtle.ConstantTimeCompare(auth, []byte(token.(string))); result != 1 { 39 | log.Printf("Invalid auth token: expected %s, got %s", auth, token) 40 | http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) 41 | return 42 | } 43 | } 44 | 45 | h.ServeHTTP(w, r) 46 | }) 47 | } 48 | 49 | func main() { 50 | port := os.Getenv("PORT") 51 | if port == "" { 52 | port = "8080" 53 | } 54 | 55 | dash := dashing.NewDashing().Start() 56 | log.Fatal(http.ListenAndServe(":"+port, tokenAuthMiddleware(dash))) 57 | } 58 | -------------------------------------------------------------------------------- /example/widgets/graph/graph.html: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /example/widgets/list/list.html: -------------------------------------------------------------------------------- 1 |

2 | 3 |
    4 |
  1. 5 | 6 | 7 |
  2. 8 |
9 | 10 | 16 | 17 |

18 |

19 | -------------------------------------------------------------------------------- /example/widgets/meter/meter.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 |

8 | -------------------------------------------------------------------------------- /example/widgets/number/number.html: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 |

8 | 9 |

10 | 11 |

12 | -------------------------------------------------------------------------------- /example/widgets/text/text.html: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 |

8 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package dashing 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "path/filepath" 9 | "time" 10 | 11 | "gopkg.in/husobee/vestigo.v1" 12 | "gopkg.in/karlseguin/gerb.v0" 13 | ) 14 | 15 | // A Server contains webservice parameters and middlewares. 16 | type Server struct { 17 | dev bool 18 | webroot string 19 | broker *Broker 20 | } 21 | 22 | func param(r *http.Request, name string) string { 23 | return r.FormValue(fmt.Sprintf(":%s", name)) 24 | } 25 | 26 | // IndexHandler redirects to the default dashboard. 27 | func (s *Server) IndexHandler(w http.ResponseWriter, r *http.Request) { 28 | files, _ := filepath.Glob("dashboards/*.gerb") 29 | 30 | for _, file := range files { 31 | dashboard := file[11 : len(file)-5] 32 | if dashboard != "layout" { 33 | http.Redirect(w, r, fmt.Sprintf("/%s", dashboard), http.StatusTemporaryRedirect) 34 | return 35 | } 36 | } 37 | 38 | http.NotFound(w, r) 39 | } 40 | 41 | // EventsHandler opens a keepalive connection and pushes events to the client. 42 | func (s *Server) EventsHandler(w http.ResponseWriter, r *http.Request) { 43 | f, ok := w.(http.Flusher) 44 | if !ok { 45 | http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | c, ok := w.(http.CloseNotifier) 50 | if !ok { 51 | http.Error(w, "Close notification unsupported!", http.StatusInternalServerError) 52 | return 53 | } 54 | 55 | // Create a new channel, over which the broker can 56 | // send this client events. 57 | events := make(chan *Event) 58 | 59 | // Add this client to the map of those that should 60 | // receive updates 61 | s.broker.newClients <- events 62 | 63 | // Remove this client from the map of attached clients 64 | // when the handler exits. 65 | defer func() { 66 | s.broker.defunctClients <- events 67 | }() 68 | 69 | w.Header().Set("Content-Type", "text/event-stream") 70 | w.Header().Set("Cache-Control", "no-cache") 71 | w.Header().Set("Connection", "keep-alive") 72 | w.Header().Set("X-Accel-Buffering", "no") 73 | closer := c.CloseNotify() 74 | 75 | for { 76 | select { 77 | case event := <-events: 78 | data := event.Body 79 | data["id"] = event.ID 80 | data["updatedAt"] = int32(time.Now().Unix()) 81 | json, err := json.Marshal(data) 82 | if err != nil { 83 | continue 84 | } 85 | if event.Target != "" { 86 | fmt.Fprintf(w, "event: %s\n", event.Target) 87 | } 88 | fmt.Fprintf(w, "data: %s\n\n", json) 89 | f.Flush() 90 | case <-closer: 91 | log.Println("Closing connection") 92 | return 93 | } 94 | } 95 | } 96 | 97 | // DashboardHandler serves the dashboard layout template. 98 | func (s *Server) DashboardHandler(w http.ResponseWriter, r *http.Request) { 99 | dashboard := param(r, "dashboard") 100 | if dashboard == "" { 101 | dashboard = fmt.Sprintf("events%s", param(r, "suffix")) 102 | } 103 | template, err := gerb.ParseFile(true, fmt.Sprintf("dashboards/%s.gerb", dashboard), "dashboards/layout.gerb") 104 | 105 | if err != nil { 106 | http.NotFound(w, r) 107 | return 108 | } 109 | 110 | w.Header().Set("Content-Type", "text/html; charset=UTF-8") 111 | 112 | template.Render(w, map[string]interface{}{ 113 | "dashboard": dashboard, 114 | "development": s.dev, 115 | "request": r, 116 | }) 117 | } 118 | 119 | // DashboardEventHandler accepts dashboard events. 120 | func (s *Server) DashboardEventHandler(w http.ResponseWriter, r *http.Request) { 121 | if r.Body != nil { 122 | defer r.Body.Close() 123 | } 124 | 125 | var data map[string]interface{} 126 | 127 | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 128 | http.Error(w, "", http.StatusBadRequest) 129 | return 130 | } 131 | 132 | s.broker.events <- &Event{param(r, "id"), data, "dashboards"} 133 | 134 | w.WriteHeader(http.StatusNoContent) 135 | } 136 | 137 | // WidgetHandler serves widget templates. 138 | func (s *Server) WidgetHandler(w http.ResponseWriter, r *http.Request) { 139 | widget := param(r, "widget") 140 | widget = widget[0 : len(widget)-5] 141 | template, err := gerb.ParseFile(true, fmt.Sprintf("widgets/%s/%s.html", widget, widget)) 142 | 143 | if err != nil { 144 | log.Printf("%v", err) 145 | http.NotFound(w, r) 146 | return 147 | } 148 | 149 | w.Header().Set("Content-Type", "text/html; charset=UTF-8") 150 | 151 | template.Render(w, nil) 152 | } 153 | 154 | // WidgetEventHandler accepts widget data. 155 | func (s *Server) WidgetEventHandler(w http.ResponseWriter, r *http.Request) { 156 | if r.Body != nil { 157 | defer r.Body.Close() 158 | } 159 | 160 | var data map[string]interface{} 161 | 162 | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 163 | log.Printf("%v", err) 164 | http.Error(w, "", http.StatusBadRequest) 165 | return 166 | } 167 | 168 | s.broker.events <- &Event{param(r, "id"), data, ""} 169 | 170 | w.WriteHeader(http.StatusNoContent) 171 | } 172 | 173 | // NewRouter creates a router with defaults. 174 | func (s *Server) NewRouter() http.Handler { 175 | r := vestigo.NewRouter() 176 | r.Get("/", s.IndexHandler) 177 | r.Get("/events", s.EventsHandler) 178 | r.Get("/events:suffix", s.DashboardHandler) // workaround for router edge case 179 | r.Get("/:dashboard", s.DashboardHandler) 180 | r.Post("/dashboards/:id", s.DashboardEventHandler) 181 | r.Get("/views/:widget", s.WidgetHandler) 182 | r.Post("/widgets/:id", s.WidgetEventHandler) 183 | return r 184 | } 185 | 186 | // NewServer creates a Server instance. 187 | func NewServer(b *Broker) *Server { 188 | return &Server{ 189 | dev: false, 190 | webroot: "", 191 | broker: b, 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /worker.go: -------------------------------------------------------------------------------- 1 | package dashing 2 | 3 | // A Job does periodic work and sends events to a channel. 4 | type Job interface { 5 | Work(send chan *Event) 6 | } 7 | 8 | // A Worker contains a collection of jobs. 9 | type Worker struct { 10 | broker *Broker 11 | registry []Job 12 | } 13 | 14 | // Register a job for a particular worker. 15 | func (w *Worker) Register(j Job) { 16 | if j == nil { 17 | panic("Can't register nil job") 18 | } 19 | w.registry = append(w.registry, j) 20 | } 21 | 22 | // Start all jobs. 23 | func (w *Worker) Start() { 24 | for _, j := range w.registry { 25 | go j.Work(w.broker.events) 26 | } 27 | } 28 | 29 | // NewWorker returns a Worker instance. 30 | func NewWorker(b *Broker) *Worker { 31 | return &Worker{ 32 | broker: b, 33 | registry: append([]Job(nil), jobs...), 34 | } 35 | } 36 | 37 | // Global registry for background jobs. 38 | var jobs []Job 39 | 40 | // Register a job to be kicked off upon starting a worker. 41 | func Register(j Job) { 42 | if j == nil { 43 | panic("Can't register nil job") 44 | } 45 | jobs = append(jobs, j) 46 | } 47 | --------------------------------------------------------------------------------