Compare commits

..

43 Commits

Author SHA1 Message Date
Chris C
cd1430f043 Merge pull request #83 from TwinProduction/persistence
Implement persistence
2021-02-02 23:23:43 -05:00
TwinProduction
79bef8d391 Implement persistence 2021-02-02 23:06:34 -05:00
TwinProduction
9196f57487 Add several tests 2021-02-01 01:37:56 -05:00
TwinProduction
1e0d9e184c Fix typo 2021-01-31 23:29:48 -05:00
Chris C
bc42497cb7 Merge pull request #81 from menduz/patch-1
fix: metrics were not working
2021-01-31 22:35:27 -05:00
Agustin Mendez
b0b0ab574d fix: metrics were not working 2021-01-31 23:45:32 -03:00
TwinProduction
e369484e5f Fix issue with event icons not displaying on mobile 2021-01-31 20:38:05 -05:00
TwinProduction
d18618449f Rebuild static files 2021-01-31 18:04:22 -05:00
TwinProduction
9186049589 Minor responsiveness improvements 2021-01-31 18:02:13 -05:00
TwinProduction
4362831f71 Improve responsiveness on smaller screens 2021-01-31 17:55:04 -05:00
TwinProduction
99f558d43e Fix minor responsiveness issue 2021-01-31 17:54:14 -05:00
TwinProduction
4a3d5944b6 Initialize Tooltip with hidden set to true 2021-01-31 17:01:02 -05:00
TwinProduction
688456d7cf Minor fix 2021-01-31 16:47:40 -05:00
TwinProduction
431fb3e9f2 Make title smaller on smaller screens 2021-01-31 16:47:31 -05:00
TwinProduction
a1679ddc5e Remove web.context-root 2021-01-31 05:49:01 -05:00
TwinProduction
d8d8e8720b Remove useless rule 2021-01-31 01:34:43 -05:00
TwinProduction
4a0d9d058a Fix documentation on badges 2021-01-31 01:25:06 -05:00
TwinProduction
c8ccf9b352 Minor improvements 2021-01-30 21:17:17 -05:00
TwinProduction
45c966fbca Undo changes to default config.yaml 2021-01-30 20:00:54 -05:00
TwinProduction
d8d4756ef3 Fix header in README.md 2021-01-30 19:49:31 -05:00
TwinProduction
1e9c54cc0f minor fixes 2021-01-30 00:23:12 -05:00
TwinProduction
80570688e1 Rebuild static files 2021-01-30 00:08:50 -05:00
TwinProduction
43e6e3e8f5 Fix Dockerfile 2021-01-29 23:10:20 -05:00
TwinProduction
8b4c5c20f3 Add docker-build-and-run 2021-01-29 23:10:11 -05:00
TwinProduction
6f6db36b0f Minor updates 2021-01-29 23:10:01 -05:00
Chris C
467874de10 Merge pull request #80 from TwinProduction/vue
Migrate frontend to Vue + Add service detail page
2021-01-29 22:06:30 -05:00
TwinProduction
8337f41425 Format stuffs 2021-01-28 23:52:01 -05:00
TwinProduction
601d676e34 Replace old static folder with new static folder 2021-01-28 23:25:29 -05:00
TwinProduction
fbb5d48bf7 Add events to service detail page 2021-01-28 22:44:31 -05:00
TwinProduction
119b80edc0 Add section for badges 2021-01-27 18:46:51 -05:00
TwinProduction
99e8cfb1ce Changed badge path to leverage ServiceStatus.Key 2021-01-27 18:26:07 -05:00
TwinProduction
dcbbec7931 Add page for individual service details 2021-01-27 18:25:37 -05:00
TwinProduction
2ccd656386 Add SERVER_URL constant based on environment 2021-01-25 22:05:19 -05:00
TwinProduction
5755f3a699 Minor fix 2021-01-25 20:57:05 -05:00
TwinProduction
911d809376 Create Makefile 2021-01-25 20:56:02 -05:00
TwinProduction
752c872d3b Rename json parameter condition-results to conditionResults 2021-01-25 20:55:49 -05:00
TwinProduction
67a3e4e330 Add tooltip 2021-01-25 20:54:57 -05:00
TwinProduction
668ed3b1a2 Migrate service group collapsing feature 2021-01-24 05:28:29 -05:00
TwinProduction
dc6cb8fc1d Start working on migrating frontend to Vue 3 2021-01-24 04:50:58 -05:00
TwinProduction
f1aa5191bf Add development CORS header when the "ENVIRONMENT" environment variable is set to "dev" 2021-01-24 04:48:07 -05:00
TwinProduction
bc6ca2ebd0 Migrate from Bootstrap to Tailwind 2021-01-23 23:39:26 -05:00
TwinProduction
30801938b2 Minor fix 2021-01-23 17:22:18 -05:00
TwinProduction
ddddd405bb Add blank status badges on entries that haven't been filled yet 2021-01-23 17:18:18 -05:00
79 changed files with 29968 additions and 803 deletions

View File

@@ -3,3 +3,4 @@ Dockerfile
.github
.idea
.git
web/app

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -24,9 +24,9 @@ jobs:
run: go build -mod vendor
- name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the "PATH=$PATH", we need it to use the same "go" executable that was configured by the "Set
# up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo "PATH=$PATH" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v1.0.14
with:

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
bin
.idea
.vscode
gatus

View File

@@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/static static/
COPY --from=builder /app/web/static ./web/static
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080
EXPOSE ${PORT}

14
Makefile Normal file
View File

@@ -0,0 +1,14 @@
docker-build:
docker build -t twinproduction/gatus:latest .
docker-build-and-run:
docker build -t twinproduction/gatus:latest . && docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
build-frontend:
npm --prefix web/app run build
run-frontend:
npm --prefix web/app run serve
test:
go test -mod=vendor ./... -cover

View File

