├── .gitignore ├── LICENSE ├── README.md ├── assets └── screenshot.png ├── server.go └── static ├── config.json ├── events.js ├── index.html └── styles.css /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Leon Chen 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 | # StatusBoard 2 | 3 | Simple HTTP status checker written in Go, complete with a dashboard for all your configured endpoints. 4 | 5 | The front-end page will automatically subscribe to update events, which are Server-Sent Events. All concurrently connected clients will receive the same update events. Slack error notifications can also be configured. 6 | 7 |

8 | 9 |

10 | 11 | ## Installing 12 | 13 | ``` 14 | go get github.com/transcranial/statusboard 15 | ``` 16 | 17 | **Config** 18 | 19 | Modify `static/config.json`. Add as many HTTP/HTTPS endpoints as you need. Each endpoint can be configured with its own status check interval (in seconds), and timeout limit (in milliseconds). The only requirement is that `id` be unique for each endpoint. 20 | 21 | **Slack notifications** 22 | 23 | Add your Slack webhook URL and message settings to the config for error notifications. To skip Slack notifications, these can be left as empty strings. 24 | 25 | **Start server** 26 | 27 | ``` 28 | go run server.go 29 | ``` 30 | 31 | [http://localhost:8080](http://localhost:8080) 32 | 33 | The page will automatically subscribe to update events. Currently it's configured to display events from the most recent hour. 34 | 35 | **Nginx** 36 | 37 | If running behind an Nginx proxy, the following is required for the SSEs to work: 38 | 39 | ```nginx 40 | proxy_set_header Connection ''; 41 | proxy_http_version 1.1; 42 | chunked_transfer_encoding off; 43 | ``` 44 | 45 | ## Notes 46 | 47 | There is just enough functionality to be useful, but the advantage is that it's extremely easy to setup. If more advanced features are required, such as TCP endpoints, advanced SSL requirements, data persistence, etc., there are some other great libraries, such as Sourcegraph's [Checkup](https://github.com/sourcegraph/checkup). 48 | 49 | ## License 50 | 51 | [MIT](https://github.com/transcranial/statusboard/blob/master/LICENSE) 52 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transcranial/statusboard/6c2d9410408af06f32cf60523360f76f3a7dd8dc/assets/screenshot.png -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "net/http/cookiejar" 11 | "os" 12 | "path/filepath" 13 | "runtime" 14 | "time" 15 | ) 16 | 17 | var ( 18 | slackTemplate Slack 19 | checkRequestChannel chan *Check 20 | checkConfigs []Check 21 | ) 22 | 23 | // Slack config 24 | type Slack struct { 25 | URL string `json:"url"` 26 | Username string `json:"username"` 27 | Icon string `json:"icon_emoji"` 28 | Channel string `json:"channel"` 29 | Text string `json:"text"` 30 | } 31 | 32 | // Check is a struct representing info for an http checker 33 | type Check struct { 34 | ID string `json:"id"` 35 | URL string `json:"url"` 36 | Name string `json:"name"` 37 | Description string `json:"description"` 38 | Method string `json:"method"` 39 | Interval int64 `json:"interval"` 40 | Timeout int64 `json:"timeout"` 41 | 42 | // results 43 | Timestamp time.Time `json:"timestamp"` 44 | StatusCode int `json:"statusCode"` 45 | ResponseTime int64 `json:"responseTime"` 46 | Error string `json:"error"` 47 | PreviousOK bool `json:"previousOk"` 48 | } 49 | 50 | // Config is a struct representing data for checkers 51 | type Config struct { 52 | Slack Slack `json:"slack"` 53 | Checks []Check `json:"checks"` 54 | } 55 | 56 | // Broker tracks attached clients and broadcasts events to those clients. 57 | type Broker struct { 58 | clients map[chan *Check]bool 59 | newClients chan chan *Check 60 | oldClients chan chan *Check 61 | checkResult chan *Check 62 | } 63 | 64 | // Start creates a new goroutine, handling addition & removal of clients, and 65 | // broadcasting of checkResult out to clients that are currently attached. 66 | func (broker *Broker) Start() { 67 | go func() { 68 | for { 69 | select { 70 | case sendChannel := <-broker.newClients: 71 | broker.clients[sendChannel] = true 72 | case sendChannel := <-broker.oldClients: 73 | delete(broker.clients, sendChannel) 74 | close(sendChannel) 75 | case check := <-broker.checkResult: 76 | for sendChannel := range broker.clients { 77 | sendChannel <- check 78 | } 79 | } 80 | } 81 | }() 82 | } 83 | 84 | func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { 85 | f, ok := w.(http.Flusher) 86 | if !ok { 87 | http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) 88 | return 89 | } 90 | 91 | checkResult := make(chan *Check) 92 | broker.newClients <- checkResult 93 | 94 | notify := w.(http.CloseNotifier).CloseNotify() 95 | go func() { 96 | <-notify 97 | broker.oldClients <- checkResult 98 | }() 99 | 100 | w.Header().Set("Content-Type", "text/event-stream") 101 | w.Header().Set("Cache-Control", "no-cache") 102 | w.Header().Set("Connection", "keep-alive") 103 | 104 | for { 105 | check, open := <-checkResult 106 | if !open { 107 | // disconnected client 108 | break 109 | } 110 | 111 | stringified, err := json.Marshal(*check) 112 | if err != nil { 113 | fmt.Fprint(w, "data: {}\n\n") 114 | } else { 115 | fmt.Fprintf(w, "data: %s\n\n", stringified) 116 | } 117 | 118 | f.Flush() 119 | } 120 | } 121 | 122 | // createCheckRequests makes url request check at the specified interval 123 | func createCheckRequests() { 124 | for i := range checkConfigs { 125 | go func(check *Check) { 126 | ticker := time.NewTicker(time.Duration(check.Interval) * time.Second) 127 | for { 128 | <-ticker.C 129 | checkRequestChannel <- check 130 | } 131 | }(&checkConfigs[i]) 132 | } 133 | } 134 | 135 | // checkRequestChannelListener listens to checkRequestChannel and performs requests 136 | func checkRequestChannelListener(broker *Broker) { 137 | for check := range checkRequestChannel { 138 | go doRequest(check, broker) 139 | } 140 | } 141 | 142 | // doRequest performs check requests 143 | // and sends it to checkResults of Broker 144 | func doRequest(check *Check, broker *Broker) { 145 | jar, err := cookiejar.New(nil) 146 | if err != nil { 147 | check.Error = err.Error() 148 | broker.checkResult <- check 149 | return 150 | } 151 | 152 | client := http.Client{ 153 | Timeout: time.Duration(check.Timeout) * time.Millisecond, 154 | Jar: jar, 155 | } 156 | 157 | request, err := http.NewRequest(check.Method, check.URL, nil) 158 | if err != nil { 159 | check.Error = err.Error() 160 | broker.checkResult <- check 161 | return 162 | } 163 | 164 | start := time.Now() 165 | check.Timestamp = start 166 | resp, err := client.Do(request) 167 | if err != nil { 168 | check.Error = err.Error() 169 | broker.checkResult <- check 170 | 171 | if err, ok := err.(net.Error); ok && err.Timeout() { 172 | check.ResponseTime = check.Timeout 173 | // notify slack only if status changed 174 | if check.PreviousOK { 175 | text := fmt.Sprintf("%s [%s] timed out after %dms.", check.Name, check.URL, check.Timeout) 176 | notifySlack(text) 177 | } 178 | } else { 179 | // notify slack only if status changed 180 | if check.PreviousOK { 181 | text := fmt.Sprintf("%s [%s] is down.", check.Name, check.URL) 182 | notifySlack(text) 183 | } 184 | } 185 | 186 | // set PreviousOK for next check 187 | check.PreviousOK = false 188 | 189 | return 190 | } 191 | 192 | check.Error = "" 193 | check.StatusCode = resp.StatusCode 194 | elapsed := time.Since(start) 195 | check.ResponseTime = int64(elapsed / time.Millisecond) 196 | 197 | log.Printf("%s %s - %dms - %d %s", check.Method, check.URL, check.ResponseTime, check.StatusCode, check.Error) 198 | 199 | broker.checkResult <- check 200 | 201 | // notify slack if status changed 202 | if !check.PreviousOK { 203 | text := fmt.Sprintf("%s [%s] is up.", check.Name, check.URL) 204 | notifySlack(text) 205 | } 206 | 207 | // set PreviousOK for next check 208 | check.PreviousOK = true 209 | } 210 | 211 | // notifySlack sends an alert message to slack 212 | func notifySlack(text string) { 213 | slackTemplate.Text = text 214 | b := new(bytes.Buffer) 215 | err := json.NewEncoder(b).Encode(slackTemplate) 216 | if err != nil { 217 | return 218 | } 219 | if slackTemplate.URL != "" { 220 | http.Post(slackTemplate.URL, "application/json; charset=utf-8", b) 221 | } 222 | } 223 | 224 | func main() { 225 | // read from config file 226 | _, currentFilePath, _, ok := runtime.Caller(0) 227 | if !ok { 228 | fmt.Println("Error recovering current file path.") 229 | } 230 | absConfigFile := filepath.Join(filepath.Dir(currentFilePath), "static", "config.json") 231 | configFile, err := os.Open(absConfigFile) 232 | if err != nil { 233 | fmt.Println("Error opening config file:\n", err.Error()) 234 | os.Exit(1) 235 | } 236 | 237 | // parse config file 238 | jsonParser := json.NewDecoder(configFile) 239 | var config Config 240 | if err = jsonParser.Decode(&config); err != nil { 241 | fmt.Println("Error parsing config file:\n", err.Error()) 242 | os.Exit(1) 243 | } 244 | 245 | // create slackTemplate 246 | slackTemplate = config.Slack 247 | 248 | // create buffered channel for check requests 249 | checkRequestChannel = make(chan *Check, len(config.Checks)) 250 | 251 | // create check configurations 252 | checkConfigs = config.Checks 253 | for i := range checkConfigs { 254 | // defaults 255 | checkConfigs[i].Error = "" 256 | checkConfigs[i].PreviousOK = true 257 | } 258 | 259 | // create check requests 260 | createCheckRequests() 261 | 262 | // make new broker instance 263 | broker := &Broker{ 264 | make(map[chan *Check]bool), 265 | make(chan (chan *Check)), 266 | make(chan (chan *Check)), 267 | make(chan *Check), 268 | } 269 | broker.Start() 270 | 271 | // goroutine that listens in on channel receiving check requests 272 | go checkRequestChannelListener(broker) 273 | 274 | // set broker as the HTTP handler for /events 275 | http.Handle("/events", broker) 276 | 277 | // serve static folder 278 | http.Handle("/", http.FileServer(http.Dir(filepath.Join(filepath.Dir(currentFilePath), "static")))) 279 | 280 | log.Println("Running checks...serving on port 8080.") 281 | http.ListenAndServe(":8080", nil) 282 | } 283 | -------------------------------------------------------------------------------- /static/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "slack": { 3 | "url": "", 4 | "username": "statusboard", 5 | "icon_emoji": ":exclamation:", 6 | "channel": "" 7 | }, 8 | "checks": [ 9 | { 10 | "id": "0", 11 | "url": "https://www.wikipedia.org/", 12 | "name": "Wikipedia", 13 | "description": "description", 14 | "method": "GET", 15 | "interval": 60, 16 | "timeout": 1000 17 | }, 18 | { 19 | "id": "1", 20 | "url": "https://www.google.com/", 21 | "name": "Google", 22 | "description": "description", 23 | "method": "GET", 24 | "interval": 60, 25 | "timeout": 1000 26 | }, 27 | { 28 | "id": "2", 29 | "url": "https://twitter.com/", 30 | "name": "Twitter", 31 | "description": "description", 32 | "method": "GET", 33 | "interval": 60, 34 | "timeout": 1000 35 | }, 36 | { 37 | "id": "3", 38 | "url": "http://www.apple.com/", 39 | "name": "Apple", 40 | "description": "description", 41 | "method": "GET", 42 | "interval": 60, 43 | "timeout": 1000 44 | }, 45 | { 46 | "id": "4", 47 | "url": "https://www.netflix.com/", 48 | "name": "Netflix", 49 | "description": "description", 50 | "method": "GET", 51 | "interval": 60, 52 | "timeout": 1000 53 | }, 54 | { 55 | "id": "5", 56 | "url": "https://news.ycombinator.com/", 57 | "name": "Hacker News", 58 | "description": "description", 59 | "method": "GET", 60 | "interval": 60, 61 | "timeout": 1000 62 | }, 63 | { 64 | "id": "6", 65 | "url": "https://github.com/", 66 | "name": "GitHub", 67 | "description": "description", 68 | "method": "GET", 69 | "interval": 60, 70 | "timeout": 1000 71 | }, 72 | { 73 | "id": "7", 74 | "url": "https://www.amazon.com/", 75 | "name": "Amazon", 76 | "description": "description", 77 | "method": "GET", 78 | "interval": 60, 79 | "timeout": 1000 80 | }, 81 | { 82 | "id": "8", 83 | "url": "https://www.microsoft.com", 84 | "name": "Microsoft", 85 | "description": "description", 86 | "method": "GET", 87 | "interval": 60, 88 | "timeout": 1000 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /static/events.js: -------------------------------------------------------------------------------- 1 | Vue.http.get('config.json').then(res => { 2 | const config = typeof res.body === 'string' ? JSON.parse(res.body) : res.body 3 | const checks = config.checks 4 | 5 | let checksData = {} 6 | checks.forEach(function (check) { 7 | checksData[check.id] = [] 8 | }) 9 | 10 | new Vue({ 11 | el: '#statusboard', 12 | data: { 13 | checks: checks, 14 | checksData: checksData 15 | }, 16 | computed: { 17 | statuses: function () { 18 | let s = {} 19 | Object.keys(this.checksData).forEach(id => { 20 | const last = this.checksData[id][0] || {} 21 | if (last.statusCode === 200 && !last.error) { 22 | s[id] = 'OK' 23 | } else if (last.statusCode >= 400 || last.error) { 24 | s[id] = 'error' 25 | } else { 26 | s[id] = 'waiting...' 27 | } 28 | }) 29 | return s 30 | } 31 | } 32 | }) 33 | 34 | // d3 charts 35 | 36 | const CHART_WINDOW_MINUTES = 60 37 | const chartMargins = { top: 10, right: 10, bottom: 40, left: 50 } 38 | 39 | let graphsMap = new Map() 40 | 41 | config.checks.forEach(check => { 42 | const id = check.id 43 | 44 | const numDataPoints = CHART_WINDOW_MINUTES * 60 / check.interval 45 | 46 | const elem = d3.select(`#graph-${id}`) 47 | const graph = elem.append('svg:svg') 48 | .attr('width', '100%') 49 | .attr('height', '150px') 50 | .append('g') 51 | .attr('transform', `translate(${chartMargins.left},${chartMargins.top})`) 52 | 53 | const elemDims = elem.node().getBoundingClientRect() 54 | const width = elemDims.width - chartMargins.left - chartMargins.right 55 | const height = elemDims.height - chartMargins.top - chartMargins.bottom 56 | 57 | const x = d3.scaleLinear() 58 | .domain([0, CHART_WINDOW_MINUTES]) 59 | .range([width, 0]) 60 | const y = d3.scaleLinear() 61 | .domain([0, check.timeout]) 62 | .range([height, 0]) 63 | 64 | const line = d3.line() 65 | .curve(d3.curveMonotoneX) 66 | .x((d, i) => x(i * check.interval / 60)) 67 | .y(d => y(d)) 68 | 69 | const initialData = [0] 70 | 71 | graph.append('svg:path') 72 | .attr('d', line(initialData)) 73 | 74 | // x-axis 75 | graph.append('svg:g') 76 | .attr('class', 'axis x-axis') 77 | .attr('transform', `translate(0,${height})`) 78 | .call(d3.axisBottom(x).ticks(6)) 79 | .append('text') 80 | .attr('class', 'axis-label') 81 | .text('minutes') 82 | .attr('transform', `translate(${width / 2}, 30)`) 83 | 84 | // y-axis 85 | graph.append('svg:g') 86 | .attr('class', 'axis y-axis') 87 | .call(d3.axisLeft(y).ticks(5)) 88 | .append('text') 89 | .attr('class', 'axis-label') 90 | .text('ms') 91 | .attr('transform', `translate(-30,${height / 2})`) 92 | 93 | graphsMap.set(id, { graph, line, x, y }) 94 | }) 95 | 96 | // Server-sent events 97 | 98 | const source = new EventSource('/events') 99 | 100 | source.onmessage = e => { 101 | const check = JSON.parse(e.data) || {} 102 | const numDataPoints = CHART_WINDOW_MINUTES * 60 / check.interval 103 | 104 | // update data and graph 105 | checksData[check.id].unshift(check) 106 | 107 | const graphObj = graphsMap.get(check.id) 108 | const graph = graphObj.graph 109 | const line = graphObj.line 110 | const x = graphObj.x 111 | const y = graphObj.y 112 | 113 | const data = checksData[check.id].map(check => check.responseTime) 114 | 115 | graph.selectAll('path') 116 | .interrupt() 117 | .data([data]) 118 | .classed('error', !!checksData[check.id][0].error) 119 | .attr('d', line) 120 | 121 | // limit to last numDataPoints 122 | if (checksData[check.id].length > numDataPoints) { 123 | checksData[check.id].pop() 124 | } 125 | } 126 | }) 127 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | StatusBoard 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
StatusBoard
16 |
17 |
18 |
19 | {{ check.name }} 20 | 26 | {{ statuses[check.id] }} 27 | 28 |
29 |
{{ check.url }}
30 |
{{ check.description }}
31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | @import 'https://fonts.googleapis.com/css?family=Inconsolata'; 2 | @import 'https://fonts.googleapis.com/css?family=Ropa+Sans'; 3 | 4 | body { 5 | background: #4D606E; 6 | color: #F5F5F5; 7 | min-height: 100vh; 8 | font-family: 'Ropa Sans', sans-serif; 9 | } 10 | 11 | .title { 12 | text-align: left; 13 | color: #F5F5F5 !important; 14 | font-family: 'Ropa Sans', sans-serif; 15 | } 16 | 17 | .subtitle { 18 | display: flex; 19 | flex-direction: row; 20 | align-items: center; 21 | justify-content: space-between; 22 | color: #4D606E !important; 23 | font-family: 'Ropa Sans', sans-serif; 24 | margin-bottom: 5px !important; 25 | } 26 | 27 | a { 28 | color: #3FBAC2; 29 | text-decoration: none; 30 | transition: color 0.2s ease-in; 31 | } 32 | 33 | a:hover, a:active { 34 | color: #4D606E; 35 | text-decoration: none; 36 | border-bottom: none !important; 37 | } 38 | 39 | .statusboard { 40 | padding: 50px; 41 | min-height: 50vh; 42 | color: #4D606E; 43 | background: #4D606E; 44 | } 45 | 46 | .statusboard .column { 47 | margin: 20px; 48 | padding: 20px; 49 | text-align: left; 50 | min-height: 100px; 51 | min-width: 450px; 52 | background: #D3D4D8; 53 | box-shadow: 10px 10px 0px 0px #3FBAC2; 54 | } 55 | 56 | .status { 57 | color: #F5F5F5; 58 | font-style: italic; 59 | } 60 | 61 | .status.ok { 62 | color: #3FBAC2; 63 | } 64 | 65 | .status.error { 66 | color: #D91E18; 67 | } 68 | 69 | .graph path { 70 | stroke: #3FBAC2; 71 | stroke-width: 2; 72 | fill: none; 73 | stroke-linecap: round; 74 | } 75 | 76 | .graph path.error { 77 | stroke: #D91E18; 78 | } 79 | 80 | .graph .axis path { 81 | stroke: #4D606E; 82 | stroke-width: 1; 83 | fill: none; 84 | } 85 | 86 | .graph .tick line { 87 | stroke: #4D606E; 88 | stroke-width: 1; 89 | fill: none; 90 | } 91 | 92 | .graph .tick text { 93 | fill: #4D606E; 94 | font-family: 'Inconsolata', sans-serif; 95 | } 96 | 97 | .graph .axis-label { 98 | fill: #4D606E; 99 | font-family: 'Inconsolata', sans-serif; 100 | font-size: 1.5em; 101 | } 102 | --------------------------------------------------------------------------------