Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd1430f043 | ||
|
|
79bef8d391 | ||
|
|
9196f57487 | ||
|
|
1e0d9e184c | ||
|
|
bc42497cb7 | ||
|
|
b0b0ab574d | ||
|
|
e369484e5f | ||
|
|
d18618449f | ||
|
|
9186049589 | ||
|
|
4362831f71 | ||
|
|
99f558d43e | ||
|
|
4a3d5944b6 | ||
|
|
688456d7cf | ||
|
|
431fb3e9f2 | ||
|
|
a1679ddc5e | ||
|
|
d8d8e8720b | ||
|
|
4a0d9d058a | ||
|
|
c8ccf9b352 | ||
|
|
45c966fbca | ||
|
|
d8d4756ef3 | ||
|
|
1e9c54cc0f | ||
|
|
80570688e1 | ||
|
|
43e6e3e8f5 | ||
|
|
8b4c5c20f3 | ||
|
|
6f6db36b0f | ||
|
|
467874de10 | ||
|
|
8337f41425 | ||
|
|
601d676e34 | ||
|
|
fbb5d48bf7 | ||
|
|
119b80edc0 | ||
|
|
99e8cfb1ce | ||
|
|
dcbbec7931 | ||
|
|
2ccd656386 | ||
|
|
5755f3a699 | ||
|
|
911d809376 | ||
|
|
752c872d3b | ||
|
|
67a3e4e330 | ||
|
|
668ed3b1a2 | ||
|
|
dc6cb8fc1d | ||
|
|
f1aa5191bf | ||
|
|
bc6ca2ebd0 | ||
|
|
30801938b2 | ||
|
|
ddddd405bb |
@@ -3,3 +3,4 @@ Dockerfile
|
||||
.github
|
||||
.idea
|
||||
.git
|
||||
web/app
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
6
.github/workflows/build.yml
vendored
@@ -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
@@ -1,4 +1,3 @@
|
||||
bin
|
||||
.idea
|
||||
.vscode
|
||||
gatus
|
||||
@@ -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
@@ -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
|
||||
26
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||

|
||||
[](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):   
|
||||
- **GitHub uptime badges**:   
|
||||
- **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
|
||||

|
||||

|
||||

|
||||
|
||||
> **NOTE**: This feature is currently in ALPHA
|
||||

|
||||

|
||||

|
||||
|
||||
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: 
|
||||
Example: 
|
||||
```
|
||||

|
||||

|
||||
```
|
||||
|
||||
If you'd like to see a visual example of each badges available, you can simply navigate to the service's detail page.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
169
controller/controller_test.go
Normal 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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
6
static/bootstrap.min.css
vendored
@@ -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">↻</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 ? "✓" : "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;'>✓</span>";
|
||||
} else {
|
||||
return "<span class='status badge badge-success' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>✓</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'>✓</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 ? "▼" : "▲") + "</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("▼");
|
||||
} else {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▲");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
2
static/jquery.min.js
vendored
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 83 KiB |
7
storage/config.go
Normal 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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
110
storage/store/memory/memory.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
136
storage/store/store_bench_test.go
Normal 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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
106
vendor/github.com/TwinProduction/gocache/README.md
generated
vendored
@@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
12
vendor/github.com/TwinProduction/gocache/gocache.go
generated
vendored
@@ -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
|
||||
|
||||
26
vendor/github.com/TwinProduction/gocache/policy.go
generated
vendored
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
27934
web/app/package-lock.json
generated
Normal file
48
web/app/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
5
web/app/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss')
|
||||
],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
16
web/app/public/index.html
Normal 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
@@ -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>
|
||||
BIN
web/app/src/assets/arrow-down-red.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
web/app/src/assets/arrow-right-black.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
web/app/src/assets/arrow-up-green.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
147
web/app/src/components/Service.vue
Normal 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)">✓</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>
|
||||
92
web/app/src/components/ServiceGroup.vue
Normal 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'>✓</span>
|
||||
<span v-else class='text-yellow-400'>~</span>
|
||||
{{ name }}
|
||||
<span class='float-right service-group-arrow'>
|
||||
{{ collapsed ? '▼' : '▲' }}
|
||||
</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>
|
||||
70
web/app/src/components/Services.vue
Normal 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>
|
||||
70
web/app/src/components/Settings.vue
Normal 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">
|
||||
↻
|
||||
</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>
|
||||
34
web/app/src/components/Social.vue
Normal 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>
|
||||
135
web/app/src/components/Tooltip.vue
Normal 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 ? "✓" : "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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
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
@@ -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
@@ -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')
|
||||
16
web/app/src/mixins/helper.js
Normal 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";
|
||||
},
|
||||
}
|
||||
}
|
||||
23
web/app/src/router/index.js
Normal 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
|
||||
165
web/app/src/views/Details.vue
Normal 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">
|
||||
←
|
||||
</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>
|
||||
42
web/app/src/views/Home.vue
Normal 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>
|
||||
11
web/app/tailwind.config.js
Normal 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
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
filenameHashing: false,
|
||||
productionSourceMap: false,
|
||||
outputDir: '../static',
|
||||
publicPath: '/'
|
||||
}
|
||||
3
web/static/css/app.css
Normal file
BIN
web/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
web/static/img/github.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
web/static/img/logo.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
1
web/static/index.html
Normal 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>
|
||||