@@ -1,4 +1,4 @@
![Gatus](static/logo-with-name.png)
![Gatus](.github/assets/logo-with-name.png)
![build](https://github.com/TwinProduction/gatus/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwinProduction/gatus?)](https://goreportcard.com/report/github.com/TwinProduction/gatus)
@@ -58,7 +58,7 @@ The main features of Gatus are:
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Metrics**
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
- **GitHub uptime badges** (ALPHA): ![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/group-core-service-twinnation%20-%20external.svg) ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg) ![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/group-core-service-twinnation%20-%20external.svg)
- **GitHub uptime badges**: ![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/core_twinnation-external.svg) ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation-external.svg) ![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/core_twinnation-external.svg)
- **Service auto discovery in Kubernetes** (ALPHA)
@@ -100,6 +100,8 @@ Note that you can also add environment variables in the configuration file (i.e.
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `debug` | Whether to enable debug logs | `false` |
| `metrics` | Whether to expose metrics at /metrics | `false` |
| `storage` | Storage configuration | `{}` |
| `storage.file` | File to persist the data in. If not set, storage is in-memory only. | `""` |
| `services` | List of services to monitor | Required `[]` |
| `services[].name` | Name of the service. Can be anything. | Required `""` |
| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` |
@@ -151,7 +153,6 @@ Note that you can also add environment variables in the configuration file (i.e.
| `web` | Web configuration | `{}` |
| `web.address` | Address to listen on | `0.0.0.0` |
| `web.port` | Port to listen on | `8080` |
| `web.context-root` | Context root at which Gatus will be exposed (frontend and backend) | `/` |
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
@@ -708,11 +709,9 @@ web:
```
### Uptime badges
![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/group-core-service-twinnation%20-%20external.svg)
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg)
![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/group-core-service-twinnation%20-%20external.svg)
> **NOTE**: This feature is currently in ALPHA
![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/core_twinnation-external.svg)
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation-external.svg)
![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/core_twinnation-external.svg)
Gatus can automatically generate a SVG badge for one of your monitored services.
This allows you to put badges in your individual services' README or even create your own status page, if you
@@ -724,21 +723,22 @@ The endpoint to generate a badge is the following:
```
Where:
- `{duration}` is `7d`, `24h` or `1h`
- `{identifier}` has the following pattern: `group-<GROUP_NAME>-service-<SERVICE_NAME>.svg`
- `{identifier}` has the pattern `<GROUP_NAME>_<SERVICE_NAME>.svg` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the uptime during the last 24 hours from the service `frontend` in the group `core`,
the URL would look like this:
```
http://example.com/api/v1/badges/uptime/7d/group-core-service-frontend.svg
http://example.com/api/v1/badges/uptime/7d/core_frontend.svg
```
If you want to display a service that is not part of a group, you must leave the group value empty:
```
http://example.com/api/v1/badges/uptime/7d/group--service-frontend.svg
http://example.com/api/v1/badges/uptime/7d/_frontend.svg
```
Example: ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg)
Example: ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation-external.svg)
```
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg)
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation-external.svg)
```
If you'd like to see a visual example of each badges available, you can simply navigate to the service's detail page.

View File

@@ -1,14 +1,14 @@
services:
- name: frontend
- name: front-end
group: core
url: "https://twinnation.org/health"
interval: 1m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 70"
- "[RESPONSE_TIME] < 150"
- name: backend
- name: back-end
group: core
url: "http://example.org/"
interval: 5m

View File

@@ -11,6 +11,8 @@ import (
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/k8s"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/util"
"gopkg.in/yaml.v2"
)
@@ -28,9 +30,6 @@ const (
// DefaultPort is the default port the service will listen on
DefaultPort = 8080
// DefaultContextRoot is the default context root of the web application
DefaultContextRoot = "/"
)
var (
@@ -74,8 +73,11 @@ type Config struct {
// Kubernetes is the Kubernetes configuration
Kubernetes *k8s.Config `yaml:"kubernetes"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage"`
// Web is the configuration for the web listener
Web *webConfig `yaml:"web"`
Web *WebConfig `yaml:"web"`
}
// Get returns the configuration, or panics if the configuration hasn't loaded yet
@@ -147,13 +149,33 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
validateServicesConfig(config)
validateKubernetesConfig(config)
validateWebConfig(config)
validateStorageConfig(config)
}
return
}
func validateStorageConfig(config *Config) {
if config.Storage == nil {
config.Storage = &storage.Config{}
}
err := storage.Initialize(config.Storage)
if err != nil {
panic(err)
}
// Remove all ServiceStatus that represent services which no longer exist in the configuration
var keys []string
for _, service := range config.Services {
keys = append(keys, util.ConvertGroupAndServiceToKey(service.Group, service.Name))
}
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
if numberOfServiceStatusesDeleted > 0 {
log.Printf("[config][validateStorageConfig] Deleted %d service statuses because their matching services no longer existed", numberOfServiceStatusesDeleted)
}
}
func validateWebConfig(config *Config) {
if config.Web == nil {
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
} else {
config.Web.validateAndSetDefaults()
}

View File

@@ -42,7 +42,10 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
}
func TestParseAndValidateConfigBytes(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
file: %s
services:
- name: twinnation
url: https://twinnation.org/health
@@ -54,9 +57,9 @@ services:
conditions:
- "[STATUS] != 400"
- "[STATUS] != 500"
`))
`, file)))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -99,7 +102,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -119,9 +122,6 @@ services:
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
}
if config.Web.ContextRoot != DefaultContextRoot {
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
}
}
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
@@ -135,7 +135,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -170,7 +170,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -204,7 +204,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -226,44 +226,6 @@ services:
}
}
func TestParseAndValidateConfigBytesWithPortAndHostAndContextRoot(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
web:
port: 12345
address: 127.0.0.1
context-root: /deeply/nested/down=/their
services:
- name: twinnation
url: https://twinnation.org/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != "127.0.0.1" {
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
}
if config.Web.Port != 12345 {
t.Errorf("Port should have been %d, because it is specified in config", 12345)
}
if config.Web.ContextRoot != "/deeply/nested/down=/their/" {
t.Errorf("Port should have been %s, because it is specified in config", "/deeply/nested/down=/their/")
}
}
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
defer func() { recover() }()
_, _ = parseAndValidateConfigBytes([]byte(`
@@ -291,7 +253,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -311,9 +273,6 @@ services:
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
}
if config.Web.ContextRoot != DefaultContextRoot {
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
}
if userAgent := config.Services[0].Headers["User-Agent"]; userAgent != "Test/2.0" {
t.Errorf("User-Agent should've been %s, got %s", "Test/2.0", userAgent)
}
@@ -332,7 +291,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -376,7 +335,6 @@ badconfig:
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
debug: true
alerting:
slack:
webhook-url: "http://example.com"
@@ -403,7 +361,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -496,7 +454,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -530,7 +488,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -569,7 +527,7 @@ services:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -622,7 +580,7 @@ services:
- "[STATUS] == 200"
`, expectedUsername, expectedPasswordHash)))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
@@ -689,7 +647,7 @@ kubernetes:
target-path: "/health"
`))
if err != nil {
t.Error("No error should've been returned")
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")

View File

@@ -3,25 +3,20 @@ package config
import (
"fmt"
"math"
"net/url"
"strings"
)
// webConfig is the structure which supports the configuration of the endpoint
// WebConfig is the structure which supports the configuration of the endpoint
// which provides access to the web frontend
type webConfig struct {
type WebConfig struct {
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
Address string `yaml:"address"`
// Port to listen on (default to 8080 specified by DefaultPort)
Port int `yaml:"port"`
// ContextRoot set the root context for the web application
ContextRoot string `yaml:"context-root"`
}
// validateAndSetDefaults checks and sets the default values for fields that are not set
func (web *webConfig) validateAndSetDefaults() {
func (web *WebConfig) validateAndSetDefaults() {
// Validate the Address
if len(web.Address) == 0 {
web.Address = DefaultAddress
@@ -32,32 +27,9 @@ func (web *webConfig) validateAndSetDefaults() {
} else if web.Port < 0 || web.Port > math.MaxUint16 {
panic(fmt.Sprintf("invalid port: value should be between %d and %d", 0, math.MaxUint16))
}
// Validate the ContextRoot
if len(web.ContextRoot) == 0 {
web.ContextRoot = DefaultContextRoot
} else {
trimmedContextRoot := strings.Trim(web.ContextRoot, "/")
if len(trimmedContextRoot) == 0 {
web.ContextRoot = DefaultContextRoot
return
}
rootContextURL, err := url.Parse(trimmedContextRoot)
if err != nil {
panic("invalid context root:" + err.Error())
}
if rootContextURL.Path != trimmedContextRoot {
panic("invalid context root: too complex")
}
web.ContextRoot = "/" + strings.Trim(rootContextURL.Path, "/") + "/"
}
}
// SocketAddress returns the combination of the Address and the Port
func (web *webConfig) SocketAddress() string {
func (web *WebConfig) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}
// PrependWithContextRoot appends the given path to the ContextRoot
func (web *webConfig) PrependWithContextRoot(path string) string {
return web.ContextRoot + strings.Trim(path, "/")
}

View File

@@ -1,12 +1,11 @@
package config
import (
"fmt"
"testing"
)
func TestWebConfig_SocketAddress(t *testing.T) {
web := &webConfig{
web := &WebConfig{
Address: "0.0.0.0",
Port: 8081,
}
@@ -14,86 +13,3 @@ func TestWebConfig_SocketAddress(t *testing.T) {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}
func TestWebConfig_PrependWithContextRoot(t *testing.T) {
web := &webConfig{ContextRoot: "/status/"}
if result := web.PrependWithContextRoot("/api/v1/results"); result != "/status/api/v1/results" {
t.Errorf("expected %s, got %s", "/status/api/v1/results", result)
}
if result := web.PrependWithContextRoot("/health"); result != "/status/health" {
t.Errorf("expected %s, got %s", "/status/health", result)
}
if result := web.PrependWithContextRoot("/health/"); result != "/status/health" {
t.Errorf("expected %s, got %s", "/status/health", result)
}
}
// validContextRootTest specifies all test case which should end up in
// a valid context root used to bind the web interface to
var validContextRootTests = []struct {
name string
path string
expectedPath string
}{
{"Empty", "", "/"},
{"/", "/", "/"},
{"///", "///", "/"},
{"Single character 'a'", "a", "/a/"},
{"Slash at the beginning", "/status", "/status/"},
{"Slashes at start and end", "/status/", "/status/"},
{"Multiple slashes at start", "//status", "/status/"},
{"Multiple slashes at start and end", "///status////", "/status/"},
{"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"},
{"Nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
{"Nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
}
func TestWebConfig_ValidContextRoots(t *testing.T) {
for idx, test := range validContextRootTests {
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
expectValidResultForContextRoot(t, test.path, test.expectedPath)
})
}
}
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
web := &webConfig{
ContextRoot: path,
}
web.validateAndSetDefaults()
if web.ContextRoot != expected {
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
}
}
// invalidContextRootTests contains all tests for context root which are
// expected to fail and stop program execution
var invalidContextRootTests = []struct {
name string
path string
}{
{"Only a fragment identifier", "#"},
{"Invalid character in path", "/invalid" + string([]byte{0x7F})},
{"Starts with protocol", "http://status/gatus"},
{"Path with fragment", "/status/gatus#here"},
{"Starts with '://'", "://status"},
{"Contains query parameter", "/status/h?ello=world"},
{"Contains '?'", "/status?"},
}
func TestWebConfig_InvalidContextRoots(t *testing.T) {
for idx, test := range invalidContextRootTests {
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
expectInvalidResultForContextRoot(t, test.path)
})
}
}
func expectInvalidResultForContextRoot(t *testing.T, path string) {
defer func() { recover() }()
web := &webConfig{ContextRoot: path}
web.validateAndSetDefaults()
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
}

View File

@@ -14,26 +14,18 @@ import (
// badgeHandler handles the automatic generation of badge based on the group name and service name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
// Pattern for {identifier}: group-<GROUP_NAME>-service-<SERVICE_NAME>.svg
// Pattern for {identifier}: <KEY>.svg
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
// group-<GROUP_NAME>-service-<SERVICE_NAME>.svg
identifier := variables["identifier"]
if duration != "7d" && duration != "24h" && duration != "1h" {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
return
}
parts := strings.Split(identifier, "-service-")
if len(parts) != 2 || !strings.HasPrefix(identifier, "group-") || !strings.HasSuffix(identifier, ".svg") {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Invalid path: Pattern should look like /group-<GROUP_NAME>-service-<SERVICE_NAME>.svg"))
return
}
groupName := strings.TrimPrefix(parts[0], "group-")
serviceName := strings.TrimSuffix(parts[1], ".svg")
uptime := watchdog.GetUptimeByServiceGroupAndName(groupName, serviceName)
identifier := variables["identifier"]
key := strings.TrimSuffix(identifier, ".svg")
uptime := watchdog.GetUptimeByKey(key)
if uptime == nil {
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("Requested service not found"))

View File

@@ -3,9 +3,11 @@ package controller
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
@@ -23,6 +25,14 @@ const (
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.LeastRecentlyUsed)
// staticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
staticFolder = "./web/static"
// server is the http.Server created by Handle.
// The only reason it exists is for testing purposes.
server *http.Server
)
func init() {
@@ -34,36 +44,52 @@ func init() {
// Handle creates the router and starts the server
func Handle() {
cfg := config.Get()
router := CreateRouter(cfg)
server := &http.Server{
var router http.Handler = CreateRouter(cfg)
if os.Getenv("ENVIRONMENT") == "dev" {
router = developmentCorsHandler(router)
}
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second,
}
log.Printf("[controller][Handle] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
if os.Getenv("ROUTER_TEST") == "true" {
return
}
log.Fatal(server.ListenAndServe())
}
// CreateRouter creates the router for the http server
func CreateRouter(cfg *config.Config) *mux.Router {
router := mux.NewRouter()
statusesHandler := serviceStatusesHandler
if cfg.Security != nil && cfg.Security.IsValid() {
statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security)
}
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") // favicon needs to be always served from the root
router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler).Methods("GET")
router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/badges/uptime/{duration}/{identifier}"), badgeHandler).Methods("GET")
router.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler).Methods("GET")
router.PathPrefix(cfg.Web.ContextRoot).Handler(GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static")))))
if cfg.Metrics {
router.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler()).Methods("GET")
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
}
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
router.HandleFunc("/health", healthHandler).Methods("GET")
router.HandleFunc("/api/v1/statuses", secureIfNecessary(cfg, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content
router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(cfg, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET")
// SPA
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
// Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router
}
func secureIfNecessary(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
if cfg.Security != nil && cfg.Security.IsValid() {
return security.Handler(handler, cfg.Security)
}
return handler
}
// serviceStatusesHandler handles requests to retrieve all service statuses
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool
@@ -81,7 +107,7 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
gzipWriter := gzip.NewWriter(buffer)
data, err = watchdog.GetServiceStatusesAsJSON()
if err != nil {
log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
return
@@ -102,6 +128,36 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
_, _ = writer.Write(data)
}
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
serviceStatus := watchdog.GetServiceStatusByKey(vars["key"])
if serviceStatus == nil {
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("not found"))
return
}
data := map[string]interface{}{
"serviceStatus": serviceStatus,
// The following fields, while present on core.ServiceStatus, are annotated to remain hidden so that we can
// expose only the necessary data on /api/v1/statuses.
// Since the /api/v1/statuses/{key} endpoint does need this data, however, we explicitly expose it here
"events": serviceStatus.Events,
"uptime": serviceStatus.Uptime,
}
output, err := json.Marshal(data)
if err != nil {
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
return
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(output)
}
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
@@ -110,5 +166,10 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) {
// favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "./static/favicon.ico")
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}
// spaHandler handles requests for /favicon.ico
func spaHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/index.html")
}

View File

@@ -0,0 +1,169 @@
package controller
import (
"math/rand"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/watchdog"
)
func TestCreateRouter(t *testing.T) {
staticFolder = "../web/static"
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter(cfg)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "health",
Path: "/health",
ExpectedCode: http.StatusOK,
},
{
Name: "metrics",
Path: "/metrics",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-1h",
Path: "/api/v1/badges/uptime/1h/core_frontend.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-24h",
Path: "/api/v1/badges/uptime/24h/core_backend.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-7d",
Path: "/api/v1/badges/uptime/7d/core_frontend.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-with-invalid-duration",
Path: "/api/v1/badges/uptime/3d/core_backend.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badges-for-invalid-key",
Path: "/api/v1/badges/uptime/7d/invalid_key.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "service-statuses",
Path: "/api/v1/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "service-statuses-gzip",
Path: "/api/v1/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-status",
Path: "/api/v1/statuses/core_frontend",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status-gzip",
Path: "/api/v1/statuses/core_frontend",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-status-for-invalid-key",
Path: "/api/v1/statuses/invalid_key",
ExpectedCode: http.StatusNotFound,
},
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-home",
Path: "/",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-assets",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func TestHandle(t *testing.T) {
cfg := &config.Config{
Web: &config.WebConfig{
Address: "0.0.0.0",
Port: rand.Intn(65534),
},
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
config.Set(cfg)
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
Handle()
request, _ := http.NewRequest("GET", "/health", nil)
responseRecorder := httptest.NewRecorder()
server.Handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
t.Error("expected GET /health to return status code 200")
}
if server == nil {
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
}
}

10
controller/cors.go Normal file
View File

@@ -0,0 +1,10 @@
package controller
import "net/http"
func developmentCorsHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
next.ServeHTTP(w, r)
})
}

View File

@@ -32,10 +32,18 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
// GzipHandler compresses the response of a given handler if the request's headers specify that the client
// GzipHandler compresses the response of a given http.Handler if the request's headers specify that the client
// supports gzip encoding
func GzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
return GzipHandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
next.ServeHTTP(writer, r)
})
}
// GzipHandlerFunc compresses the response of a given http.HandlerFunc if the request's headers specify that the client
// supports gzip encoding
func GzipHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, r *http.Request) {
// If the request doesn't specify that it supports gzip, then don't compress it
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(writer, r)
@@ -47,5 +55,5 @@ func GzipHandler(next http.Handler) http.Handler {
gz.Reset(writer)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r)
})
}
}

26
core/event.go Normal file
View File

@@ -0,0 +1,26 @@
package core
import "time"
// Event is something that happens at a specific time
type Event struct {
// Type is the kind of event
Type EventType `json:"type"`
// Timestamp is the moment at which the event happened
Timestamp time.Time `json:"timestamp"`
}
// EventType is, uh, the types of events?
type EventType string
var (
// EventStart is a type of event that represents when a service starts being monitored
EventStart EventType = "START"
// EventHealthy is a type of event that represents a service passing all of its conditions
EventHealthy EventType = "HEALTHY"
// EventUnhealthy is a type of event that represents a service failing one or more of its conditions
EventUnhealthy EventType = "UNHEALTHY"
)

View File

@@ -31,7 +31,7 @@ type Result struct {
Errors []string `json:"errors"`
// ConditionResults results of the service's conditions
ConditionResults []*ConditionResult `json:"condition-results"`
ConditionResults []*ConditionResult `json:"conditionResults"`
// Success whether the result signifies a success or not
Success bool `json:"success"`

View File

@@ -1,5 +1,11 @@
package core
import (
"time"
"github.com/TwinProduction/gatus/util"
)
// ServiceStatus contains the evaluation Results of a Service
type ServiceStatus struct {
// Name of the service
@@ -8,11 +14,25 @@ type ServiceStatus struct {
// Group the service is a part of. Used for grouping multiple services together on the front end.
Group string `json:"group,omitempty"`
// Key is the key representing the ServiceStatus
Key string `json:"key"`
// Results is the list of service evaluation results
Results []*Result `json:"results"`
// Events is a list of events
//
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
// However, the detailed service page does leverage this by including it to a map that will be
// marshalled alongside the ServiceStatus.
Events []*Event `json:"-"`
// Uptime information on the service's uptime
Uptime *Uptime `json:"uptime"`
//
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
// However, the detailed service page does leverage this by including it to a map that will be
// marshalled alongside the ServiceStatus.
Uptime *Uptime `json:"-"`
}
// NewServiceStatus creates a new ServiceStatus
@@ -20,14 +40,35 @@ func NewServiceStatus(service *Service) *ServiceStatus {
return &ServiceStatus{
Name: service.Name,
Group: service.Group,
Key: util.ConvertGroupAndServiceToKey(service.Group, service.Name),
Results: make([]*Result, 0),
Uptime: NewUptime(),
Events: []*Event{{
Type: EventStart,
Timestamp: time.Now(),
}},
Uptime: NewUptime(),
}
}
// AddResult adds a Result to ServiceStatus.Results and makes sure that there are
// no more than 20 results in the Results slice
func (ss *ServiceStatus) AddResult(result *Result) {
if len(ss.Results) > 0 {
// Check if there's any change since the last result
// OR there's only 1 event, which only happens when there's a start event
if ss.Results[len(ss.Results)-1].Success != result.Success || len(ss.Events) == 1 {
event := &Event{Timestamp: result.Timestamp}
if result.Success {
event.Type = EventHealthy
} else {
event.Type = EventUnhealthy
}
ss.Events = append(ss.Events, event)
if len(ss.Events) > 20 {
ss.Events = ss.Events[1:]
}
}
}
ss.Results = append(ss.Results, result)
if len(ss.Results) > 20 {
ss.Results = ss.Results[1:]

View File

@@ -14,6 +14,9 @@ func TestNewServiceStatus(t *testing.T) {
if serviceStatus.Group != service.Group {
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
}
if serviceStatus.Key != "group_name" {
t.Errorf("expected %s, got %s", "group_name", serviceStatus.Key)
}
}
func TestServiceStatus_AddResult(t *testing.T) {

View File

@@ -25,15 +25,20 @@ type Uptime struct {
// LastHour is the uptime percentage over the past hour
LastHour float64 `json:"1h"`
successCountPerHour map[string]uint64
totalCountPerHour map[string]uint64
// SuccessCountPerHour is a map containing the number of successes per hour, per timestamp following the
// custom RFC3339WithoutMinutesAndSeconds format
SuccessCountPerHour map[string]uint64 `json:"-"`
// TotalCountPerHour is a map containing the total number of checks per hour, per timestamp following the
// custom RFC3339WithoutMinutesAndSeconds format
TotalCountPerHour map[string]uint64 `json:"-"`
}
// NewUptime creates a new Uptime
func NewUptime() *Uptime {
return &Uptime{
successCountPerHour: make(map[string]uint64),
totalCountPerHour: make(map[string]uint64),
SuccessCountPerHour: make(map[string]uint64),
TotalCountPerHour: make(map[string]uint64),
}
}
@@ -42,16 +47,16 @@ func NewUptime() *Uptime {
func (uptime *Uptime) ProcessResult(result *Result) {
timestampDateWithHour := result.Timestamp.Format(RFC3339WithoutMinutesAndSeconds)
if result.Success {
uptime.successCountPerHour[timestampDateWithHour]++
uptime.SuccessCountPerHour[timestampDateWithHour]++
}
uptime.totalCountPerHour[timestampDateWithHour]++
uptime.TotalCountPerHour[timestampDateWithHour]++
// Clean up only when we're starting to have too many useless keys
// Note that this is only triggered when there are more entries than there should be after
// 10 days, despite the fact that we are deleting everything that's older than 7 days.
// This is to prevent re-iterating on every `ProcessResult` as soon as the uptime has been logged for 7 days.
if len(uptime.totalCountPerHour) > numberOfHoursInTenDays {
if len(uptime.TotalCountPerHour) > numberOfHoursInTenDays {
sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour))
for k := range uptime.totalCountPerHour {
for k := range uptime.TotalCountPerHour {
dateWithHour, err := time.Parse(time.RFC3339, k)
if err != nil {
// This shouldn't happen, but we'll log it in case it does happen
@@ -59,8 +64,8 @@ func (uptime *Uptime) ProcessResult(result *Result) {
continue
}
if sevenDaysAgo.Unix() > dateWithHour.Unix() {
delete(uptime.totalCountPerHour, k)
delete(uptime.successCountPerHour, k)
delete(uptime.TotalCountPerHour, k)
delete(uptime.SuccessCountPerHour, k)
}
}
}
@@ -88,8 +93,8 @@ func (uptime *Uptime) recalculate() {
timestamp := now.Add(-sevenDays)
for now.Sub(timestamp) >= 0 {
timestampDateWithHour := timestamp.Format(RFC3339WithoutMinutesAndSeconds)
successCountForTimestamp := uptime.successCountPerHour[timestampDateWithHour]
totalCountForTimestamp := uptime.totalCountPerHour[timestampDateWithHour]
successCountForTimestamp := uptime.SuccessCountPerHour[timestampDateWithHour]
totalCountForTimestamp := uptime.TotalCountPerHour[timestampDateWithHour]
uptimeBrackets["7d_success"] += successCountForTimestamp
uptimeBrackets["7d_total"] += totalCountForTimestamp
if now.Sub(timestamp) <= 24*time.Hour {

View File

@@ -51,8 +51,8 @@ func TestServiceStatus_AddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
timestamp := now.Add(-12 * 24 * time.Hour)
for timestamp.Unix() <= now.Unix() {
serviceStatus.AddResult(&Result{Timestamp: timestamp, Success: true})
if len(serviceStatus.Uptime.successCountPerHour) > numberOfHoursInTenDays {
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.successCountPerHour", numberOfHoursInTenDays)
if len(serviceStatus.Uptime.SuccessCountPerHour) > numberOfHoursInTenDays {
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.SuccessCountPerHour", numberOfHoursInTenDays)
}
//fmt.Printf("timestamp=%s; uptimeDuringLastHour=%f; timeAgo=%s\n", timestamp.Format(time.RFC3339), serviceStatus.UptimeDuringLastHour, time.Since(timestamp))
if now.Sub(timestamp) > time.Hour && serviceStatus.Uptime.LastHour != 0 {

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.15
require (
cloud.google.com/go v0.74.0 // indirect
github.com/TwinProduction/gocache v1.1.0
github.com/TwinProduction/gocache v1.2.0
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663
github.com/google/gofuzz v1.2.0 // indirect
github.com/gorilla/mux v1.8.0

4
go.sum
View File

@@ -50,8 +50,8 @@ github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/TwinProduction/gocache v1.1.0 h1:mibBUyccd8kGHlm5dXhTMDOvWBK4mjNqGyOOkG8mib8=
github.com/TwinProduction/gocache v1.1.0/go.mod h1:+qH57V/K4oAcX9C7CvgJTwUX4lzfIUXQC/6XaRSOS1Y=
github.com/TwinProduction/gocache v1.2.0 h1:iZBUuri5VydxYhNkWEOZm/JzX/X2b3OZzfLrPaRWKjk=
github.com/TwinProduction/gocache v1.2.0/go.mod h1:+qH57V/K4oAcX9C7CvgJTwUX4lzfIUXQC/6XaRSOS1Y=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=

File diff suppressed because one or more lines are too long

View File

@@ -1,414 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Health Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="./bootstrap.min.css" />
<style>
html, body {
background-color: #f7f9fb;
}
html {
height: 100%;
}
#results div.container:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
#results div.container:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-width: 1px;
border-color: #dee2e6;
border-style: solid;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
padding: .25em 0;
color: white;
}
.title {
font-size: 2.5rem;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-over-time {
overflow: auto;
}
.status-over-time>span:not(:last-child) {
margin-left: 2px;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status-min-max-ms {
overflow-x: hidden;
}
#tooltip {
position: fixed;
top: 0;
left: 0;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip>.tooltip-title:first-child {
margin-top: 0;
}
#social {
position: fixed;
right: 5px;
bottom: 5px;
padding: 5px;
margin: 0;
z-index: 100;
}
#social img {
opacity: 0.3;
}
#social img:hover {
opacity: 1;
}
#settings {
position: fixed;
left: 5px;
bottom: 5px;
padding: 5px;
}
#settings select:focus {
box-shadow: none;
}
.service-group {
cursor: pointer;
user-select: none;
}
.service-group h5:hover {
color: #1b1e21 !important;
}
</style>
</head>
<body>
<div class="container my-3 rounded p-3 border shadow">
<div class="mb-2">
<div class="row">
<div class="col-8 text-left my-auto">
<div class="title display-4">Health Status</div>
</div>
<div class="col-4 text-right">
<img src="logo.png" alt="Gatus" style="position: relative; min-width: 50px; max-width: 200px; width: 20%;"/>
</div>
</div>
</div>
<div id="results"></div>
</div>
<div id="tooltip" style="display: none">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">...</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">...</code>
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">...</code>
<div id="tooltip-errors-container">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">...</code>
</div>
</div>
<script src="./jquery.min.js"></script>
<div id="social">
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
<img src="./github.png" alt="GitHub" width="32" height="auto" />
</a>
</div>
<div id="settings">
<div class="input-group input-group-sm">
<div class="input-group-prepend">
<div class="input-group-text">&#x21bb;</div>
</div>
<select class="form-control form-control-sm" id="refresh-rate">
<option value="10">10s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="120">2m</option>
<option value="300">5m</option>
<option value="600">10m</option>
</select>
</div>
</div>
<script>
let serviceStatuses = {};
let timerHandler = 0;
let refreshIntervalHandler = 0;
let userClickedStatus = false;
// TODO: make this variable configurable and persist the choice in localStorage
let showStatusOnHover = true;
function showTooltip(serviceName, index, element) {
//userClickedStatus = false;
clearTimeout(timerHandler);
let serviceResult = serviceStatuses[serviceName].results[index];
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
// Populate the condition section
let conditions = "";
for (let i in serviceResult['condition-results']) {
let conditionResult = serviceResult['condition-results'][i];
conditions += (conditionResult.success ? "&#10003;" : "X") + " ~ " + htmlEntities(conditionResult.condition) + "<br />";
}
$("#tooltip-conditions").html(conditions);
// Populate the error section only if there are errors
if (serviceResult.errors && serviceResult.errors.length > 0) {
let errors = "";
for (let i in serviceResult.errors) {
errors += "- " + htmlEntities(serviceResult.errors[i]) + "<br />";
}
$("#tooltip-errors").html(errors);
$("#tooltip-errors-container").show();
} else {
$("#tooltip-errors-container").hide();
}
// Position tooltip
$("#tooltip").css({top: "0px", left: "0px"}).show();
let targetTopPosition = element.getBoundingClientRect().y + 30;
let targetLeftPosition = element.getBoundingClientRect().x;
// Make adjustments if necessary
let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = element.getBoundingClientRect().x - tooltipBoundingClientRect.width + element.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = element.getBoundingClientRect().y + 30;
}
}
$("#tooltip").css({top: targetTopPosition + "px", left: targetLeftPosition + "px"});
}
function fadeTooltip() {
if (!userClickedStatus) {
timerHandler = setTimeout(function () {
$("#tooltip").hide();
}, 500);
}
}
function toggleTooltip(serviceName, index, element) {
console.log("userClickedStatus="+userClickedStatus);
if (!userClickedStatus) {
showTooltip(serviceName, index, element);
userClickedStatus = true;
} else {
$("#tooltip").hide();
userClickedStatus = false;
}
}
function createStatusBadge(serviceStatusIndex, index, success) {
if (success) {
if (showStatusOnHover) {
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>&#10003;</span>";
} else {
return "<span class='status badge badge-success' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>&#10003;</span>";
}
}
if (showStatusOnHover) {
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
} else {
return "<span class='status badge badge-danger' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>X</span>";
}
}
function refreshStatuses() {
$.getJSON("./api/v1/statuses", function (data) {
// Update the table only if there's a change
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
serviceStatuses = data;
buildTable();
}
});
}
function buildTable() {
let outputByGroup = {};
for (let serviceStatusIndex in serviceStatuses) {
let serviceStatusOverTime = "";
let serviceStatus = serviceStatuses[serviceStatusIndex];
let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname;
let minResponseTime = null;
let maxResponseTime = null;
let newestTimestamp = null;
let oldestTimestamp = null;
for (let resultIndex in serviceStatus.results) {
let serviceResult = serviceStatus.results[resultIndex];
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
const timestamp = new Date(serviceResult.timestamp);
if (newestTimestamp == null || newestTimestamp < timestamp) {
newestTimestamp = timestamp;
}
if (oldestTimestamp == null || oldestTimestamp > timestamp) {
oldestTimestamp = timestamp;
}
}
let output = ""
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
+ " <div class='row mb-2'>"
+ " <div class='col-md-10'>"
+ " <span class='font-weight-bold'>" + serviceStatus.name + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>"
+ " <div class='col-md-2 text-right'>"
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
+ " </div>"
+ " </div>"
+ " <div class='row'>"
+ " <div class='col-12 d-flex flex-row-reverse status-over-time'>"
+ " " + serviceStatusOverTime
+ " </div>"
+ " </div>"
+ " <div class='row status-time-ago'>"
+ " <div class='col-6'>"
+ " " + generatePrettyTimeAgo(oldestTimestamp)
+ " </div>"
+ " <div class='col-6 text-right'>"
+ " " + generatePrettyTimeAgo(newestTimestamp)
+ " </div>"
+ " </div>"
+ "</div>";
// create an empty entry if this group is new
if (!outputByGroup[serviceStatus.group]) {
outputByGroup[serviceStatus.group] = "";
}
outputByGroup[serviceStatus.group] += output;
}
let output = "";
for (let group in outputByGroup) {
// Services that don't have a group should be skipped and left for last
if (group === 'undefined') {
continue
}
let key = group.replace(/[^a-zA-Z0-9]/g, '');
let existingGroupContentSelector = $("#service-group-" + key + "-content");
let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none';
let groupStatus = "<span class='text-success'>&#10003;</span>";
if (outputByGroup[group].includes("badge badge-danger")) {
groupStatus = "<span class='text-warning'>~</span>";
}
output += ""
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
+ " <div class='container pt-2 border-left border-right border-top border-black border-bottom service-group' id='service-group-" + key + "' data-group='" + key + "' onclick='toggleGroup(this)'>"
+ " <h5 class='text-secondary text-monospace pb-0'>"
+ " " + groupStatus + " " + group
+ " <span class='float-right service-group-arrow' id='service-group-" + key + "-arrow'>" + (isCurrentlyHidden ? "&#9660;" : "&#9650;") + "</span>"
+ " </h5>"
+ " </div>"
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
+ " " + outputByGroup[group]
+ " </div>"
+ "</div>";
}
// Add all services that don't have a group at the end
if (outputByGroup['undefined']) {
output += ""
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
+ " " + outputByGroup['undefined']
+ "</div>"
}
$("#results").html(output);
}
function toggleGroup(element) {
let selector = $("#service-group-" + element.dataset.group + "-content");
selector.toggle("fast", function() {
if (selector.length && selector[0].style.display === 'none') {
$("#service-group-" + element.dataset.group + "-arrow").html("&#9660;");
} else {
$("#service-group-" + element.dataset.group + "-arrow").html("&#9650;");
}
});
}
function prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth()+1)<10?"0":"")+""+(date.getMonth()+1);
let DD = ((date.getDate())<10?"0":"")+""+(date.getDate());
let hh = ((date.getHours())<10?"0":"")+""+(date.getHours());
let mm = ((date.getMinutes())<10?"0":"")+""+(date.getMinutes());
let ss = ((date.getSeconds())<10?"0":"")+""+(date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
}
function generatePrettyTimeAgo(t) {
let differenceInMs = new Date().getTime() - new Date(t).getTime();
if (differenceInMs > 3600000) {
let hours = (differenceInMs/3600000).toFixed(0);
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 60000) {
let minutes = (differenceInMs/60000).toFixed(0);
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
}
return (differenceInMs/1000).toFixed(0) + " seconds ago";
}
function htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function setRefreshInterval(seconds) {
refreshStatuses();
refreshIntervalHandler = setInterval(function() {
refreshStatuses();
}, seconds * 1000);
}
$("#refresh-rate").change(function() {
clearInterval(refreshIntervalHandler);
setRefreshInterval($(this).val());
});
setRefreshInterval(30);
$("#refresh-rate").val(30);
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

7
storage/config.go Normal file
View File

@@ -0,0 +1,7 @@
package storage
// Config is the configuration for alerting providers
type Config struct {
// File is the path of the file to use when using file.Store
File string `yaml:"file"`
}

View File

@@ -1,61 +0,0 @@
package storage
import (
"encoding/json"
"fmt"
"sync"
"github.com/TwinProduction/gatus/core"
)
// InMemoryStore implements an in-memory store
type InMemoryStore struct {
serviceStatuses map[string]*core.ServiceStatus
serviceResultsMutex sync.RWMutex
}
// NewInMemoryStore returns an in-memory store. Note that the store acts as a singleton, so although new-ing
// up in-memory stores will give you a unique reference to a struct each time, all structs returned
// by this function will act on the same in-memory store.
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{
serviceStatuses: make(map[string]*core.ServiceStatus),
}
}
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
func (ims *InMemoryStore) GetAllAsJSON() ([]byte, error) {
ims.serviceResultsMutex.RLock()
serviceStatuses, err := json.Marshal(ims.serviceStatuses)
ims.serviceResultsMutex.RUnlock()
return serviceStatuses, err
}
// GetServiceStatus returns the service status for a given service name in the given group
func (ims *InMemoryStore) GetServiceStatus(group, name string) *core.ServiceStatus {
key := fmt.Sprintf("%s_%s", group, name)
ims.serviceResultsMutex.RLock()
serviceStatus := ims.serviceStatuses[key]
ims.serviceResultsMutex.RUnlock()
return serviceStatus
}
// Insert inserts the observed result for the specified service into the in memory store
func (ims *InMemoryStore) Insert(service *core.Service, result *core.Result) {
key := fmt.Sprintf("%s_%s", service.Group, service.Name)
ims.serviceResultsMutex.Lock()
serviceStatus, exists := ims.serviceStatuses[key]
if !exists {
serviceStatus = core.NewServiceStatus(service)
ims.serviceStatuses[key] = serviceStatus
}
serviceStatus.AddResult(result)
ims.serviceResultsMutex.Unlock()
}
// Clear will empty all the results from the in memory store
func (ims *InMemoryStore) Clear() {
ims.serviceResultsMutex.Lock()
ims.serviceStatuses = make(map[string]*core.ServiceStatus)
ims.serviceResultsMutex.Unlock()
}

48
storage/storage.go Normal file
View File

@@ -0,0 +1,48 @@
package storage
import (
"log"
"time"
"github.com/TwinProduction/gatus/storage/store"
"github.com/TwinProduction/gatus/storage/store/memory"
)
var (
provider store.Store
// initialized keeps track of whether the storage provider was initialized
// Because store.Store is an interface, a nil check wouldn't be sufficient, so instead of doing reflection
// every single time Get is called, we'll just lazily keep track of its existence through this variable
initialized bool
)
// Get retrieves the storage provider
func Get() store.Store {
if !initialized {
log.Println("[storage][Get] Provider requested before it was initialized, automatically initializing")
err := Initialize(nil)
if err != nil {
panic("failed to automatically initialize store: " + err.Error())
}
}
return provider
}
// Initialize instantiates the storage provider based on the Config provider
func Initialize(cfg *Config) error {
initialized = true
var err error
if cfg == nil || len(cfg.File) == 0 {
log.Println("[storage][Initialize] Creating storage provider")
provider, err = memory.NewStore("")
} else {
log.Printf("[storage][Initialize] Creating storage provider with file=%s", cfg.File)
provider, err = memory.NewStore(cfg.File)
if err != nil {
return err
}
go provider.(*memory.Store).AutoSave(7 * time.Minute)
}
return nil
}

View File

@@ -0,0 +1,110 @@
package memory
import (
"encoding/gob"
"encoding/json"
"log"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/util"
"github.com/TwinProduction/gocache"
)
func init() {
gob.Register(&core.ServiceStatus{})
gob.Register(&core.Uptime{})
gob.Register(&core.Result{})
gob.Register(&core.Event{})
}
// Store that leverages gocache
type Store struct {
file string
cache *gocache.Cache
}
// NewStore creates a new store
func NewStore(file string) (*Store, error) {
store := &Store{
file: file,
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
}
if len(file) > 0 {
_, err := store.cache.ReadFromFile(file)
if err != nil {
return nil, err
}
}
return store, nil
}
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
func (s *Store) GetAllAsJSON() ([]byte, error) {
return json.Marshal(s.cache.GetAll())
}
// GetServiceStatus returns the service status for a given service name in the given group
func (s *Store) GetServiceStatus(groupName, serviceName string) *core.ServiceStatus {
return s.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(groupName, serviceName))
}
// GetServiceStatusByKey returns the service status for a given key
func (s *Store) GetServiceStatusByKey(key string) *core.ServiceStatus {
serviceStatus := s.cache.GetValue(key)
if serviceStatus == nil {
return nil
}
return serviceStatus.(*core.ServiceStatus)
}
// Insert adds the observed result for the specified service into the store
func (s *Store) Insert(service *core.Service, result *core.Result) {
key := util.ConvertGroupAndServiceToKey(service.Group, service.Name)
serviceStatus, exists := s.cache.Get(key)
if !exists {
serviceStatus = core.NewServiceStatus(service)
}
serviceStatus.(*core.ServiceStatus).AddResult(result)
s.cache.Set(key, serviceStatus)
}
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
func (s *Store) DeleteAllServiceStatusesNotInKeys(keys []string) int {
var keysToDelete []string
for _, existingKey := range s.cache.GetKeysByPattern("*", 0) {
shouldDelete := true
for _, key := range keys {
if existingKey == key {
shouldDelete = false
break
}
}
if shouldDelete {
keysToDelete = append(keysToDelete, existingKey)
}
}
return s.cache.DeleteAll(keysToDelete)
}
// Clear deletes everything from the store
func (s *Store) Clear() {
s.cache.Clear()
}
// Save persists the cache to the store file
func (s *Store) Save() error {
return s.cache.SaveToFile(s.file)
}
// AutoSave automatically calls the Save function at every interval
func (s *Store) AutoSave(interval time.Duration) {
for {
time.Sleep(interval)
log.Printf("[memory][AutoSave] Persisting data to file")
err := s.Save()
if err != nil {
log.Printf("[memory][AutoSave] failed to save to file=%s: %s", s.file, err.Error())
}
}
}

View File

@@ -1,4 +1,4 @@
package storage
package memory
import (
"fmt"
@@ -6,6 +6,7 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/util"
)
var (
@@ -82,17 +83,17 @@ var (
}
)
func TestInMemoryStore_Insert(t *testing.T) {
store := NewInMemoryStore()
func TestStore_Insert(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
if len(store.serviceStatuses) != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", len(store.serviceStatuses))
if store.cache.Count() != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", store.cache.Count())
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
serviceStatus, exists := store.serviceStatuses[key]
if !exists {
serviceStatus := store.GetServiceStatusByKey(key)
if serviceStatus == nil {
t.Fatalf("Store should've had key '%s', but didn't", key)
}
if len(serviceStatus.Results) != 2 {
@@ -139,8 +140,8 @@ func TestInMemoryStore_Insert(t *testing.T) {
}
}
func TestInMemoryStore_GetServiceStatus(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetServiceStatus(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
@@ -160,11 +161,10 @@ func TestInMemoryStore_GetServiceStatus(t *testing.T) {
if serviceStatus.Uptime.LastSevenDays != 0.5 {
t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5")
}
fmt.Println(serviceStatus.Results[0].Timestamp.Format(time.RFC3339))
}
func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
serviceStatus := store.GetServiceStatus("nonexistantgroup", "nonexistantname")
@@ -181,8 +181,31 @@ func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T)
}
}
func TestInMemoryStore_GetAllAsJSON(t *testing.T) {
store := NewInMemoryStore()
func TestStore_GetServiceStatusByKey(t *testing.T) {
store, _ := NewStore("")
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
serviceStatus := store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(testService.Group, testService.Name))
if serviceStatus == nil {
t.Fatalf("serviceStatus shouldn't have been nil")
}
if serviceStatus.Uptime == nil {
t.Fatalf("serviceStatus.Uptime shouldn't have been nil")
}
if serviceStatus.Uptime.LastHour != 0.5 {
t.Errorf("serviceStatus.Uptime.LastHour should've been 0.5")
}
if serviceStatus.Uptime.LastTwentyFourHours != 0.5 {
t.Errorf("serviceStatus.Uptime.LastTwentyFourHours should've been 0.5")
}
if serviceStatus.Uptime.LastSevenDays != 0.5 {
t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5")
}
}
func TestStore_GetAllAsJSON(t *testing.T) {
store, _ := NewStore("")
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
store.Insert(&testService, firstResult)
@@ -194,8 +217,36 @@ func TestInMemoryStore_GetAllAsJSON(t *testing.T) {
if err != nil {
t.Fatal("shouldn't have returned an error, got", err.Error())
}
expectedOutput := `{"group_name":{"name":"name","group":"group","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"condition-results":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"condition-results":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"uptime":{"7d":0.5,"24h":0.5,"1h":0.5}}}`
expectedOutput := `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`
if string(output) != expectedOutput {
t.Errorf("expected:\n %s\n\ngot:\n %s", expectedOutput, string(output))
}
}
func TestStore_DeleteAllServiceStatusesNotInKeys(t *testing.T) {
store, _ := NewStore("")
firstService := core.Service{Name: "service-1", Group: "group"}
secondService := core.Service{Name: "service-2", Group: "group"}
result := &testSuccessfulResult
store.Insert(&firstService, result)
store.Insert(&secondService, result)
if store.cache.Count() != 2 {
t.Errorf("expected cache to have 2 keys, got %d", store.cache.Count())
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)) == nil {
t.Fatal("firstService should exist")
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(secondService.Group, secondService.Name)) == nil {
t.Fatal("secondService should exist")
}
store.DeleteAllServiceStatusesNotInKeys([]string{util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)})
if store.cache.Count() != 1 {
t.Fatalf("expected cache to have 1 keys, got %d", store.cache.Count())
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)) == nil {
t.Error("secondService should've been deleted")
}
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(secondService.Group, secondService.Name)) != nil {
t.Error("firstService should still exist")
}
}

