├── .buildpacks ├── .gitignore ├── LICENSE.md ├── README.md ├── agent ├── Dockerfile ├── agent.go ├── agent_test.go ├── cli │ ├── Makefile │ └── main.go ├── client │ ├── client.go │ └── client_test.go └── lambda │ ├── .gitignore │ ├── Makefile │ ├── README.md │ └── main.go ├── api ├── Dockerfile ├── api.go └── api_test.go ├── client ├── .gitignore ├── Dockerfile ├── package.json ├── public │ ├── _redirects │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── humans.txt │ ├── index.html │ ├── manifest.json │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ ├── robots.txt │ └── safari-pinned-tab.svg ├── src │ ├── api.js │ ├── components │ │ ├── App │ │ │ ├── index.js │ │ │ └── style.module.css │ │ ├── Breakpoint │ │ │ └── index.js │ │ ├── Chart │ │ │ ├── index.js │ │ │ └── style.module.css │ │ ├── Duration │ │ │ └── index.js │ │ ├── History │ │ │ ├── index.js │ │ │ └── style.module.css │ │ ├── Indicator │ │ │ ├── index.js │ │ │ └── style.module.css │ │ ├── Menu │ │ │ ├── index.js │ │ │ └── style.module.css │ │ ├── Website │ │ │ ├── index.js │ │ │ └── style.module.css │ │ └── Welcome │ │ │ ├── index.js │ │ │ └── style.module.css │ ├── global.css │ ├── index.js │ ├── owl-color.svg │ ├── owl-wire.svg │ └── serviceWorker.js └── yarn.lock ├── docker-compose.yml ├── go.mod ├── go.sum ├── screenshot.png └── web ├── .gitignore ├── Dockerfile ├── main.go └── schema.sql /.buildpacks: -------------------------------------------------------------------------------- 1 | https://github.com/heroku/heroku-buildpack-go.git -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | *.log 4 | dist/ 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | © 2019 Corenzan 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 | # Owl 2 | 3 | > Owl is an open-source self-hosted solution for website monitoring and status report. 4 | 5 | ![Owl](screenshot.png) 6 | 7 | ## About 8 | 9 | Owl comprises 3 Go modules and 1 React application: 10 | 11 | 1. An web server that handles all the data in and out of the database. 12 | 2. An agent that can check HTTP endpoints and post the results to the server. 13 | 3. An API library for common types. 14 | 4. A web client for the dashboard. 15 | 16 | ## Development 17 | 18 | You'll need docker-compose 1.23+. Simply run: 19 | 20 | ```sh 21 | $ docker-compose up 22 | ``` 23 | 24 | ### Server 25 | 26 | Located at [./web](web). 27 | 28 | The Server is a web service written in Go using [Echo](https://echo.labstack.com/) and backed by PostgreSQL 11. The container will watch for source file changes and automatically rebuild. 29 | 30 | ### Agent 31 | 32 | Located at [./agent](agent). 33 | 34 | The Agent is a Go package designed to be invoked from a standalone Go program. The container will watch for source file changes and automatically run its test suite. 35 | 36 | It also includes three sub-packages: 37 | 38 | - [./agent/client](agent/client) a decorated HTTP client ready to talk with the Owl server. 39 | - [./agent/lambda](agent/lambda) wraps the client to run on Amazon Lambda. 40 | - [./agent/cli](agent/cli) wraps the client to be ran from a CLI. 41 | 42 | ### Client 43 | 44 | The client is a React application controlled by [create-react-app](https://github.com/facebook/create-react-app). 45 | 46 | ## Deploy 47 | 48 | ⚠️ Although both the server and the client share the same repository **they're deployed separately**. For heroku like environments you can use [git-subtree](https://github.com/apenwarr/git-subtree/blob/master/git-subtree.txt). e.g. 49 | 50 | ```shell 51 | $ git subtree push --prefix web heroku master 52 | ``` 53 | 54 | ## License 55 | 56 | MIT License © 2019 Corenzan 57 | -------------------------------------------------------------------------------- /agent/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine 2 | 3 | RUN apk add --no-cache git build-base 4 | RUN go get github.com/cespare/reflex 5 | 6 | ENV GO111MODULE auto 7 | 8 | CMD ["reflex", "--", "go", "test", "-cover", ".", "./client"] 9 | -------------------------------------------------------------------------------- /agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/http/httptrace" 9 | "sync" 10 | "time" 11 | 12 | "github.com/corenzan/owl/agent/client" 13 | "github.com/corenzan/owl/api" 14 | ) 15 | 16 | type ( 17 | // Agent ... 18 | Agent struct { 19 | apiClient *client.Client 20 | checkClient *http.Client 21 | } 22 | 23 | // Timeline ... 24 | Timeline struct { 25 | Connection, DNS, Dial, TLS, Request, Wait, Response time.Time 26 | } 27 | ) 28 | 29 | // New ... 30 | func New(endpoint, key string) *Agent { 31 | return &Agent{ 32 | apiClient: client.New(endpoint, key), 33 | checkClient: &http.Client{ 34 | Timeout: time.Second * 10, 35 | CheckRedirect: func(_ *http.Request, _ []*http.Request) error { 36 | return http.ErrUseLastResponse 37 | }, 38 | }, 39 | } 40 | } 41 | 42 | // Check ... 43 | func (a *Agent) Check(website *api.Website) (*api.Check, error) { 44 | check := &api.Check{ 45 | WebsiteID: website.ID, 46 | Result: api.ResultDown, 47 | Latency: &api.Latency{}, 48 | } 49 | timeline := &Timeline{} 50 | trace := &httptrace.ClientTrace{ 51 | DNSStart: func(_ httptrace.DNSStartInfo) { 52 | timeline.DNS = time.Now() 53 | }, 54 | DNSDone: func(_ httptrace.DNSDoneInfo) { 55 | check.Latency.DNS = time.Since(timeline.DNS) / time.Millisecond 56 | }, 57 | ConnectStart: func(_, _ string) { 58 | timeline.Connection = time.Now() 59 | }, 60 | ConnectDone: func(_, _ string, _ error) { 61 | check.Latency.Connection = time.Since(timeline.Connection) / time.Millisecond 62 | }, 63 | TLSHandshakeStart: func() { 64 | timeline.TLS = time.Now() 65 | }, 66 | TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { 67 | check.Latency.TLS = time.Since(timeline.TLS) / time.Millisecond 68 | }, 69 | WroteRequest: func(_ httptrace.WroteRequestInfo) { 70 | timeline.Wait = time.Now() 71 | }, 72 | GotFirstResponseByte: func() { 73 | check.Latency.Application = time.Since(timeline.Wait) / time.Millisecond 74 | }, 75 | } 76 | req, err := http.NewRequest("GET", website.URL, nil) 77 | if err != nil { 78 | return nil, err 79 | } 80 | resp, err := a.checkClient.Do(req.WithContext(httptrace.WithClientTrace(req.Context(), trace))) 81 | if err == nil { 82 | if resp.StatusCode < 500 { 83 | check.Result = api.ResultUp 84 | } 85 | } 86 | check.Latency.Total = check.Latency.DNS + check.Latency.TLS + 87 | check.Latency.Connection + check.Latency.Application 88 | return check, err 89 | } 90 | 91 | // Report ... 92 | func (a *Agent) Report(check *api.Check) error { 93 | req, err := a.apiClient.NewRequest("POST", fmt.Sprintf("/websites/%d/checks", check.WebsiteID), check) 94 | if err != nil { 95 | return err 96 | } 97 | return a.apiClient.Do(req, nil) 98 | } 99 | 100 | // Run ... 101 | func (a *Agent) Run() { 102 | req, err := a.apiClient.NewRequest("GET", "/websites?checkable=1", nil) 103 | if err != nil { 104 | log.Printf("agent: failed to fetch websites: %s", err) 105 | } 106 | websites := []*api.Website{} 107 | err = a.apiClient.Do(req, &websites) 108 | if err != nil { 109 | log.Printf("agent: failed to fetch websites: %s", err) 110 | } 111 | semaphore := make(chan struct{}, 5) 112 | wg := &sync.WaitGroup{} 113 | for _, website := range websites { 114 | wg.Add(1) 115 | go (func(w *api.Website) { 116 | defer wg.Done() 117 | defer (func() { 118 | <-semaphore 119 | })() 120 | semaphore <- struct{}{} 121 | log.Printf("agent: checking %s", w.URL) 122 | check, err := a.Check(w) 123 | if err != nil { 124 | log.Printf("agent: check failed: %s", err) 125 | } 126 | if check != nil { 127 | if err := a.Report(check); err != nil { 128 | log.Printf("agent: report failed: %s", err) 129 | } 130 | } 131 | })(website) 132 | } 133 | wg.Wait() 134 | } 135 | -------------------------------------------------------------------------------- /agent/agent_test.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/corenzan/owl/api" 10 | ) 11 | 12 | type RoundTripFunc func(req *http.Request) *http.Response 13 | 14 | func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { 15 | return f(req), nil 16 | } 17 | 18 | func NewTestClient(fn RoundTripFunc) *http.Client { 19 | return &http.Client{ 20 | Transport: RoundTripFunc(fn), 21 | } 22 | } 23 | 24 | func TestNew(t *testing.T) { 25 | c := New("https://api", "123") 26 | if c.apiClient.Endpoint != "https://api" { 27 | t.Fail() 28 | } 29 | if c.apiClient.Key != "123" { 30 | t.Fail() 31 | } 32 | } 33 | 34 | func TestAgentCheck(t *testing.T) { 35 | history := []*http.Request{} 36 | testHTTPClient := NewTestClient(func(req *http.Request) *http.Response { 37 | history = append(history, req) 38 | return &http.Response{ 39 | StatusCode: 200, 40 | Body: ioutil.NopCloser(bytes.NewBufferString(`OK`)), 41 | Header: http.Header{}, 42 | } 43 | }) 44 | 45 | c := New("https://api", "123") 46 | c.checkClient = testHTTPClient 47 | c.apiClient.Client = testHTTPClient 48 | 49 | check, err := c.Check(&api.Website{ 50 | ID: 1, 51 | URL: "https://website", 52 | }) 53 | 54 | if err != nil { 55 | t.Fail() 56 | } 57 | if history[0].Method != "GET" { 58 | t.Fail() 59 | } 60 | if history[0].URL.String() != "https://website" { 61 | t.Fail() 62 | } 63 | 64 | err = c.Report(check) 65 | 66 | if err != nil { 67 | t.Fail() 68 | } 69 | if history[1].Method != "POST" { 70 | t.Fail() 71 | } 72 | if history[1].URL.String() != "https://api/websites/1/checks" { 73 | t.Fail() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /agent/cli/Makefile: -------------------------------------------------------------------------------- 1 | default: clean 2 | mkdir -p dist 3 | go build -o dist/owl-agent 4 | clean: 5 | rm -rf dist 6 | 7 | PHONY: default clean 8 | -------------------------------------------------------------------------------- /agent/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/corenzan/owl/agent" 9 | "github.com/corenzan/owl/api" 10 | ) 11 | 12 | var ( 13 | endpoint, key, url string 14 | ) 15 | 16 | func init() { 17 | flag.StringVar(&endpoint, "endpoint", os.Getenv("API_URL"), "endpoint for the API, also read from API_URL") 18 | flag.StringVar(&key, "key", os.Getenv("API_KEY"), "key for API authorization, also read from API_KEY") 19 | flag.StringVar(&url, "url", "", "skip the API and just check given URL") 20 | } 21 | 22 | func main() { 23 | flag.Parse() 24 | 25 | a := agent.New(endpoint, key) 26 | 27 | if url != "" { 28 | check, err := a.Check(&api.Website{ 29 | URL: url, 30 | }) 31 | if err == nil { 32 | fmt.Printf("%12s: %s\n", "URL", url) 33 | fmt.Printf("%12s: %dms\n", "DNS", check.Latency.DNS) 34 | fmt.Printf("%12s: %dms\n", "TLS", check.Latency.TLS) 35 | fmt.Printf("%12s: %dms\n", "Connection", check.Latency.Connection) 36 | fmt.Printf("%12s: %dms\n", "Application", check.Latency.Application) 37 | fmt.Printf("%12s: %dms\n", "Total", check.Latency.Total) 38 | } else { 39 | fmt.Println("agent/cli:", err) 40 | } 41 | return 42 | } 43 | 44 | a.Run() 45 | } 46 | -------------------------------------------------------------------------------- /agent/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type ( 11 | // Client ... 12 | Client struct { 13 | Endpoint, Key string 14 | Client *http.Client 15 | } 16 | ) 17 | 18 | // New ... 19 | func New(endpoint, key string) *Client { 20 | return &Client{ 21 | Endpoint: endpoint, 22 | Key: key, 23 | Client: &http.Client{ 24 | Timeout: time.Second * 3, 25 | }, 26 | } 27 | } 28 | 29 | // NewRequest ... 30 | func (c *Client) NewRequest(method, path string, payload interface{}) (*http.Request, error) { 31 | body := &bytes.Buffer{} 32 | if payload != nil { 33 | err := json.NewEncoder(body).Encode(payload) 34 | if err != nil { 35 | return nil, err 36 | } 37 | } 38 | req, err := http.NewRequest(method, c.Endpoint+path, body) 39 | if err != nil { 40 | return nil, err 41 | } 42 | req.Header.Set("Authorization", "Bearer "+c.Key) 43 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 44 | return req, nil 45 | } 46 | 47 | // Do ... 48 | func (c *Client) Do(req *http.Request, recipient interface{}) error { 49 | resp, err := c.Client.Do(req) 50 | if err != nil { 51 | return err 52 | } 53 | if recipient != nil { 54 | return json.NewDecoder(resp.Body).Decode(recipient) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /agent/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | a := New("http://server", "123") 12 | if a.Endpoint != "http://server" { 13 | t.Fail() 14 | } 15 | if a.Key != "123" { 16 | t.Fail() 17 | } 18 | if a.Client == nil { 19 | t.Fail() 20 | } 21 | } 22 | 23 | func TestAPINewRequest(t *testing.T) { 24 | a := New("http://server", "123") 25 | req, err := a.NewRequest("GET", "/", nil) 26 | if err != nil { 27 | t.Fail() 28 | } 29 | if req.Method != "GET" { 30 | t.Fail() 31 | } 32 | if req.URL.String() != "http://server/" { 33 | t.Fail() 34 | } 35 | if req.Header.Get("Authorization") != "Bearer 123" { 36 | t.Fail() 37 | } 38 | if req.Header.Get("Content-Type") != "application/json; charset=utf-8" { 39 | t.Fail() 40 | } 41 | payload := []int{1, 2, 3} 42 | req, err = a.NewRequest("GET", "/", payload) 43 | if err != nil { 44 | t.Fail() 45 | } 46 | if body, err := ioutil.ReadAll(req.Body); err != nil || string(body) != "[1,2,3]\n" { 47 | t.Fail() 48 | } 49 | } 50 | 51 | type RoundTripFunc func(req *http.Request) *http.Response 52 | 53 | func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { 54 | return f(req), nil 55 | } 56 | 57 | func NewTestClient(f RoundTripFunc) *http.Client { 58 | return &http.Client{ 59 | Transport: RoundTripFunc(f), 60 | } 61 | } 62 | func TestAPIDo(t *testing.T) { 63 | a := New("http://server", "123") 64 | req, _ := a.NewRequest("GET", "/", nil) 65 | 66 | a.Client = NewTestClient(func(req *http.Request) *http.Response { 67 | return &http.Response{ 68 | StatusCode: 200, 69 | Body: ioutil.NopCloser(bytes.NewBufferString(`"OK"`)), 70 | Header: make(http.Header), 71 | } 72 | }) 73 | 74 | var result string 75 | err := a.Do(req, &result) 76 | if err != nil { 77 | t.Fail() 78 | } 79 | if result != "OK" { 80 | t.Fail() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /agent/lambda/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /agent/lambda/Makefile: -------------------------------------------------------------------------------- 1 | PREFIX=$(abspath .) 2 | GOOS=linux 3 | GOARCH=amd64 4 | RUNTIME=go1.x 5 | MEMORY=128 6 | NAME=owl-agent 7 | HANDLER=dist/owl-agent-lambda-handler 8 | REGION= 9 | TIMEOUT=300 10 | ROLE= 11 | ZIPFILE=$(HANDLER).zip 12 | 13 | include .env 14 | 15 | default: clean 16 | mkdir -p dist 17 | go build -o $(HANDLER) . 18 | zip $(ZIPFILE) $(HANDLER) 19 | clean: 20 | rm -rf dist 21 | update: default 22 | aws lambda update-function-code \ 23 | --function-name $(NAME) \ 24 | --zip-file fileb://$(PREFIX)/$(ZIPFILE) 25 | create: default 26 | aws lambda create-function \ 27 | --region $(REGION) \ 28 | --function-name $(NAME) \ 29 | --memory $(MEMORY) \ 30 | --role $(ROLE) \ 31 | --timeout $(TIMEOUT) \ 32 | --runtime $(RUNTIME) \ 33 | --zip-file fileb://$(PREFIX)/$(ZIPFILE) \ 34 | --handler $(HANDLER) \ 35 | --environment Variables={API_KEY=$(API_KEY),API_URL=$(API_URL)} 36 | invoke: 37 | aws lambda invoke \ 38 | --function-name $(NAME) \ 39 | /dev/null 40 | 41 | PHONY: default clean update create invoke -------------------------------------------------------------------------------- /agent/lambda/README.md: -------------------------------------------------------------------------------- 1 | This is a handler (function) wrapping the Owl agent so it can run on Amazon Lambda. 2 | 3 | Use `make` to build, deploy, and update the function on your account. You'll also need to have the [AWS-CLI client](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) already installed and create a `.env` file with some variables like region and [role ARN](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html) for its execution. 4 | 5 | See [Building Lambda Functions with Go](https://docs.aws.amazon.com/lambda/latest/dg/go-programming-model.html). 6 | 7 | To schedule the agent you can use events. See [Tutorial: Schedule AWS Lambda Functions Using CloudWatch Events](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/RunLambdaSchedule.html). 8 | -------------------------------------------------------------------------------- /agent/lambda/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/aws/aws-lambda-go/lambda" 7 | "github.com/corenzan/owl/agent" 8 | ) 9 | 10 | var a *agent.Agent 11 | 12 | func init() { 13 | endpoint := os.Getenv("API_URL") 14 | key := os.Getenv("API_KEY") 15 | 16 | a = agent.New(endpoint, key) 17 | } 18 | 19 | func main() { 20 | lambda.Start(a.Run) 21 | } 22 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine 2 | 3 | RUN apk add --no-cache git build-base 4 | RUN go get github.com/cespare/reflex 5 | 6 | ENV GO111MODULE auto 7 | 8 | CMD ["reflex", "--", "go", "test", "-cover", "."] 9 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ( 8 | // Website ... 9 | Website struct { 10 | ID uint `json:"id"` 11 | Updated time.Time `json:"updatedAt" db:"updated_at"` 12 | Status string `json:"status"` 13 | URL string `json:"url"` 14 | Uptime float64 `json:"uptime"` 15 | } 16 | 17 | // Latency ... 18 | Latency struct { 19 | DNS time.Duration `json:"dns"` 20 | Connection time.Duration `json:"connection"` 21 | TLS time.Duration `json:"tls"` 22 | Application time.Duration `json:"application"` 23 | Total time.Duration `json:"total"` 24 | } 25 | 26 | // Check ... 27 | Check struct { 28 | ID uint `json:"id"` 29 | WebsiteID uint `json:"websiteId,omitempty" db:"website_id"` 30 | Checked time.Time `json:"checkedAt" db:"checked_at"` 31 | Result string `json:"result"` 32 | Latency *Latency `json:"latency"` 33 | } 34 | 35 | // Stats ... 36 | Stats struct { 37 | Uptime float64 `json:"uptime"` 38 | Apdex float64 `json:"apdex"` 39 | Average float64 `json:"average"` 40 | Lowest float64 `json:"lowest"` 41 | Highest float64 `json:"highest"` 42 | Count uint `json:"count"` 43 | } 44 | 45 | // Entry ... 46 | Entry struct { 47 | Time time.Time `json:"time"` 48 | Status string `json:"status"` 49 | Duration time.Duration `json:"duration"` 50 | } 51 | ) 52 | 53 | // Website status enumaration. 54 | const ( 55 | StatusUnknown = "unknown" 56 | StatusUp = "up" 57 | StatusMaintenance = "maintenance" 58 | StatusDown = "down" 59 | ) 60 | 61 | // Check result enumaration. 62 | const ( 63 | ResultUp = "up" 64 | ResultDown = "down" 65 | ) 66 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | ) 6 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /build 4 | 5 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.12-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json yarn.lock ./ 5 | RUN npm i 6 | 7 | COPY . . 8 | EXPOSE 3000 9 | 10 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "classnames": "^2.2.6", 7 | "moment": "^2.24.0", 8 | "prop-types": "^15.7.2", 9 | "react": "^16.8.6", 10 | "react-dom": "^16.8.6", 11 | "react-moment": "^0.9.1", 12 | "react-scripts": "2.1.8", 13 | "react-timeago": "^4.4.0", 14 | "resetize": "^26.0.0", 15 | "route-parser": "^0.0.5", 16 | "wouter": "^1.1.0" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": "react-app" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /client/public/_redirects: -------------------------------------------------------------------------------- 1 | # https://www.netlify.com/docs/redirects/ 2 | /* /index.html 200 -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #7e5c62 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/humans.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/humans.txt -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 24 | Owl — Dashboard 25 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Owl", 3 | "short_name": "Owl", 4 | "orientation": "portrait", 5 | "start_url": "/?source=pwa", 6 | "icons": [ 7 | { 8 | "src": "/android-chrome-192x192.png", 9 | "sizes": "192x192", 10 | "type": "image/png" 11 | }, 12 | { 13 | "src": "/android-chrome-512x512.png", 14 | "sizes": "512x512", 15 | "type": "image/png" 16 | } 17 | ], 18 | "theme_color": "#7e5c62", 19 | "background_color": "#7e5c62", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /client/public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/mstile-144x144.png -------------------------------------------------------------------------------- /client/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/mstile-310x150.png -------------------------------------------------------------------------------- /client/public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/mstile-310x310.png -------------------------------------------------------------------------------- /client/public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/mstile-70x70.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/client/public/robots.txt -------------------------------------------------------------------------------- /client/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/api.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | const request = path => { 4 | return fetch(process.env.REACT_APP_API_URL + path) 5 | .then(response => { 6 | if (!response.ok) { 7 | throw Error(response.status); 8 | } 9 | return response.json(); 10 | }) 11 | .catch(console.error); 12 | }; 13 | 14 | export default { 15 | websites(after, before) { 16 | return request( 17 | `/websites?after=${moment(after).toISOString()}&before=${moment( 18 | before 19 | ).toISOString()}` 20 | ); 21 | }, 22 | 23 | website(id) { 24 | return request(`/websites/${id}`); 25 | }, 26 | 27 | stats(id, after, before) { 28 | return request( 29 | `/websites/${id}/stats?after=${moment( 30 | after 31 | ).toISOString()}&before=${moment(before).toISOString()}` 32 | ); 33 | }, 34 | 35 | checks(id, after, before) { 36 | return request( 37 | `/websites/${id}/checks?after=${moment( 38 | after 39 | ).toISOString()}&before=${moment(before).toISOString()}` 40 | ); 41 | }, 42 | 43 | history(id, after, before) { 44 | return request( 45 | `/websites/${id}/history?after=${moment( 46 | after 47 | ).toISOString()}&before=${moment(before).toISOString()}` 48 | ); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, createContext } from "react"; 2 | import { Route, Link, useLocation } from "wouter"; 3 | import c from "classnames"; 4 | import Moment from "react-moment"; 5 | import moment from "moment"; 6 | 7 | import Menu from "../Menu"; 8 | import Welcome from "../Welcome"; 9 | import History from "../History"; 10 | 11 | import style from "./style.module.css"; 12 | 13 | export const appContext = createContext(); 14 | 15 | export default () => { 16 | const [period] = useState([moment().subtract(1, "month"), moment()]); 17 | const [path] = useLocation(); 18 | 19 | return ( 20 | 21 |
22 |
23 |

