├── .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 |
--------------------------------------------------------------------------------