34
storage/store/store.go Normal file
View File

@@ -0,0 +1,34 @@
package store
import (
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/memory"
)
// Store is the interface that each stores should implement
type Store interface {
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
GetAllAsJSON() ([]byte, error)
// GetServiceStatus returns the service status for a given service name in the given group
GetServiceStatus(groupName, serviceName string) *core.ServiceStatus
// GetServiceStatusByKey returns the service status for a given key
GetServiceStatusByKey(key string) *core.ServiceStatus
// Insert adds the observed result for the specified service into the store
Insert(service *core.Service, result *core.Result)
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
//
// Used to delete services that have been persisted but are no longer part of the configured services
DeleteAllServiceStatusesNotInKeys(keys []string) int
// Clear deletes everything from the store
Clear()
}
var (
// Validate interface implementation on compile
_ Store = (*memory.Store)(nil)
)

View File

@@ -0,0 +1,136 @@
package store
import (
"testing"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/memory"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testService = core.Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Body: []byte("body"),
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Body: []byte("body"),
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func BenchmarkStore_GetAllAsJSON(b *testing.B) {
memoryStore, err := memory.NewStore("")
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
type Scenario struct {
Name string
Store Store
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
},
}
for _, scenario := range scenarios {
scenario.Store.Insert(&testService, &testSuccessfulResult)
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
b.Run(scenario.Name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
scenario.Store.GetAllAsJSON()
}
b.ReportAllocs()
})
}
}
func BenchmarkStore_Insert(b *testing.B) {
memoryStore, err := memory.NewStore("")
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
type Scenario struct {
Name string
Store Store
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
},
}
for _, scenario := range scenarios {
b.Run(scenario.Name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
scenario.Store.Insert(&testService, &testSuccessfulResult)
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
}
b.ReportAllocs()
})
}
}