24 | Owl 25 |

26 |
27 | 28 | {" – "} 29 | 30 |
31 |
32 |
33 | 36 |
37 | 38 | 39 |
40 |
41 |
42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /client/src/components/App/style.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | } 6 | 7 | .topbar { 8 | --background: var(--chocolate); 9 | --foreground: var(--khaki-light-background); 10 | align-items: center; 11 | background-color: var(--background); 12 | color: var(--foreground); 13 | display: flex; 14 | flex: 0 0 auto; 15 | height: 4.5rem; 16 | justify-content: space-between; 17 | padding: 0 1.125rem; 18 | } 19 | 20 | .brand, 21 | .period { 22 | overflow: hidden; 23 | text-overflow: ellipsis; 24 | white-space: nowrap; 25 | } 26 | 27 | .brand { 28 | font-size: 1.5em; 29 | } 30 | 31 | .main { 32 | display: flex; 33 | flex: 0 1 100%; 34 | position: relative; 35 | min-height: 0; 36 | } 37 | 38 | .aside, 39 | .content { 40 | -webkit-overflow-scrolling: touch; 41 | height: 100%; 42 | overflow-y: auto; 43 | overflow-x: hidden; 44 | } 45 | 46 | .aside { 47 | flex: 0 0 30%; 48 | min-width: 20rem; 49 | } 50 | 51 | .content { 52 | flex: 0 1 100%; 53 | } 54 | 55 | @media screen and (max-width: 768px) { 56 | .aside { 57 | display: none; 58 | position: absolute; 59 | width: 100%; 60 | z-index: 100; 61 | } 62 | 63 | .aside.open { 64 | display: block; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client/src/components/Breakpoint/index.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect } from "react"; 2 | 3 | const context = createContext(null); 4 | 5 | export const BreakpointProvider = ({ children }) => { 6 | const [width, setWidth] = useState(0); 7 | const [height, setHeight] = useState(0); 8 | 9 | useEffect(() => { 10 | const onResize = e => { 11 | setWidth(window.innerWidth); 12 | setHeight(window.innerHeight); 13 | }; 14 | onResize(); 15 | window.addEventListener("resize", onResize); 16 | return () => { 17 | window.removeEventListener("resize", onResize); 18 | }; 19 | }, []); 20 | 21 | return {children}; 22 | }; 23 | 24 | const Breakpoint = ({ children, minWidth, maxWidth, minHeight, maxHeight }) => { 25 | const { width, height } = useContext(context); 26 | if (width > maxWidth || width < minWidth || height > maxHeight || height < minHeight) { 27 | return null; 28 | } 29 | return <>{children}; 30 | }; 31 | 32 | Breakpoint.defaultProps = { 33 | maxHeight: Infinity, 34 | maxWidth: Infinity, 35 | minHeight: 0, 36 | minWidth: 0 37 | }; 38 | 39 | export default Breakpoint; 40 | -------------------------------------------------------------------------------- /client/src/components/Chart/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, useCallback } from "react"; 2 | import c from "classnames"; 3 | 4 | import style from "./style.module.css"; 5 | import moment from "moment"; 6 | 7 | const maxLatency = 10000; 8 | const height = 128; 9 | const step = 12; 10 | const stroke = 1; 11 | 12 | const Bar = ({ index, check }) => { 13 | const x = step * index; 14 | const y = check.latency.total / maxLatency; 15 | 16 | const dns = (check.latency.dns / (check.latency.total || 1)) * 100; 17 | const connection = (check.latency.connection / (check.latency.total || 1)) * 100; 18 | const tls = (check.latency.tls / (check.latency.total || 1)) * 100; 19 | 20 | return ( 21 | 22 | 23 | {moment(check.checkedAt).format("MMM D, H:mm")} / Result: {check.result === "up" ? "Up" : "Down"} / DNS:{" "} 24 | {check.latency.dns}ms / Connection: {check.latency.connection}ms / TLS: {check.latency.tls}ms / Application:{" "} 25 | {check.latency.application} 26 | ms / Total: {check.latency.total}ms 27 | 28 | {check.latency.total > 0 ? ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ) : null} 42 | 0 ? "url(#fill" + index + ")" : "#aaa"} 48 | className={style.bar} 49 | /> 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default ({ checks }) => { 56 | const ref = useRef(null); 57 | 58 | const [availableSpace, setAvailableSpace] = useState(0); 59 | useEffect(() => { 60 | const calculateAvailableSpace = e => { 61 | const parent = ref.current.parentElement; 62 | const style = getComputedStyle(parent); 63 | setAvailableSpace(parent.clientWidth - parseInt(style.paddingLeft, 10) - parseInt(style.paddingRight, 10)); 64 | }; 65 | calculateAvailableSpace(); 66 | window.addEventListener("resize", calculateAvailableSpace); 67 | return () => { 68 | window.removeEventListener("resize", calculateAvailableSpace); 69 | }; 70 | }, []); 71 | 72 | const limit = Math.floor(availableSpace / step); 73 | const width = step * limit; 74 | const viewBox = `0 0 ${width} ${height}`; 75 | const maxOffset = checks.length - limit; 76 | 77 | const [offset, setOffset] = useState(Infinity); 78 | 79 | useEffect(() => { 80 | setOffset(offset > maxOffset ? maxOffset : offset); 81 | }, [maxOffset]); 82 | 83 | const [previousClientX, setPreviousClientX] = useState(0); 84 | const [previousOffset, setPreviousOffset] = useState(0); 85 | 86 | const onWheel = useCallback( 87 | e => { 88 | if (checks.length < limit) { 89 | return; 90 | } 91 | const delta = e.deltaY || e.deltaX; 92 | if (delta > 0) { 93 | setOffset(Math.min(offset + Math.round(delta / step), maxOffset)); 94 | } else { 95 | setOffset(Math.max(offset + Math.round(delta / step), 0)); 96 | } 97 | e.stopPropagation(); 98 | }, 99 | [offset, maxOffset] 100 | ); 101 | 102 | const onTouchMove = useCallback( 103 | e => { 104 | if (checks.length < limit) { 105 | return; 106 | } 107 | const delta = previousClientX - e.touches[0].clientX; 108 | if (delta > 0) { 109 | setOffset(Math.min(previousOffset + Math.round(delta / step), maxOffset)); 110 | } else { 111 | setOffset(Math.max(previousOffset + Math.round(delta / step), 0)); 112 | } 113 | e.stopPropagation(); 114 | }, 115 | [maxOffset, previousClientX, previousOffset] 116 | ); 117 | 118 | const onTouchStart = useCallback( 119 | e => { 120 | setPreviousClientX(e.touches[0].clientX); 121 | setPreviousOffset(offset); 122 | // e.preventDefault(); 123 | }, 124 | [offset] 125 | ); 126 | 127 | // console.log(checks.length, offset, limit); 128 | 129 | return ( 130 | onTouchMove(e)} 138 | onWheel={e => onWheel(e)} 139 | onTouchStart={e => onTouchStart(e)} 140 | > 141 | {checks.slice(offset, offset + limit).map((check, index) => ( 142 | 143 | ))} 144 | 145 | ); 146 | }; 147 | -------------------------------------------------------------------------------- /client/src/components/Chart/style.module.css: -------------------------------------------------------------------------------- 1 | .svg { 2 | background-color: white; 3 | } 4 | 5 | .area:hover .bar { 6 | opacity: 0.5; 7 | } 8 | 9 | .overlay { 10 | mix-blend-mode: overlay; 11 | fill: var(--positive); 12 | } 13 | 14 | .overlay.bad { 15 | fill: var(--negative); 16 | } 17 | 18 | .bar { 19 | /* transition: all 0.125s ease-out; */ 20 | rx: 2; 21 | ry: 2; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/components/Duration/index.js: -------------------------------------------------------------------------------- 1 | const pluralize = (n, single, plural) => { 2 | return n + " " + (n !== 1 ? plural || single + "s" : single); 3 | }; 4 | 5 | const minute = 60; 6 | const hour = 60 * minute; 7 | const day = 24 * hour; 8 | 9 | export default ({ value }) => { 10 | if (value > day * 3) { 11 | return pluralize(Math.round(value / day), "day"); 12 | } 13 | if (value > hour) { 14 | return pluralize(Math.round(value / hour), "hour"); 15 | } 16 | return pluralize(Math.round(value / minute), "minute"); 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/components/History/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from "react"; 2 | import Moment from "react-moment"; 3 | import c from "classnames"; 4 | 5 | import api from "../../api.js"; 6 | import { appContext } from "../App"; 7 | import Chart from "../Chart"; 8 | import Duration from "../Duration"; 9 | import Website from "../Website"; 10 | 11 | import style from "./style.module.css"; 12 | 13 | const Entry = ({ entry }) => ( 14 | 15 | 16 | 17 | 18 | 19 | 24 | {entry.status === "up" ? "Up" : "Down"} 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | export default ({ params }) => { 34 | const { period } = useContext(appContext); 35 | 36 | const [website, setWebsite] = useState(null); 37 | const [checks, setChecks] = useState([]); 38 | const [history, setHistory] = useState([]); 39 | 40 | useEffect(() => { 41 | api.website(params.id).then(setWebsite); 42 | api.checks(params.id, ...period).then(setChecks); 43 | api.history(params.id, ...period).then(setHistory); 44 | }, [params.id]); 45 | 46 | if (!website) { 47 | return null; 48 | } 49 | 50 | return ( 51 |
52 |
53 | 54 |
55 |
{checks.length ? : null}
56 | 57 | 58 | {history.map(entry => ( 59 | 60 | ))} 61 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/components/History/style.module.css: -------------------------------------------------------------------------------- 1 | .history { 2 | --alternate-background: var(--khaki-light-background); 3 | } 4 | 5 | .topbar { 6 | background-color: var(--sienna-light-background); 7 | position: sticky; 8 | top: 0; 9 | } 10 | 11 | .chart { 12 | padding: 1rem; 13 | } 14 | 15 | .table { 16 | width: 100%; 17 | } 18 | 19 | .table tr:nth-child(2n + 1) { 20 | background-color: var(--alternate-background); 21 | } 22 | 23 | .table td { 24 | height: 3.5rem; 25 | padding: 0 0.5rem; 26 | text-align: center; 27 | vertical-align: middle; 28 | white-space: nowrap; 29 | } 30 | 31 | .table td:first-child { 32 | padding-left: 1rem; 33 | text-align: left; 34 | width: 1px; 35 | } 36 | 37 | .table td:last-child { 38 | padding-right: 1rem; 39 | text-align: right; 40 | width: 1px; 41 | } 42 | 43 | .status { 44 | font-weight: bolder; 45 | color: var(--contrast-positive); 46 | } 47 | 48 | .status.bad { 49 | color: var(--contrast-negative); 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/Indicator/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import c from "classnames"; 3 | 4 | import style from "./style.module.css"; 5 | 6 | export default ({ status }) => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/components/Indicator/style.module.css: -------------------------------------------------------------------------------- 1 | .indicator { 2 | display: inline-block; 3 | color: #8f879b; 4 | vertical-align: middle; 5 | } 6 | 7 | .up { 8 | color: var(--positive); 9 | } 10 | 11 | .down { 12 | color: var(--negative); 13 | } 14 | 15 | .maintenance { 16 | color: var(--banana); 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/Menu/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from "react"; 2 | import { Link, useRoute } from "wouter"; 3 | import c from "classnames"; 4 | 5 | import { appContext } from "../App"; 6 | import Website from "../Website"; 7 | import api from "../../api.js"; 8 | 9 | import style from "./style.module.css"; 10 | 11 | export default () => { 12 | const [match, params] = useRoute("/websites/:id"); 13 | const [websites, update] = useState([]); 14 | const [query, setQuery] = useState(""); 15 | const { period } = useContext(appContext); 16 | 17 | useEffect(() => { 18 | api.websites(...period).then(update); 19 | }, []); 20 | 21 | const onKeyDown = e => { 22 | if (e.code === "Escape") { 23 | setQuery(""); 24 | } 25 | }; 26 | 27 | return ( 28 |
29 |
30 | setQuery(e.target.value)} 35 | onKeyDown={e => onKeyDown(e)} 36 | aria-label="Search Websites" 37 | /> 38 |
39 | {websites 40 | .filter(website => website.url.indexOf(query.toLowerCase()) > -1) 41 | .map(website => ( 42 | 43 | 49 | 50 | 51 | 52 | ))} 53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /client/src/components/Menu/style.module.css: -------------------------------------------------------------------------------- 1 | .menu { 2 | --background: var(--chocolate); 3 | --foreground: var(--khaki-light-background); 4 | background-color: var(--background); 5 | color: var(--foreground); 6 | min-height: 100%; 7 | } 8 | 9 | .search { 10 | background-color: var(--background); 11 | position: sticky; 12 | top: 0; 13 | z-index: 10; 14 | } 15 | 16 | .search input { 17 | background-color: hsla(0, 0%, 0%, 25%); 18 | padding: 1rem; 19 | width: 100%; 20 | } 21 | 22 | .row { 23 | display: block; 24 | background-color: hsla(0, 0%, 0%, 12.5%); 25 | } 26 | 27 | .row:nth-child(2n + 1) { 28 | background-color: hsla(0, 0%, 0%, 25%); 29 | } 30 | 31 | .row.selected { 32 | background-color: var(--sienna); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/components/Website/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from "react"; 2 | import Moment from "react-moment"; 3 | import c from "classnames"; 4 | 5 | import api from "../../api.js"; 6 | import { appContext } from "../App"; 7 | import Indicator from "../Indicator"; 8 | 9 | import style from "./style.module.css"; 10 | 11 | const Uptime = ({ value, className, label }) => ( 12 |
13 | {label ? Uptime : null} 14 | {value ? (value % 1 > 0 ? value.toFixed(2) : value) : 0}% 15 |
16 | ); 17 | const Apdex = ({ value, className }) => ( 18 |
19 | Apdex 20 | {value ? value.toFixed(2) : "0.00"} 21 |
22 | ); 23 | const Average = ({ value, className }) => ( 24 |
25 | Average 26 | {value ? (value / 1000).toFixed(2) : "0.00"}s 27 |
28 | ); 29 | const Lowest = ({ value, className }) => ( 30 |
31 | Lowest 32 | {value ? (value / 1000).toFixed(2) : "0.00"}s 33 |
34 | ); 35 | const Highest = ({ value, className }) => ( 36 |
37 | Highest 38 | {value ? (value / 1000).toFixed(2) : "0.00"}s 39 |
40 | ); 41 | const Checks = ({ value, className }) => ( 42 |
43 | Checks 44 | {value ? value : "0"} 45 |
46 | ); 47 | 48 | export default ({ website, extended, onClick }) => { 49 | const [stats, setStats] = useState(null); 50 | const { period } = useContext(appContext); 51 | 52 | useEffect(() => { 53 | if (extended) { 54 | api.stats(website.id, ...period).then(setStats); 55 | } 56 | }, [extended, website.id]); 57 | 58 | return ( 59 |
60 |
61 |
62 | 63 |
64 |
65 | {website.url} 66 | 67 | 68 | 69 |
70 | {extended ? ( 71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | ) : ( 80 |
81 | 82 |
83 | )} 84 |
85 | {extended ? ( 86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 |
95 |
96 | ) : null} 97 |
98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /client/src/components/Website/style.module.css: -------------------------------------------------------------------------------- 1 | .website { 2 | line-height: 1.25; 3 | width: 100%; 4 | } 5 | 6 | .row { 7 | align-items: center; 8 | display: flex; 9 | height: 3.75rem; 10 | padding: 0 0.5rem; 11 | } 12 | 13 | .row.justified { 14 | justify-content: center; 15 | } 16 | 17 | .segment { 18 | margin: 0 0.5rem; 19 | white-space: nowrap; 20 | } 21 | 22 | .stats { 23 | align-items: center; 24 | display: flex; 25 | } 26 | 27 | .stats > * + * { 28 | margin-left: 1rem; 29 | } 30 | 31 | .label { 32 | display: block; 33 | line-height: 1.5; 34 | opacity: 0.75; 35 | } 36 | 37 | .name { 38 | flex-grow: 1; 39 | overflow: hidden; 40 | text-overflow: ellipsis; 41 | white-space: nowrap; 42 | } 43 | 44 | .row:nth-child(2) { 45 | background-color: var(--alternate-background); 46 | } 47 | 48 | @media screen and (min-width: 769px) { 49 | .mobile { 50 | display: none; 51 | } 52 | 53 | .stats > * + * { 54 | margin-left: 1.5rem; 55 | } 56 | } 57 | 58 | @media screen and (max-width: 768px) { 59 | .desktop { 60 | display: none; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/Welcome/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ReactComponent as Owl } from "../../owl-wire.svg"; 4 | import style from "./style.module.css"; 5 | 6 | export default () => { 7 | return ( 8 |
9 | 10 |

Owl sees all.

11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/components/Welcome/style.module.css: -------------------------------------------------------------------------------- 1 | .welcome { 2 | background-color: var(--khaki-light-background); 3 | color: var(--sienna-light-background); 4 | height: 100%; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | text-align: center; 9 | flex-direction: column; 10 | } 11 | 12 | .owl { 13 | fill: currentColor; 14 | width: 22rem; 15 | } 16 | 17 | .message { 18 | font-size: 4.5em; 19 | font-weight: bold; 20 | } 21 | 22 | @media screen and (max-width: 768px) { 23 | .owl { 24 | width: 15rem; 25 | } 26 | 27 | .message { 28 | font-size: 3em; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/global.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Dark brown, chocolate, khaki, yellow, and orange. 3 | * https://coolors.co/7f5c62-9a6458-e7bf95-ffce00-ff9200 4 | * 5 | * Blackish, grayish, white, positive, and negative. 6 | * https://coolors.co/3b3447-5c546a-ffffff-9EC400-f15524 7 | * 8 | */ 9 | :root { 10 | --blackish: #3b3447; 11 | --grayish: #5c546a; 12 | --white: #ffffff; 13 | --positive: #9ec400; 14 | --contrast-positive: #1d781d; 15 | --negative: #f15524; 16 | --contrast-negative: #d43900; 17 | 18 | --chocolate: #7f5c62; 19 | --sienna: #9a6458; 20 | --sienna-light-background: #f2ebea; 21 | --khaki: #e7bf95; 22 | --khaki-light-background: #fdfbf8; 23 | --banana: #ffce00; 24 | --orange: #ff9200; 25 | } 26 | 27 | html, 28 | body { 29 | height: 100%; 30 | } 31 | 32 | body { 33 | font-family: Arimo, sans-serif; 34 | color: var(--blackish); 35 | } 36 | 37 | #root { 38 | height: 100%; 39 | } 40 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./components/App"; 4 | import * as serviceWorker from "./serviceWorker"; 5 | 6 | import "resetize"; 7 | import "./global.css"; 8 | 9 | ReactDOM.render(, document.getElementById("root")); 10 | 11 | // If you want your app to work offline and load faster, you can change 12 | // unregister() to register() below. Note this comes with some pitfalls. 13 | // Learn more about service workers: https://bit.ly/CRA-PWA 14 | serviceWorker.register(); 15 | -------------------------------------------------------------------------------- /client/src/owl-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 63 | -------------------------------------------------------------------------------- /client/src/owl-wire.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 14 | 17 | 20 | 23 | 24 | -------------------------------------------------------------------------------- /client/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | export function register(config) { 22 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener("load", () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Let's check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl, config); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | "This web app is being served cache-first by a service " + 44 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 45 | ); 46 | }); 47 | } else { 48 | // Is not localhost. Just register service worker 49 | registerValidSW(swUrl, config); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl, config) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | if (installingWorker == null) { 62 | return; 63 | } 64 | installingWorker.onstatechange = () => { 65 | if (installingWorker.state === "installed") { 66 | if (navigator.serviceWorker.controller) { 67 | // At this point, the updated precached content has been fetched, 68 | // but the previous service worker will still serve the older 69 | // content until all client tabs are closed. 70 | console.log( 71 | "New content is available and will be used when all " + 72 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 73 | ); 74 | 75 | // Execute callback 76 | if (config && config.onUpdate) { 77 | config.onUpdate(registration); 78 | } 79 | } else { 80 | // At this point, everything has been precached. 81 | // It's the perfect time to display a 82 | // "Content is cached for offline use." message. 83 | console.log("Content is cached for offline use."); 84 | 85 | // Execute callback 86 | if (config && config.onSuccess) { 87 | config.onSuccess(registration); 88 | } 89 | } 90 | } 91 | }; 92 | }; 93 | }) 94 | .catch(error => { 95 | console.error("Error during service worker registration:", error); 96 | }); 97 | } 98 | 99 | function checkValidServiceWorker(swUrl, config) { 100 | // Check if the service worker can be found. If it can't reload the page. 101 | fetch(swUrl) 102 | .then(response => { 103 | // Ensure service worker exists, and that we really are getting a JS file. 104 | const contentType = response.headers.get("content-type"); 105 | if (response.status === 404 || (contentType != null && contentType.indexOf("javascript") === -1)) { 106 | // No service worker found. Probably a different app. Reload the page. 107 | navigator.serviceWorker.ready.then(registration => { 108 | registration.unregister().then(() => { 109 | window.location.reload(); 110 | }); 111 | }); 112 | } else { 113 | // Service worker found. Proceed as normal. 114 | registerValidSW(swUrl, config); 115 | } 116 | }) 117 | .catch(() => { 118 | console.log("No internet connection found. App is running in offline mode."); 119 | }); 120 | } 121 | 122 | export function unregister() { 123 | if ("serviceWorker" in navigator) { 124 | navigator.serviceWorker.ready.then(registration => { 125 | registration.unregister(); 126 | }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | agent: 4 | build: ./agent 5 | volumes: 6 | - .:/app/owl 7 | working_dir: /app/owl/agent 8 | api: 9 | build: ./api 10 | volumes: 11 | - .:/app/owl 12 | working_dir: /app/owl/api 13 | web: 14 | build: ./web 15 | volumes: 16 | - .:/app/owl 17 | working_dir: /app/owl/web 18 | ports: 19 | - 5000 20 | environment: 21 | - PORT=5000 22 | - API_KEY=123 23 | - VIRTUAL_HOST=api.owl.localhost 24 | - DATABASE_URL=postgres://postgres:123@db/postgres?sslmode=disable 25 | depends_on: 26 | - db 27 | client: 28 | build: ./client 29 | ports: 30 | - 3000 31 | volumes: 32 | - ./client:/app 33 | - /app/node_modules 34 | environment: 35 | - VIRTUAL_HOST=owl.localhost 36 | - REACT_APP_API_URL=http://api.owl.localhost 37 | db: 38 | image: postgres:12-alpine 39 | ports: 40 | - 5432 41 | environment: 42 | - POSTGRES_PASSWORD=123 43 | - PGPASSWORD=123 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // +heroku goVersion go1.13 2 | // +heroku install github.com/corenzan/owl/web 3 | 4 | module github.com/corenzan/owl 5 | 6 | go 1.13 7 | 8 | replace github.com/corenzan/owl/api => ./api 9 | 10 | require ( 11 | github.com/aws/aws-lambda-go v1.13.3 12 | github.com/jackc/pgx/v4 v4.1.2 13 | github.com/labstack/echo/v4 v4.1.11 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/aws/aws-lambda-go v1.13.3 h1:SuCy7H3NLyp+1Mrfp+m80jcbi9KYWAs9/BXwppwRDzY= 3 | github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= 4 | github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= 5 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 6 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 7 | github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 9 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 14 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 15 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 16 | github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= 17 | github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 18 | github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= 19 | github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 20 | github.com/jackc/chunkreader/v2 v2.0.0 h1:DUwgMQuuPnS0rhMXenUtZpqZqrR/30NWY+qQvTpSvEs= 21 | github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 22 | github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= 23 | github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= 24 | github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= 25 | github.com/jackc/pgconn v1.1.0 h1:10i6DMVJOSko/sD3FLpFKBHONzDGKkX8pbLyHC8B92o= 26 | github.com/jackc/pgconn v1.1.0/go.mod h1:GgY/Lbj1VonNaVdNUHs9AwWom3yP2eymFQ1C8z9r/Lk= 27 | github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= 28 | github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= 29 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA= 30 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= 31 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 32 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 33 | github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= 34 | github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= 35 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= 36 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= 37 | github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 38 | github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 39 | github.com/jackc/pgproto3/v2 v2.0.0 h1:FApgMJ/GtaXfI0s8Lvd0kaLaRwMOhs4VH92pwkwQQvU= 40 | github.com/jackc/pgproto3/v2 v2.0.0/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 41 | github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= 42 | github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= 43 | github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= 44 | github.com/jackc/pgtype v1.0.2 h1:TVyes5WLzcWjLUQ5C7WUQOZ/+yd+v7bCfKRd7XMP6Mk= 45 | github.com/jackc/pgtype v1.0.2/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0= 46 | github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 47 | github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= 48 | github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= 49 | github.com/jackc/pgx/v4 v4.1.2 h1:xZwqiD9cP6zF7oJ1NO2j9txtjpA7I+MdfP3h/TAT1Q8= 50 | github.com/jackc/pgx/v4 v4.1.2/go.mod h1:0cQ5ee0A6fEsg29vZekucSFk5OcWy8sT4qkhuPXHuIE= 51 | github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 52 | github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 53 | github.com/jackc/puddle v1.0.0 h1:rbjAshlgKscNa7j0jAM0uNQflis5o2XUogPMVAwtcsM= 54 | github.com/jackc/puddle v1.0.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 55 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 56 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 57 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 58 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 59 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 60 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 61 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 62 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 63 | github.com/labstack/echo/v4 v4.1.11 h1:z0BZoArY4FqdpUEl+wlHp4hnr/oSR6MTmQmv8OHSoww= 64 | github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= 65 | github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= 66 | github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= 67 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 68 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 69 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 70 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 71 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 72 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 73 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 74 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 75 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 76 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 77 | github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= 78 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 79 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 80 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 81 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 82 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 83 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 84 | github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 85 | github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= 86 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 87 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 88 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE= 89 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 90 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 91 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 92 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 93 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 94 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 95 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 96 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 97 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 98 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 99 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 100 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 101 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 102 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 103 | github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= 104 | github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 105 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 106 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 107 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 108 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 109 | go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 110 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 111 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 112 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 113 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 114 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 115 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 h1:0hQKqeLdqlt5iIwVOBErRisrHJAN57yOiPRQItI20fU= 116 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 117 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 118 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 119 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 120 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7 h1:fHDIZ2oxGnUZRN6WgWFCbYBjH9uqVPRCUVUDhs0wnbA= 121 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 122 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 123 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 124 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 125 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 126 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg= 131 | golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 132 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 133 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 134 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 135 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 136 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 137 | golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 138 | golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 139 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 140 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= 141 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 142 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 143 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 144 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 145 | gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= 146 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 147 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 148 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corenzan/owl/df6a15f4767e6c1686772d6ad0cd48911f6ac247/screenshot.png -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine 2 | 3 | RUN apk add --no-cache git build-base 4 | RUN go get github.com/cespare/reflex 5 | 6 | ENV GO111MODULE auto 7 | 8 | CMD ["reflex", "-s", "--", "go", "run", "."] 9 | -------------------------------------------------------------------------------- /web/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/corenzan/owl/api" 13 | 14 | pgx "github.com/jackc/pgx/v4" 15 | "github.com/jackc/pgx/v4/pgxpool" 16 | 17 | "github.com/labstack/echo/v4" 18 | "github.com/labstack/echo/v4/middleware" 19 | ) 20 | 21 | const ( 22 | // Threshold of a satisfactory request, in ms. 23 | apdexThreshold = 2000 24 | ) 25 | 26 | var db *pgxpool.Pool 27 | 28 | var errMissingArgument = fmt.Errorf("missing query parameter") 29 | 30 | func handleNewWebsite(c echo.Context) error { 31 | website := &api.Website{} 32 | if err := c.Bind(website); err != nil { 33 | panic(err) 34 | } 35 | fields := []interface{}{ 36 | &website.ID, 37 | &website.Status, 38 | &website.Updated, 39 | } 40 | q := `insert into websites (url) values ($1) returning id, status, updated_at;` 41 | if err := db.QueryRow(context.Background(), q, website.URL).Scan(fields...); err != nil { 42 | panic(err) 43 | } 44 | if err := c.JSON(http.StatusCreated, website); err != nil { 45 | panic(err) 46 | } 47 | return nil 48 | } 49 | 50 | func queryWebsite(id string) (*api.Website, error) { 51 | if id == "" { 52 | return nil, errMissingArgument 53 | } 54 | q := ` 55 | select 56 | id, 57 | url, 58 | status, 59 | updated_at 60 | from 61 | websites 62 | where 63 | id = $1 64 | limit 1; 65 | ` 66 | website := &api.Website{} 67 | fields := []interface{}{ 68 | &website.ID, 69 | &website.URL, 70 | &website.Status, 71 | &website.Updated, 72 | } 73 | if err := db.QueryRow(context.Background(), q, id).Scan(fields...); err != nil { 74 | return nil, err 75 | } 76 | return website, nil 77 | } 78 | 79 | func handleGetWebsite(c echo.Context) error { 80 | website, err := queryWebsite(c.Param("id")) 81 | if err != nil { 82 | switch err { 83 | case pgx.ErrNoRows: 84 | return echo.NewHTTPError(http.StatusNotFound) 85 | case errMissingArgument: 86 | return echo.NewHTTPError(http.StatusBadRequest) 87 | default: 88 | panic(err) 89 | } 90 | } 91 | if err := c.JSON(http.StatusOK, website); err != nil { 92 | panic(err) 93 | } 94 | return nil 95 | } 96 | 97 | func queryWebsiteStats(id, beginning, ending string) (*api.Stats, error) { 98 | if id == "" { 99 | return nil, errMissingArgument 100 | } 101 | if beginning == "" { 102 | return nil, errMissingArgument 103 | } 104 | if ending == "" { 105 | ending = time.Now().String() 106 | } 107 | var found bool 108 | q := ` 109 | select true from websites where id = $1 limit 1; 110 | ` 111 | if err := db.QueryRow(context.Background(), q, id).Scan(&found); err != nil { 112 | return nil, err 113 | } 114 | stats := &api.Stats{} 115 | q = ` 116 | select 117 | percentage(count(*) filter (where result = 'up'), count(*)) as uptime 118 | from 119 | checks 120 | where 121 | website_id = $1 122 | and checked_at between $2 and $3; 123 | ` 124 | if err := db.QueryRow(context.Background(), q, id, beginning, ending).Scan(&stats.Uptime); err != nil { 125 | return nil, err 126 | } 127 | q = ` 128 | select 129 | (count(*) filter (where result = 'up' and (latency->>'total')::float < $4) 130 | + count(*) filter (where result = 'up' and (latency->>'total')::float >= $4) / 2) 131 | / (case count(*) when 0 then 1 else count(*)::float end) as apdex 132 | from 133 | checks 134 | where 135 | website_id = $1 136 | and checked_at between $2 and $3; 137 | ` 138 | if err := db.QueryRow(context.Background(), q, id, beginning, ending, apdexThreshold).Scan(&stats.Apdex); err != nil { 139 | return nil, err 140 | } 141 | q = ` 142 | select 143 | avg((latency->>'total')::numeric) over (partition by website_id) as average, 144 | min((latency->>'total')::numeric) over (partition by website_id) as lowest, 145 | max((latency->>'total')::numeric) over (partition by website_id) as highest 146 | from 147 | checks 148 | where 149 | website_id = $1 150 | and checked_at between $2 and $3 151 | and result = 'up'; 152 | ` 153 | if err := db.QueryRow(context.Background(), q, id, beginning, ending).Scan(&stats.Average, &stats.Lowest, &stats.Highest); err != nil { 154 | return nil, err 155 | } 156 | q = ` 157 | select 158 | count(*) 159 | from 160 | checks where website_id = $1 161 | and checked_at between $2::timestamptz and $3::timestamptz; 162 | ` 163 | if err := db.QueryRow(context.Background(), q, id, beginning, ending).Scan(&stats.Count); err != nil { 164 | return nil, err 165 | } 166 | return stats, nil 167 | } 168 | 169 | func handleGetWebsiteStats(c echo.Context) error { 170 | website, err := queryWebsiteStats(c.Param("id"), c.QueryParam("after"), c.QueryParam("before")) 171 | if err != nil { 172 | switch err { 173 | case pgx.ErrNoRows: 174 | return echo.NewHTTPError(http.StatusNotFound) 175 | case errMissingArgument: 176 | return echo.NewHTTPError(http.StatusBadRequest) 177 | default: 178 | panic(err) 179 | } 180 | } 181 | if err := c.JSON(http.StatusOK, website); err != nil { 182 | panic(err) 183 | } 184 | return nil 185 | } 186 | 187 | func queryWebsites(checkable, beginning, ending string) ([]*api.Website, error) { 188 | if checkable == "" && beginning == "" { 189 | return nil, errMissingArgument 190 | } 191 | if ending == "" { 192 | ending = time.Now().String() 193 | } 194 | var rows pgx.Rows 195 | var err error 196 | if checkable == "" { 197 | q := ` 198 | select 199 | id, 200 | url, 201 | status, 202 | updated_at, 203 | coalesce(uptime, 0) as uptime 204 | from 205 | websites 206 | left join 207 | (select 208 | website_id, 209 | percentage(count(*) filter (where result = 'up'), count(*)) as uptime 210 | from 211 | checks 212 | where 213 | checked_at between $1 and $2 214 | group by 215 | website_id) t on id = website_id 216 | order by 217 | status desc, 218 | updated_at desc; 219 | ` 220 | rows, err = db.Query(context.Background(), q, beginning, ending) 221 | } else { 222 | q := ` 223 | select 224 | id, 225 | url, 226 | status, 227 | updated_at, 228 | 0.0 229 | from 230 | websites 231 | where 232 | status != 'maintenance' 233 | order by 234 | status desc, 235 | updated_at desc; 236 | ` 237 | rows, err = db.Query(context.Background(), q) 238 | } 239 | if err != nil { 240 | return nil, err 241 | } 242 | defer rows.Close() 243 | websites := []*api.Website{} 244 | for rows.Next() { 245 | website := &api.Website{} 246 | fields := []interface{}{ 247 | &website.ID, 248 | &website.URL, 249 | &website.Status, 250 | &website.Updated, 251 | &website.Uptime, 252 | } 253 | if err := rows.Scan(fields...); err != nil { 254 | return nil, err 255 | } 256 | websites = append(websites, website) 257 | } 258 | return websites, nil 259 | } 260 | 261 | func handleListWebsites(c echo.Context) error { 262 | websites, err := queryWebsites(c.QueryParam("checkable"), c.QueryParam("after"), c.QueryParam("before")) 263 | if err != nil { 264 | switch err { 265 | case pgx.ErrNoRows: 266 | return echo.NewHTTPError(http.StatusNotFound) 267 | case errMissingArgument: 268 | return echo.NewHTTPError(http.StatusBadRequest) 269 | default: 270 | panic(err) 271 | } 272 | } 273 | if err := c.JSON(http.StatusOK, websites); err != nil { 274 | panic(err) 275 | } 276 | return nil 277 | } 278 | 279 | func handleNewCheck(c echo.Context) error { 280 | website := &api.Website{} 281 | q := ` 282 | select 283 | id, 284 | status 285 | from 286 | websites 287 | where 288 | id = $1 289 | limit 1; 290 | ` 291 | if err := db.QueryRow(context.Background(), q, c.Param("id")).Scan(&website.ID, &website.Status); err != nil { 292 | if err == pgx.ErrNoRows { 293 | return echo.NewHTTPError(http.StatusNotFound) 294 | } 295 | panic(err) 296 | } 297 | check := &api.Check{ 298 | WebsiteID: website.ID, 299 | } 300 | if err := c.Bind(check); err != nil { 301 | panic(err) 302 | } 303 | q = `insert into checks (website_id, result, latency) values ($1, $2, $3) returning id, checked_at;` 304 | if err := db.QueryRow(context.Background(), q, website.ID, check.Result, check.Latency).Scan(&check.ID, &check.Checked); err != nil { 305 | panic(err) 306 | } 307 | status := api.StatusDown 308 | if check.Result == api.ResultUp { 309 | status = api.StatusUp 310 | } 311 | if website.Status != status { 312 | q := `update websites set updated_at = current_timestamp, status = $2 where id = $1;` 313 | if _, err := db.Exec(context.Background(), q, website.ID, status); err != nil { 314 | panic(err) 315 | } 316 | } 317 | if err := c.JSON(http.StatusCreated, check); err != nil { 318 | panic(err) 319 | } 320 | return nil 321 | } 322 | 323 | func queryWebsiteChecks(id, beginning, ending string) ([]*api.Check, error) { 324 | if id == "" { 325 | return nil, errMissingArgument 326 | } 327 | if beginning == "" { 328 | return nil, errMissingArgument 329 | } 330 | if ending == "" { 331 | ending = time.Now().String() 332 | } 333 | var found bool 334 | q := ` 335 | select 336 | true 337 | from 338 | websites 339 | where 340 | id = $1 341 | limit 1; 342 | ` 343 | if err := db.QueryRow(context.Background(), q, id).Scan(&found); err != nil { 344 | return nil, err 345 | } 346 | q = ` 347 | select 348 | id, 349 | checked_at, 350 | result, 351 | latency 352 | from 353 | checks 354 | where 355 | website_id = $1 356 | and checked_at between $2::timestamptz and $3::timestamptz 357 | order by 358 | checked_at asc; 359 | ` 360 | rows, err := db.Query(context.Background(), q, id, beginning, ending) 361 | if err != nil { 362 | return nil, err 363 | } 364 | defer rows.Close() 365 | checks := []*api.Check{} 366 | for rows.Next() { 367 | check := &api.Check{} 368 | fields := []interface{}{ 369 | &check.ID, 370 | &check.Checked, 371 | &check.Result, 372 | &check.Latency, 373 | } 374 | if err := rows.Scan(fields...); err != nil { 375 | return nil, err 376 | } 377 | checks = append(checks, check) 378 | } 379 | return checks, nil 380 | } 381 | 382 | func handleListWebsiteChecks(c echo.Context) error { 383 | checks, err := queryWebsiteChecks(c.Param("id"), c.QueryParam("after"), c.QueryParam("before")) 384 | if err != nil { 385 | switch err { 386 | case pgx.ErrNoRows: 387 | return echo.NewHTTPError(http.StatusNotFound) 388 | case errMissingArgument: 389 | return echo.NewHTTPError(http.StatusBadRequest) 390 | default: 391 | panic(err) 392 | } 393 | } 394 | if err := c.JSON(http.StatusOK, checks); err != nil { 395 | panic(err) 396 | } 397 | return nil 398 | } 399 | 400 | func queryWebsiteHistory(id, beginning, ending string) ([]*api.Entry, error) { 401 | if id == "" { 402 | return nil, errMissingArgument 403 | } 404 | if beginning == "" { 405 | return nil, errMissingArgument 406 | } 407 | if ending == "" { 408 | ending = time.Now().String() 409 | } 410 | var found bool 411 | q := ` 412 | select 413 | true 414 | from 415 | websites 416 | where 417 | id = $1 418 | limit 1; 419 | ` 420 | if err := db.QueryRow(context.Background(), q, id).Scan(&found); err != nil { 421 | return nil, err 422 | } 423 | q = ` 424 | select 425 | checked_at as time, 426 | result as status, 427 | extract(epoch from lag(checked_at, 1, current_timestamp) 428 | over (order by checked_at desc) - checked_at)::int as duration 429 | from 430 | checks 431 | where 432 | website_id = $1 433 | and checked_at between $2::timestamptz and $3::timestamptz 434 | order by 435 | checked_at desc; 436 | ` 437 | rows, err := db.Query(context.Background(), q, id, beginning, ending) 438 | if err != nil { 439 | return nil, err 440 | } 441 | defer rows.Close() 442 | history := []*api.Entry{} 443 | prevEntry := &api.Entry{} 444 | for rows.Next() { 445 | entry := &api.Entry{} 446 | if err := rows.Scan(&entry.Time, &entry.Status, &entry.Duration); err != nil { 447 | return nil, err 448 | } 449 | if prevEntry.Status == entry.Status { 450 | prevEntry.Time = entry.Time 451 | prevEntry.Duration += entry.Duration 452 | } else { 453 | prevEntry = entry 454 | history = append(history, entry) 455 | } 456 | } 457 | return history, nil 458 | } 459 | 460 | func handleListWebsiteHistory(c echo.Context) error { 461 | history, err := queryWebsiteHistory(c.Param("id"), c.QueryParam("after"), c.QueryParam("before")) 462 | if err != nil { 463 | switch err { 464 | case pgx.ErrNoRows: 465 | return echo.NewHTTPError(http.StatusNotFound) 466 | case errMissingArgument: 467 | return echo.NewHTTPError(http.StatusBadRequest) 468 | default: 469 | panic(err) 470 | } 471 | } 472 | if err := c.JSON(http.StatusOK, history); err != nil { 473 | panic(err) 474 | } 475 | return nil 476 | } 477 | 478 | func init() { 479 | var err error 480 | db, err = pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL")) 481 | if err != nil { 482 | panic(err) 483 | } 484 | } 485 | 486 | func main() { 487 | e := echo.New() 488 | 489 | e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 490 | Format: "${id} ${method} ${uri} ${status} ${latency_human} ${remote_ip} ${user_agent}\n", 491 | })) 492 | e.Use(middleware.Recover()) 493 | e.Use(middleware.RequestID()) 494 | e.Use(middleware.CORS()) 495 | 496 | e.GET("/websites", handleListWebsites) 497 | e.GET("/websites/:id", handleGetWebsite) 498 | e.GET("/websites/:id/stats", handleGetWebsiteStats) 499 | e.GET("/websites/:id/checks", handleListWebsiteChecks) 500 | e.GET("/websites/:id/history", handleListWebsiteHistory) 501 | 502 | authorize := middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { 503 | return key == os.Getenv("API_KEY"), nil 504 | }) 505 | 506 | e.POST("/websites", handleNewWebsite, authorize) 507 | e.POST("/websites/:id/checks", handleNewCheck, authorize) 508 | 509 | done := make(chan struct{}) 510 | shutdown := make(chan os.Signal) 511 | signal.Notify(shutdown, syscall.SIGTERM, syscall.SIGINT) 512 | go func() { 513 | // Wait for the signal. 514 | <-shutdown 515 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 516 | defer cancel() 517 | if err := e.Shutdown(ctx); err != nil { 518 | e.Logger.Fatal(err) 519 | } 520 | done <- struct{}{} 521 | }() 522 | 523 | e.Logger.Fatal(e.Start(":" + os.Getenv("PORT"))) 524 | 525 | // Wait for shutdown to be done. 526 | <-done 527 | } 528 | -------------------------------------------------------------------------------- /web/schema.sql: -------------------------------------------------------------------------------- 1 | create type website_status as enum ('unknown', 'up', 'maintenance', 'down'); 2 | 3 | create table websites ( 4 | id serial not null primary key, 5 | url text not null, 6 | status website_status not null default 'unknown', 7 | updated_at timestamptz not null default current_timestamp 8 | ); 9 | 10 | create type check_result as enum ('up', 'down'); 11 | 12 | create table checks ( 13 | id serial not null primary key, 14 | checked_at timestamptz not null default current_timestamp, 15 | website_id integer references websites (id), 16 | result check_result not null, 17 | latency jsonb not null default '{}' 18 | ); 19 | 20 | create function percentage(n numeric, t numeric) 21 | returns numeric as $$ 22 | begin 23 | return case when t = 0 then 0 else n / t * 100.0 end; 24 | end; 25 | $$ language plpgsql; --------------------------------------------------------------------------------