├── .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 | 
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 |
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 |
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 |
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 |
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 |
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 |
63 |
--------------------------------------------------------------------------------
/client/src/owl-wire.svg:
--------------------------------------------------------------------------------
1 |
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;
--------------------------------------------------------------------------------