18
util/key.go Normal file
View File

@@ -0,0 +1,18 @@
package util
import "strings"
// ConvertGroupAndServiceToKey converts a group and a service to a key
func ConvertGroupAndServiceToKey(group, service string) string {
return sanitize(group) + "_" + sanitize(service)
}
func sanitize(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, "_", "-")
s = strings.ReplaceAll(s, ".", "-")
s = strings.ReplaceAll(s, ",", "-")
s = strings.ReplaceAll(s, " ", "-")
return s
}

36
util/key_test.go Normal file
View File

@@ -0,0 +1,36 @@
package util
import "testing"
func TestConvertGroupAndServiceToKey(t *testing.T) {
type Scenario struct {
GroupName string
ServiceName string
ExpectedOutput string
}
scenarios := []Scenario{
{
GroupName: "Core",
ServiceName: "Front End",
ExpectedOutput: "core_front-end",
},
{
GroupName: "Load balancers",
ServiceName: "us-west-2",
ExpectedOutput: "load-balancers_us-west-2",
},
{
GroupName: "a/b test",
ServiceName: "a",
ExpectedOutput: "a-b-test_a",
},
}
for _, scenario := range scenarios {
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
output := ConvertGroupAndServiceToKey(scenario.GroupName, scenario.ServiceName)
if output != scenario.ExpectedOutput {
t.Errorf("Expected '%s', got '%s'", scenario.ExpectedOutput, output)
}
})
}
}

View File

@@ -306,9 +306,9 @@ If you do not start the janitor, there will be no passive deletion of expired ke
For the sake of convenience, a ready-to-go cache server is available
through the `gocacheserver` package.
The reason why the server is in a different package is because `gocache` does not use
any external dependencies, but rather than re-inventing the wheel, the server
implementation uses redcon, which is a Redis server framework for Go.
The reason why the server is in a different package is because `gocache` limit its external dependencies to the strict
minimum (e.g. boltdb for persistence), however, rather than re-inventing the wheel, the server implementation uses
redcon, which is a very good Redis server framework for Go.
That way, those who desire to use gocache without the server will not add any extra dependencies
as long as they don't import the `gocacheserver` package.
@@ -323,7 +323,7 @@ import (
func main() {
cache := gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed).WithMaxSize(100000)
server := gocacheserver.NewServer(cache)
server := gocacheserver.NewServer(cache).WithPort(6379)
server.Start()
}
```
@@ -382,43 +382,67 @@ but if you're looking into using a library like gocache, odds are, you want more
| mem | 32G DDR4 |
```
BenchmarkMap_Get-8 95936680 26.3 ns/op
BenchmarkMap_SetSmallValue-8 7738132 424 ns/op
BenchmarkMap_SetMediumValue-8 7766346 424 ns/op
BenchmarkMap_SetLargeValue-8 7947063 435 ns/op
BenchmarkCache_Get-8 54549049 45.7 ns/op
BenchmarkCache_SetSmallValue-8 35225013 69.2 ns/op
BenchmarkCache_SetMediumValue-8 5952064 412 ns/op
BenchmarkCache_SetLargeValue-8 5969121 411 ns/op
BenchmarkCache_GetUsingLRU-8 54545949 45.6 ns/op
BenchmarkCache_SetSmallValueUsingLRU-8 5909504 419 ns/op
BenchmarkCache_SetMediumValueUsingLRU-8 5910885 418 ns/op
BenchmarkCache_SetLargeValueUsingLRU-8 5867544 419 ns/op
BenchmarkCache_SetSmallValueWhenUsingMaxMemoryUsage-8 5477178 462 ns/op
BenchmarkCache_SetMediumValueWhenUsingMaxMemoryUsage-8 5417595 475 ns/op
BenchmarkCache_SetLargeValueWhenUsingMaxMemoryUsage-8 5215263 479 ns/op
BenchmarkCache_SetSmallValueWithMaxSize10-8 10115574 236 ns/op
BenchmarkCache_SetMediumValueWithMaxSize10-8 10242792 241 ns/op
BenchmarkCache_SetLargeValueWithMaxSize10-8 10201894 241 ns/op
BenchmarkCache_SetSmallValueWithMaxSize1000-8 9637113 253 ns/op
BenchmarkCache_SetMediumValueWithMaxSize1000-8 9635175 253 ns/op
BenchmarkCache_SetLargeValueWithMaxSize1000-8 9598982 260 ns/op
BenchmarkCache_SetSmallValueWithMaxSize100000-8 7642584 337 ns/op
BenchmarkCache_SetMediumValueWithMaxSize100000-8 7407571 344 ns/op
BenchmarkCache_SetLargeValueWithMaxSize100000-8 7071360 345 ns/op
BenchmarkCache_SetSmallValueWithMaxSize100000AndLRU-8 7544194 332 ns/op
BenchmarkCache_SetMediumValueWithMaxSize100000AndLRU-8 7667004 344 ns/op
BenchmarkCache_SetLargeValueWithMaxSize100000AndLRU-8 7357642 338 ns/op
BenchmarkCache_GetAndSetMultipleConcurrently-8 1442306 1684 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndLRU-8 5117271 477 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndFIFO-8 5228412 475 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndNoEvictionAndLRU-8 5139195 529 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndNoEvictionAndFIFO-8 5251639 511 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithFrequentEvictionsAndLRU-8 7384626 334 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithFrequentEvictionsAndFIFO-8 7361985 332 ns/op
BenchmarkCache_GetConcurrentlyWithLRU-8 3370784 726 ns/op
BenchmarkCache_GetConcurrentlyWithFIFO-8 3749994 681 ns/op
BenchmarkCache_GetKeysThatDoNotExistConcurrently-8 17647344 143 ns/op
// Normal map
BenchmarkMap_Get
BenchmarkMap_Get-8 46087372 26.7 ns/op
BenchmarkMap_Set
BenchmarkMap_Set/small_value-8 3841911 389 ns/op
BenchmarkMap_Set/medium_value-8 3887074 391 ns/op
BenchmarkMap_Set/large_value-8 3921956 393 ns/op
// Gocache
BenchmarkCache_Get
BenchmarkCache_Get/FirstInFirstOut-8 27273036 46.4 ns/op
BenchmarkCache_Get/LeastRecentlyUsed-8 26648248 46.3 ns/op
BenchmarkCache_Set
BenchmarkCache_Set/FirstInFirstOut_small_value-8 2919584 405 ns/op
BenchmarkCache_Set/FirstInFirstOut_medium_value-8 2990841 391 ns/op
BenchmarkCache_Set/FirstInFirstOut_large_value-8 2970513 391 ns/op
BenchmarkCache_Set/LeastRecentlyUsed_small_value-8 2962939 402 ns/op
BenchmarkCache_Set/LeastRecentlyUsed_medium_value-8 2962963 390 ns/op
BenchmarkCache_Set/LeastRecentlyUsed_large_value-8 2962928 394 ns/op
BenchmarkCache_SetUsingMaxMemoryUsage
BenchmarkCache_SetUsingMaxMemoryUsage/small_value-8 2683356 447 ns/op
BenchmarkCache_SetUsingMaxMemoryUsage/medium_value-8 2637578 441 ns/op
BenchmarkCache_SetUsingMaxMemoryUsage/large_value-8 2672434 443 ns/op
BenchmarkCache_SetWithMaxSize
BenchmarkCache_SetWithMaxSize/100_small_value-8 4782966 252 ns/op
BenchmarkCache_SetWithMaxSize/10000_small_value-8 4067967 296 ns/op
BenchmarkCache_SetWithMaxSize/100000_small_value-8 3762055 328 ns/op
BenchmarkCache_SetWithMaxSize/100_medium_value-8 4760479 252 ns/op
BenchmarkCache_SetWithMaxSize/10000_medium_value-8 4081050 295 ns/op
BenchmarkCache_SetWithMaxSize/100000_medium_value-8 3785050 330 ns/op
BenchmarkCache_SetWithMaxSize/100_large_value-8 4732909 254 ns/op
BenchmarkCache_SetWithMaxSize/10000_large_value-8 4079533 297 ns/op
BenchmarkCache_SetWithMaxSize/100000_large_value-8 3712820 331 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU
BenchmarkCache_SetWithMaxSizeAndLRU/100_small_value-8 4761732 254 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/10000_small_value-8 4084474 296 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/100000_small_value-8 3761402 329 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/100_medium_value-8 4783075 254 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/10000_medium_value-8 4103980 296 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/100000_medium_value-8 3646023 331 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/100_large_value-8 4779025 254 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/10000_large_value-8 4096192 296 ns/op
BenchmarkCache_SetWithMaxSizeAndLRU/100000_large_value-8 3726823 331 ns/op
BenchmarkCache_GetSetMultipleConcurrent
BenchmarkCache_GetSetMultipleConcurrent-8 707142 1698 ns/op
BenchmarkCache_GetSetConcurrentWithFrequentEviction
BenchmarkCache_GetSetConcurrentWithFrequentEviction/FirstInFirstOut-8 3616256 334 ns/op
BenchmarkCache_GetSetConcurrentWithFrequentEviction/LeastRecentlyUsed-8 3636367 331 ns/op
BenchmarkCache_GetConcurrentWithLRU
BenchmarkCache_GetConcurrentWithLRU/FirstInFirstOut-8 4405557 268 ns/op
BenchmarkCache_GetConcurrentWithLRU/LeastRecentlyUsed-8 4445475 269 ns/op
BenchmarkCache_WithForceNilInterfaceOnNilPointer
BenchmarkCache_WithForceNilInterfaceOnNilPointer/true_with_nil_struct_pointer-8 6184591 191 ns/op
BenchmarkCache_WithForceNilInterfaceOnNilPointer/true-8 6090482 191 ns/op
BenchmarkCache_WithForceNilInterfaceOnNilPointer/false_with_nil_struct_pointer-8 6184629 187 ns/op
BenchmarkCache_WithForceNilInterfaceOnNilPointer/false-8 6281781 186 ns/op
(Trimmed "BenchmarkCache_" for readability)
WithForceNilInterfaceOnNilPointerWithConcurrency
WithForceNilInterfaceOnNilPointerWithConcurrency/true_with_nil_struct_pointer-8 4379564 268 ns/op
WithForceNilInterfaceOnNilPointerWithConcurrency/true-8 4379558 265 ns/op
WithForceNilInterfaceOnNilPointerWithConcurrency/false_with_nil_struct_pointer-8 4444456 261 ns/op
WithForceNilInterfaceOnNilPointerWithConcurrency/false-8 4493896 262 ns/op
```

View File

@@ -170,7 +170,7 @@ func (cache *Cache) WithEvictionPolicy(policy EvictionPolicy) *Cache {
// value, _ := cache.Get("key")
// // the following returns true, because the interface{} was forcefully set to nil
// if value == nil {}
// // the following will panic, because the value has been casted to its type
// // the following will panic, because the value has been casted to its type (which is nil)
// if value.(*Struct) == nil {}
//
// If set to false:
@@ -218,7 +218,8 @@ func (cache *Cache) Set(key string, value interface{}) {
// The TTL provided must be greater than 0, or NoExpiration (-1). If a negative value that isn't -1 (NoExpiration) is
// provided, the entry will not be created if the key doesn't exist
func (cache *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) {
// An interface is only nil if both its value and its type are nil, however, passing a pointer
// An interface is only nil if both its value and its type are nil, however, passing a nil pointer as an interface{}
// means that the interface itself is not nil, because the interface value is nil but not the type.
if cache.forceNilInterfaceOnNilPointer {
if value != nil && (reflect.ValueOf(value).Kind() == reflect.Ptr && reflect.ValueOf(value).IsNil()) {
value = nil
@@ -334,6 +335,13 @@ func (cache *Cache) Get(key string) (interface{}, bool) {
return entry.Value, true
}
// GetValue retrieves an entry using the key passed as parameter
// Unlike Get, this function only returns the value
func (cache *Cache) GetValue(key string) interface{} {
value, _ := cache.Get(key)
return value
}
// GetByKeys retrieves multiple entries using the keys passed as parameter
// All keys are returned in the map, regardless of whether they exist or not, however, entries that do not exist in the
// cache will return nil, meaning that there is no way of determining whether a key genuinely has the value nil, or

View File

@@ -3,6 +3,30 @@ package gocache
type EvictionPolicy string
var (
// LeastRecentlyUsed is an eviction policy that causes the most recently accessed cache entry to be moved to the
// head of the cache. Effectively, this causes the cache entries that have not been accessed for some time to
// gradually move closer and closer to the tail, and since the tail is the entry that gets deleted when an eviction
// is required, it allows less used cache entries to be evicted while keeping recently accessed entries at or close
// to the head.
//
// For instance, creating a Cache with a Cache.MaxSize of 3 and creating the entries 1, 2 and 3 in that order would
// put 3 at the head and 1 at the tail:
// 3 (head) -> 2 -> 1 (tail)
// If the cache entry 1 was then accessed, 1 would become the head and 2 the tail:
// 1 (head) -> 3 -> 2 (tail)
// If a cache entry 4 was then created, because the Cache.MaxSize is 3, the tail (2) would then be evicted:
// 4 (head) -> 1 -> 3 (tail)
LeastRecentlyUsed EvictionPolicy = "LeastRecentlyUsed"
FirstInFirstOut EvictionPolicy = "FirstInFirstOut"
// FirstInFirstOut is an eviction policy that causes cache entries to be evicted in the same order that they are
// created.
//
// For instance, creating a Cache with a Cache.MaxSize of 3 and creating the entries 1, 2 and 3 in that order would
// put 3 at the head and 1 at the tail:
// 3 (head) -> 2 -> 1 (tail)
// If the cache entry 1 was then accessed, unlike with LeastRecentlyUsed, nothing would change:
// 3 (head) -> 2 -> 1 (tail)
// If a cache entry 4 was then created, because the Cache.MaxSize is 3, the tail (1) would then be evicted:
// 4 (head) -> 3 -> 2 (tail)
FirstInFirstOut EvictionPolicy = "FirstInFirstOut"
)

2
vendor/modules.txt vendored
View File

@@ -1,7 +1,7 @@
# cloud.google.com/go v0.74.0
## explicit
cloud.google.com/go/compute/metadata
# github.com/TwinProduction/gocache v1.1.0
# github.com/TwinProduction/gocache v1.2.0
## explicit
github.com/TwinProduction/gocache
# github.com/beorn7/perks v1.0.1

View File

@@ -13,8 +13,6 @@ import (
)
var (
store = storage.NewInMemoryStore()
// monitoringMutex is used to prevent multiple services from being evaluated at the same time.
// Without this, conditions using response time may become inaccurate.
monitoringMutex sync.Mutex
@@ -22,18 +20,23 @@ var (
// GetServiceStatusesAsJSON the JSON encoding of all core.ServiceStatus recorded
func GetServiceStatusesAsJSON() ([]byte, error) {
return store.GetAllAsJSON()
return storage.Get().GetAllAsJSON()
}
// GetUptimeByServiceGroupAndName returns the uptime of a service based on its group and name
func GetUptimeByServiceGroupAndName(group, name string) *core.Uptime {
serviceStatus := store.GetServiceStatus(group, name)
// GetUptimeByKey returns the uptime of a service based on the ServiceStatus key
func GetUptimeByKey(key string) *core.Uptime {
serviceStatus := storage.Get().GetServiceStatusByKey(key)
if serviceStatus == nil {
return nil
}
return serviceStatus.Uptime
}
// GetServiceStatusByKey returns the uptime of a service based on its ServiceStatus key
func GetServiceStatusByKey(key string) *core.ServiceStatus {
return storage.Get().GetServiceStatusByKey(key)
}
// Monitor loops over each services and starts a goroutine to monitor each services separately
func Monitor(cfg *config.Config) {
for _, service := range cfg.Services {
@@ -83,5 +86,5 @@ func monitor(service *core.Service) {
// UpdateServiceStatuses updates the slice of service statuses
func UpdateServiceStatuses(service *core.Service, result *core.Result) {
store.Insert(service, result)
storage.Get().Insert(service, result)
}

23
web/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
web/app/README.md Normal file
View File

@@ -0,0 +1,24 @@
# app
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
web/app/babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

27934
web/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
web/app/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode development",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@tailwindcss/postcss7-compat": "^2.0.2",
"autoprefixer": "^9.8.6",
"core-js": "^3.6.5",
"postcss": "^7.0.35",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0-0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('tailwindcss')
],
};

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

16
web/app/public/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Health Dashboard | Gatus</title>
</head>
<body>
<noscript>
<strong>Enable JavaScript to view this page.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

56
web/app/src/App.vue Normal file
View File

@@ -0,0 +1,56 @@
<template>
<div class="container container-xs relative mx-auto xl:rounded xl:border xl:shadow-xl xl:my-5 p-5 pb-12 xl:pb-5 text-left" id="global">
<div class="mb-2">
<div class="flex flex-wrap">
<div class="w-3/4 text-left my-auto">
<div class="text-3xl xl:text-5xl lg:text-4xl font-light">Health Status</div>
</div>
<div class="w-1/4 flex justify-end">
<img src="./assets/logo.png" alt="Gatus" class="object-scale-down" style="max-width: 100px; min-width: 50px; min-height:50px;"/>
</div>
</div>
</div>
<router-view @showTooltip="showTooltip"/>
</div>
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
<Social/>
</template>
<script>
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue';
export default {
name: 'App',
components: {
Social,
Tooltip
},
methods: {
showTooltip(result, event) {
this.tooltip = {result: result, event: event};
}
},
data() {
return {
tooltip: {}
}
},
}
</script>
<style>
html {
height: 100%;
}
html, body {
background-color: #f7f9fb;
}
#global, #results {
max-width: 1200px;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,147 @@
<template>
<div class='service px-3 py-3 border-l border-r border-t rounded-none hover:bg-gray-100' v-if="data && data.results && data.results.length">
<div class='flex flex-wrap mb-2'>
<div class='w-3/4'>
<router-link :to="generatePath()" class="font-bold hover:text-blue-800 hover:underline" title="View detailed service health">
{{ data.name }}
</router-link>
<span class='text-gray-500 font-light'> | {{ data.results[data.results.length - 1].hostname }}</span>
</div>
<div class='w-1/4 text-right'>
<span class='font-light status-min-max-ms'>
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + '-' + maxResponseTime)) }}ms
</span>
</div>
</div>
<div>
<div class='status-over-time flex flex-row'>
<slot v-for="filler in maximumNumberOfResults - data.results.length" :key="filler">
<span class="status rounded border border-dashed"> </span>
</slot>
<slot v-for="result in data.results" :key="result">
<span v-if="result.success" class="status rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)">&#10003;</span>
<span v-else class="status rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)">X</span>
</slot>
</div>
</div>
<div class='flex flex-wrap status-time-ago'>
<!-- Show "Last update at" instead? -->
<div class='w-1/2'>
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
</div>
<div class='w-1/2 text-right'>
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
</div>
</div>
</div>
</template>
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Service',
props: {
maximumNumberOfResults: Number,
data: Object,
},
emits: ['showTooltip'],
mixins: [helper],
methods: {
updateMinAndMaxResponseTimes() {
let minResponseTime = null;
let maxResponseTime = null;
for (let i in this.data.results) {
const responseTime = parseInt(this.data.results[i].duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
if (this.minResponseTime !== minResponseTime) {
this.minResponseTime = minResponseTime;
}
if (this.maxResponseTime !== maxResponseTime) {
this.maxResponseTime = maxResponseTime;
}
},
generatePath() {
if (!this.data) {
return '/';
}
return `/services/${this.data.key}`;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
watch: {
data: function () {
this.updateMinAndMaxResponseTimes();
}
},
created() {
this.updateMinAndMaxResponseTimes()
},
data() {
return {
minResponseTime: 0,
maxResponseTime: 0
}
}
}
</script>
<style>
.service:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.service:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-width: 3px;
border-color: #dee2e6;
border-style: solid;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
color: white;
width: 5%;
font-size: 75%;
font-weight: 700;
text-align: center;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-over-time {
overflow: auto;
}
.status-over-time > span:not(:first-child) {
margin-left: 2px;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status-min-max-ms {
overflow-x: hidden;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div :class="services.length === 0 ? 'mt-3' : 'mt-4'">
<slot v-if="name !== 'undefined'">
<div class="service-group pt-2 border" @click="toggleGroup">
<h5 class='text-monospace text-gray-400 text-xl font-medium pb-2 px-3'>
<span v-if="healthy" class='text-green-600'>&#10003;</span>
<span v-else class='text-yellow-400'>~</span>
{{ name }}
<span class='float-right service-group-arrow'>
{{ collapsed ? '&#9660;' : '&#9650;' }}
</span>
</h5>
</div>
</slot>
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'service-group-content'">
<slot v-for="service in services" :key="service">
<Service :data="service" @showTooltip="showTooltip" :maximumNumberOfResults="20"/>
</slot>
</div>
</div>
</template>
<script>
import Service from './Service.vue';
export default {
name: 'ServiceGroup',
components: {
Service
},
props: {
name: String,
services: Array
},
emits: ['showTooltip'],
methods: {
healthCheck() {
if (this.services) {
for (let i in this.services) {
for (let j in this.services[i].results) {
if (!this.services[i].results[j].success) {
// Set the service group to unhealthy (only if it's currently healthy)
if (this.healthy) {
this.healthy = false;
}
return;
}
}
}
}
// Set the service group to healthy (only if it's currently unhealthy)
if (!this.healthy) {
this.healthy = true;
}
},
toggleGroup() {
this.collapsed = !this.collapsed;
sessionStorage.setItem(`service-group:${this.name}:collapsed`, this.collapsed);
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
watch: {
services: function () {
this.healthCheck();
}
},
created() {
this.healthCheck();
},
data() {
return {
healthy: true,
collapsed: sessionStorage.getItem(`service-group:${this.name}:collapsed`) === "true"
}
}
}
</script>
<style>
.service-group {
cursor: pointer;
user-select: none;
}
.service-group h5:hover {
color: #1b1e21 !important;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div id="results">
<slot v-for="serviceGroup in serviceGroups" :key="serviceGroup">
<ServiceGroup :services="serviceGroup.services" :name="serviceGroup.name" @showTooltip="showTooltip"/>
</slot>
</div>
</template>
<script>
import ServiceGroup from './ServiceGroup.vue';
export default {
name: 'Services',
components: {
ServiceGroup
},
props: {
showStatusOnHover: Boolean,
serviceStatuses: Object
},
emits: ['showTooltip'],
methods: {
process() {
let outputByGroup = {};
for (let serviceStatusIndex in this.serviceStatuses) {
let serviceStatus = this.serviceStatuses[serviceStatusIndex];
// create an empty entry if this group is new
if (!outputByGroup[serviceStatus.group] || outputByGroup[serviceStatus.group].length === 0) {
outputByGroup[serviceStatus.group] = [];
}
outputByGroup[serviceStatus.group].push(serviceStatus);
}
let serviceGroups = [];
for (let name in outputByGroup) {
if (name !== 'undefined') {
serviceGroups.push({name: name, services: outputByGroup[name]})
}
}
// Add all services that don't have a group at the end
if (outputByGroup['undefined']) {
serviceGroups.push({name: 'undefined', services: outputByGroup['undefined']})
}
this.serviceGroups = serviceGroups;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
watch: {
serviceStatuses: function () {
this.process();
}
},
data() {
return {
userClickedStatus: false,
serviceGroups: []
}
}
}
</script>
<style>
.service-group-content > div:nth-child(1) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div id="settings">
<div class="flex bg-gray-200 rounded border border-gray-300 shadow">
<div class="text-sm text-gray-600 rounded-xl py-1 px-2">
&#x21bb;
</div>
<select class="text-center text-gray-500 text-sm" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
<option value="10">10s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="120">2m</option>
<option value="300">5m</option>
<option value="600">10m</option>
</select>
</div>
</div>
</template>
<script>
export default {
name: 'Settings',
props: {},
methods: {
setRefreshInterval(seconds) {
let that = this;
this.refreshIntervalHandler = setInterval(function () {
that.refreshData();
}, seconds * 1000);
},
refreshData() {
this.$emit('refreshData');
},
handleChangeRefreshInterval() {
this.refreshData();
clearInterval(this.refreshIntervalHandler);
this.setRefreshInterval(this.$refs.refreshInterval.value);
}
},
created() {
this.setRefreshInterval(this.refreshInterval);
},
unmounted() {
clearInterval(this.refreshIntervalHandler);
},
data() {
return {
refreshInterval: 30,
refreshIntervalHandler: 0,
}
},
}
// props.refreshInterval = 30
//$("#refresh-rate").val(30);
</script>
<style scoped>
#settings {
position: fixed;
left: 5px;
bottom: 5px;
padding: 5px;
}
#settings select:focus {
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div id="social">
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
<img src="../assets/github.png" alt="GitHub" width="32" height="auto"/>
</a>
</div>
</template>
<script>
export default {
name: 'Social'
}
</script>
<style scoped>
#social {
position: fixed;
right: 5px;
bottom: 5px;
padding: 5px;
margin: 0;
z-index: 100;
}
#social img {
opacity: 0.3;
}
#social img:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
<slot v-if="result">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
{{ conditionResult.success ? "&#10003;" : "X" }} ~ {{ conditionResult.condition }}<br/>
</slot>
</code>
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">
<slot v-for="error in result.errors" :key="error">
- {{ error }}<br/>
</slot>
</code>
</div>
</slot>
</div>
</template>
<script>
export default {
name: 'Services',
props: {
event: Event,
result: Object
},
methods: {
prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
},
htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
},
reposition() {
if (this.event && this.event.type) {
if (this.event.type === 'mouseenter') {
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
}
}
this.top = targetTopPosition;
this.left = targetLeftPosition;
} else if (this.event.type === 'mouseleave') {
this.hidden = true;
}
}
}
},
watch: {
event: function (value) {
if (value && value.type) {
if (value.type === 'mouseenter') {
this.hidden = false;
} else if (value.type === 'mouseleave') {
this.hidden = true;
}
}
}
},
updated() {
this.reposition();
},
created() {
this.reposition();
},
data() {
return {
hidden: true,
top: 0,
left: 0
}
}
}
</script>
<style>
#tooltip {
position: fixed;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip > .tooltip-title:first-child {
margin-top: 0;
}
</style>

11
web/app/src/index.css Normal file
View File

@@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.bg-success {
background-color: #28a745;
}
.text-monospace {
font-family: Consolas, monospace;
}

8
web/app/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import router from './router'
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '.' : 'http://localhost:8080'
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,16 @@
export const helper = {
methods: {
generatePrettyTimeAgo(t) {
let differenceInMs = new Date().getTime() - new Date(t).getTime();
if (differenceInMs > 3600000) {
let hours = (differenceInMs / 3600000).toFixed(0);
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 60000) {
let minutes = (differenceInMs / 60000).toFixed(0);
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
}
return (differenceInMs / 1000).toFixed(0) + " seconds ago";
},
}
}

View File

@@ -0,0 +1,23 @@
import {createRouter, createWebHistory} from 'vue-router'
import Home from '@/views/Home'
import Details from "@/views/Details";
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/services/:key',
name: 'Details',
component: Details,
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router

View File

@@ -0,0 +1,165 @@
<template>
<router-link to="../" class="absolute top-2 left-2 inline-block px-2 py-0 text-lg text-black transition bg-gray-100 rounded shadow ripple hover:shadow-lg hover:bg-gray-200 focus:outline-none">
&larr;
</router-link>
<div>
<slot v-if="serviceStatus">
<h1 class="text-xl xl:text-3xl text-monospace text-gray-400">RECENT CHECKS</h1>
<hr class="mb-4" />
<Service :data="serviceStatus" :maximumNumberOfResults="20" @showTooltip="showTooltip" />
</slot>
<div v-if="uptime" class="mt-12">
<h1 class="text-xl xl:text-3xl text-monospace text-gray-400">UPTIME</h1>
<hr />
<div class="flex space-x-4 text-center text-xl xl:text-2xl mt-3">
<div class="flex-1">
{{ prettifyUptime(uptime['7d']) }}
<h2 class="text-sm text-gray-400">Last 7 days</h2>
</div>
<div class="flex-1">
{{ prettifyUptime(uptime['24h']) }}
<h2 class="text-sm text-gray-400">Last 24 hours</h2>
</div>
<div class="flex-1">
{{ prettifyUptime(uptime['1h']) }}
<h2 class="text-sm text-gray-400">Last hour</h2>
</div>
</div>
<hr class="mt-1"/>
<h3 class="text-xl text-monospace text-gray-400 mt-1 text-right">BADGES</h3>
<div v-if="serviceStatus && serviceStatus.key" class="flex space-x-4 text-center text-2xl mt-6 relative bottom-12">
<div class="flex-1">
<img :src="generateBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto" />
</div>
<div class="flex-1">
<img :src="generateBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto" />
</div>
<div class="flex-1">
<img :src="generateBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto" />
</div>
</div>
</div>
<div>
<h1 class="text-xl xl:text-3xl text-monospace text-gray-400 mt-4">EVENTS</h1>
<hr class="mb-4" />
<div>
<slot v-for="event in events" :key="event">
<div class="p-3 my-4">
<h2 class="text-lg">
<img v-if="event.type === 'HEALTHY'" src="../assets/arrow-up-green.png" alt="Healthy" class="border border-green-600 rounded-full opacity-75 bg-green-100 mr-2 inline" width="26" />
<img v-else-if="event.type === 'UNHEALTHY'" src="../assets/arrow-down-red.png" alt="Unhealthy" class="border border-red-500 rounded-full opacity-75 bg-red-100 mr-2 inline" width="26" />
<img v-else-if="event.type === 'START'" src="../assets/arrow-right-black.png" alt="Start" class="border border-gray-500 rounded-full opacity-75 bg-gray-100 mr-2 inline" width="26" />
{{ event.fancyText }}
</h2>
<div class="flex mt-1 text-sm text-gray-400">
<div class="flex-1 text-left pl-10">
{{ new Date(event.timestamp).toISOString() }}
</div>
<div class="flex-1 text-right">
{{ event.fancyTimeAgo }}
</div>
</div>
</div>
</slot>
</div>
</div>
</div>
<Settings @refreshData="fetchData"/>
</template>
<script>
import Settings from '@/components/Settings.vue'
import Service from '@/components/Service.vue';
import {SERVER_URL} from "@/main.js";
import {helper} from "@/mixins/helper.js";
export default {
name: 'Details',
components: {
Service,
Settings,
},
emits: ['showTooltip'],
mixins: [helper],
methods: {
fetchData() {
//console.log("[Details][fetchData] Fetching data");
fetch(`${this.serverUrl}/api/v1/statuses/${this.$route.params.key}`)
.then(response => response.json())
.then(data => {
if (JSON.stringify(this.serviceStatus) !== JSON.stringify(data)) {
this.serviceStatus = data.serviceStatus;
this.uptime = data.uptime;
let events = [];
for (let i = data.events.length-1; i >= 0; i--) {
let event = data.events[i];
if (i === data.events.length-1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Service is unhealthy';
} else if (event.type === 'HEALTHY') {
event.fancyText = 'Service is healthy';
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
} else {
let nextEvent = data.events[i+1];
if (event.type === 'HEALTHY') {
event.fancyText = 'Service became healthy';
} else if (event.type === 'UNHEALTHY') {
if (nextEvent) {
event.fancyText = 'Service was unhealthy for ' + this.prettifyTimeDifference(nextEvent.timestamp, event.timestamp);
} else {
event.fancyText = 'Service became unhealthy';
}
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
}
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
events.push(event);
}
this.events = events;
}
});
},
generateBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/badges/uptime/${duration}/${this.serviceStatus.key}`;
},
prettifyUptime(uptime) {
if (!uptime) {
return '0%';
}
return (uptime * 100).toFixed(2) + '%'
},
prettifyTimeDifference(start, end) {
let minutes = Math.ceil((new Date(start) - new Date(end))/1000/60);
return minutes + (minutes === 1 ? ' minute' : ' minutes');
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
data() {
return {
serviceStatus: {},
events: [],
uptime: {"7d": 0, "24h": 0, "1h": 0},
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
}
},
created() {
this.fetchData();
}
}
</script>
<style scoped>
.service {
border-radius: 3px;
border-bottom-width: 3px;
border-color: #dee2e6;
border-style: solid;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<Services :serviceStatuses="serviceStatuses" :showStatusOnHover="true" @showTooltip="showTooltip"/>
<Settings @refreshData="fetchData"/>
</template>
<script>
import Settings from '@/components/Settings.vue'
import Services from '@/components/Services.vue';
import {SERVER_URL} from "@/main.js";
export default {
name: 'Home',
components: {
Services,
Settings,
},
emits: ['showTooltip'],
methods: {
fetchData() {
//console.log("[Home][fetchData] Fetching data");
fetch(`${SERVER_URL}/api/v1/statuses`)
.then(response => response.json())
.then(data => {
if (JSON.stringify(this.serviceStatuses) !== JSON.stringify(data)) {
this.serviceStatuses = data;
}
});
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
data() {
return {
serviceStatuses: {}
}
},
created() {
this.fetchData();
}
}
</script>

View File

@@ -0,0 +1,11 @@
module.exports = {
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}

6
web/app/vue.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
filenameHashing: false,
productionSourceMap: false,
outputDir: '../static',
publicPath: '/'
}

3
web/static/css/app.css Normal file

File diff suppressed because one or more lines are too long

BIN
web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web/static/img/github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
web/static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

1
web/static/index.html Normal file
View File

@@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>Health Dashboard | Gatus</title><link href="/css/app.css" rel="preload" as="style"><link href="/js/app.js" rel="preload" as="script"><link href="/js/chunk-vendors.js" rel="preload" as="script"><link href="/css/app.css" rel="stylesheet"></head><body><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div><script src="/js/chunk-vendors.js"></script><script src="/js/app.js"></script></body></html>

1
web/static/js/app.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long