├── .gitattributes ├── .github ├── FUNDING.yml ├── assets │ ├── logo.png │ ├── example.jpg │ ├── gitea-alerts.png │ ├── slack-alerts.png │ ├── teams-alerts.png │ ├── dashboard-dark.jpg │ ├── gatus-diagram.jpg │ ├── github-alerts.png │ ├── gitlab-alerts.png │ ├── gotify-alerts.png │ ├── endpoint-groups.jpg │ ├── grafana-dashboard.png │ ├── mattermost-alerts.png │ ├── telegram-alerts.png │ ├── logo-with-dark-text.png │ ├── past-announcements.jpg │ ├── dashboard-conditions.jpg │ ├── teams-workflows-alerts.png │ ├── pagerduty-integration-key.png │ ├── logo.svg │ └── gatus-diagram.drawio ├── dependabot.yml ├── codecov.yml └── workflows │ ├── test-ui.yml │ ├── benchmark.yml │ ├── test.yml │ ├── publish-experimental.yml │ ├── publish-custom.yml │ ├── labeler.yml │ ├── publish-release.yml │ └── publish-latest.yml ├── .examples ├── docker-compose-sqlite-storage │ ├── data │ │ └── .gitkeep │ ├── compose.yaml │ └── config │ │ └── config.yaml ├── docker-minimal │ ├── Dockerfile │ └── config.yaml ├── nixos │ ├── README.md │ └── gatus.nix ├── docker-compose │ ├── config │ │ └── config.yaml │ └── compose.yaml ├── docker-compose-grafana-prometheus │ ├── prometheus │ │ └── prometheus.yml │ ├── grafana │ │ ├── provisioning │ │ │ ├── datasources │ │ │ │ └── prometheus.yml │ │ │ └── dashboards │ │ │ │ └── dashboard.yml │ │ └── grafana.ini │ ├── config │ │ └── config.yaml │ ├── compose.yaml │ └── README.md ├── docker-compose-multiple-config-files │ ├── config │ │ ├── global.yaml │ │ ├── frontend.yaml │ │ └── backend.yaml │ └── compose.yaml ├── docker-compose-mtls │ ├── config │ │ └── config.yaml │ ├── nginx │ │ └── default.conf │ ├── compose.yaml │ └── certs │ │ ├── server │ │ ├── ca.crt │ │ ├── server.crt │ │ └── server.key │ │ └── client │ │ ├── client.crt │ │ └── client.key ├── docker-compose-mattermost │ ├── config │ │ └── config.yaml │ └── compose.yaml ├── docker-compose-postgres-storage │ ├── compose.yaml │ └── config │ │ └── config.yaml └── kubernetes │ └── gatus.yaml ├── testdata ├── badcert.key ├── badcert.pem ├── cert.key └── cert.pem ├── web ├── app │ ├── src │ │ ├── components │ │ │ ├── ui │ │ │ │ ├── badge │ │ │ │ │ ├── index.js │ │ │ │ │ └── Badge.vue │ │ │ │ ├── input │ │ │ │ │ ├── index.js │ │ │ │ │ └── Input.vue │ │ │ │ ├── button │ │ │ │ │ ├── index.js │ │ │ │ │ └── Button.vue │ │ │ │ ├── select │ │ │ │ │ └── index.js │ │ │ │ └── card │ │ │ │ │ ├── CardContent.vue │ │ │ │ │ ├── index.js │ │ │ │ │ ├── CardHeader.vue │ │ │ │ │ ├── CardTitle.vue │ │ │ │ │ └── Card.vue │ │ │ ├── Loading.vue │ │ │ ├── Social.vue │ │ │ ├── StatusBadge.vue │ │ │ ├── Pagination.vue │ │ │ └── SearchBar.vue │ │ ├── main.js │ │ ├── utils │ │ │ ├── misc.js │ │ │ ├── format.js │ │ │ ├── markdown.js │ │ │ └── time.js │ │ ├── router │ │ │ └── index.js │ │ ├── assets │ │ │ └── logo.svg │ │ └── index.css │ ├── public │ │ ├── favicon.ico │ │ ├── logo-192x192.png │ │ ├── logo-512x512.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── manifest.json │ │ └── index.html │ ├── babel.config.js │ ├── postcss.config.js │ ├── .gitignore │ ├── README.md │ ├── vue.config.js │ ├── package.json │ └── tailwind.config.js ├── static │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── logo-192x192.png │ ├── logo-512x512.png │ ├── apple-touch-icon.png │ ├── manifest.json │ ├── img │ │ └── logo.svg │ └── index.html ├── static.go └── static_test.go ├── .dockerignore ├── security ├── sessions.go ├── basic.go ├── basic_test.go └── oidc_test.go ├── config ├── key │ ├── key_bench_test.go │ ├── key.go │ └── key_test.go ├── endpoint │ ├── condition_result.go │ ├── event_test.go │ ├── result_test.go │ ├── status_test.go │ ├── heartbeat │ │ └── heartbeat.go │ ├── dns │ │ ├── dns_test.go │ │ └── dns.go │ ├── uptime.go │ ├── common.go │ ├── event.go │ ├── status.go │ ├── ssh │ │ ├── ssh_test.go │ │ └── ssh.go │ ├── common_test.go │ ├── ui │ │ ├── ui_test.go │ │ └── ui.go │ ├── result.go │ └── condition_bench_test.go ├── util.go ├── suite │ ├── suite_status.go │ └── result.go ├── remote │ └── remote.go ├── connectivity │ ├── connectivity.go │ └── connectivity_test.go ├── tunneling │ └── tunneling.go ├── announcement │ └── announcement.go ├── gontext │ └── gontext.go └── web │ └── web.go ├── .gitignore ├── test └── mock.go ├── storage ├── type.go ├── store │ ├── common │ │ ├── errors.go │ │ └── paging │ │ │ ├── suite_status_params.go │ │ │ ├── endpoint_status_params.go │ │ │ ├── endpoint_status_params_test.go │ │ │ └── suite_status_params_test.go │ └── memory │ │ ├── util_bench_test.go │ │ ├── uptime_bench_test.go │ │ └── uptime.go └── config.go ├── api ├── cache.go ├── custom_css.go ├── util.go ├── config.go ├── config_test.go ├── spa.go ├── suite_status.go ├── util_test.go ├── raw.go ├── spa_test.go └── api_test.go ├── jsonpath └── jsonpath_bench_test.go ├── pattern ├── pattern_bench_test.go ├── pattern.go └── pattern_test.go ├── alerting └── provider │ └── incidentio │ └── dedup.go ├── Dockerfile ├── Makefile ├── controller ├── controller.go └── controller_test.go ├── config.yaml ├── client └── grpc.go └── watchdog ├── watchdog.go ├── suite.go └── endpoint.go /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TwiN] 2 | -------------------------------------------------------------------------------- /.examples/docker-compose-sqlite-storage/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/badcert.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | wat 3 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /testdata/badcert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | wat 3 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /web/app/src/components/ui/badge/index.js: -------------------------------------------------------------------------------- 1 | export { default as Badge } from './Badge.vue' -------------------------------------------------------------------------------- /web/app/src/components/ui/input/index.js: -------------------------------------------------------------------------------- 1 | export { default as Input } from './Input.vue' -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/logo.png -------------------------------------------------------------------------------- /web/app/src/components/ui/button/index.js: -------------------------------------------------------------------------------- 1 | export { default as Button } from './Button.vue' -------------------------------------------------------------------------------- /web/app/src/components/ui/select/index.js: -------------------------------------------------------------------------------- 1 | export { default as Select } from './Select.vue' -------------------------------------------------------------------------------- /web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/static/favicon.ico -------------------------------------------------------------------------------- /.github/assets/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/example.jpg -------------------------------------------------------------------------------- /web/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/app/public/favicon.ico -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .examples 2 | Dockerfile 3 | .github 4 | .idea 5 | .git 6 | web/app 7 | *.db 8 | testdata -------------------------------------------------------------------------------- /.examples/docker-minimal/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM twinproduction/gatus 2 | ADD config.yaml ./config/config.yaml -------------------------------------------------------------------------------- /web/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/static/favicon-16x16.png -------------------------------------------------------------------------------- /web/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/static/favicon-32x32.png -------------------------------------------------------------------------------- /web/static/logo-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/static/logo-192x192.png -------------------------------------------------------------------------------- /web/static/logo-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/static/logo-512x512.png -------------------------------------------------------------------------------- /.github/assets/gitea-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/gitea-alerts.png -------------------------------------------------------------------------------- /.github/assets/slack-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/slack-alerts.png -------------------------------------------------------------------------------- /.github/assets/teams-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/teams-alerts.png -------------------------------------------------------------------------------- /web/app/public/logo-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/app/public/logo-192x192.png -------------------------------------------------------------------------------- /web/app/public/logo-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/app/public/logo-512x512.png -------------------------------------------------------------------------------- /web/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/static/apple-touch-icon.png -------------------------------------------------------------------------------- /.github/assets/dashboard-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/dashboard-dark.jpg -------------------------------------------------------------------------------- /.github/assets/gatus-diagram.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/gatus-diagram.jpg -------------------------------------------------------------------------------- /.github/assets/github-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/github-alerts.png -------------------------------------------------------------------------------- /.github/assets/gitlab-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/gitlab-alerts.png -------------------------------------------------------------------------------- /.github/assets/gotify-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/gotify-alerts.png -------------------------------------------------------------------------------- /web/app/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/app/public/favicon-16x16.png -------------------------------------------------------------------------------- /web/app/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/app/public/favicon-32x32.png -------------------------------------------------------------------------------- /.github/assets/endpoint-groups.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/endpoint-groups.jpg -------------------------------------------------------------------------------- /.github/assets/grafana-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/grafana-dashboard.png -------------------------------------------------------------------------------- /.github/assets/mattermost-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/mattermost-alerts.png -------------------------------------------------------------------------------- /.github/assets/telegram-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/telegram-alerts.png -------------------------------------------------------------------------------- /web/app/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /web/app/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/web/app/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.github/assets/logo-with-dark-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/logo-with-dark-text.png -------------------------------------------------------------------------------- /.github/assets/past-announcements.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/past-announcements.jpg -------------------------------------------------------------------------------- /.github/assets/dashboard-conditions.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/dashboard-conditions.jpg -------------------------------------------------------------------------------- /.github/assets/teams-workflows-alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/teams-workflows-alerts.png -------------------------------------------------------------------------------- /.examples/nixos/README.md: -------------------------------------------------------------------------------- 1 | # NixOS 2 | 3 | Gatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example 4 | usage. 5 | -------------------------------------------------------------------------------- /.github/assets/pagerduty-integration-key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwiN/gatus/HEAD/.github/assets/pagerduty-integration-key.png -------------------------------------------------------------------------------- /.examples/docker-minimal/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: example 3 | url: https://example.org 4 | interval: 30s 5 | conditions: 6 | - "[STATUS] == 200" 7 | -------------------------------------------------------------------------------- /.examples/docker-compose/config/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: example 3 | url: https://example.org 4 | interval: 30s 5 | conditions: 6 | - "[STATUS] == 200" 7 | -------------------------------------------------------------------------------- /.examples/docker-compose/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gatus: 3 | image: twinproduction/gatus:latest 4 | ports: 5 | - 8080:8080 6 | volumes: 7 | - ./config:/config 8 | -------------------------------------------------------------------------------- /web/app/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import './index.css' 4 | import router from './router' 5 | 6 | createApp(App).use(router).mount('#app') 7 | -------------------------------------------------------------------------------- /web/app/src/utils/misc.js: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function combineClasses(...inputs) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /web/app/postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | tailwindcss('./tailwind.config.js'), 6 | require('autoprefixer'), 7 | ], 8 | }; -------------------------------------------------------------------------------- /security/sessions.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import "github.com/TwiN/gocache/v2" 4 | 5 | var sessions = gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed) // TODO: Move this to storage 6 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: gatus 3 | scrape_interval: 10s 4 | static_configs: 5 | - targets: 6 | - gatus:8080 7 | -------------------------------------------------------------------------------- /.examples/docker-compose-multiple-config-files/config/global.yaml: -------------------------------------------------------------------------------- 1 | metrics: true 2 | ui: 3 | header: Example Company 4 | link: https://example.org 5 | buttons: 6 | - name: "Home" 7 | link: "https://example.org" 8 | -------------------------------------------------------------------------------- /.examples/docker-compose-sqlite-storage/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gatus: 3 | image: twinproduction/gatus:latest 4 | ports: 5 | - "8080:8080" 6 | volumes: 7 | - ./config:/config 8 | - ./data:/data/ 9 | -------------------------------------------------------------------------------- /web/static.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import "embed" 4 | 5 | var ( 6 | //go:embed static 7 | FileSystem embed.FS 8 | ) 9 | 10 | const ( 11 | RootPath = "static" 12 | IndexPath = RootPath + "/index.html" 13 | ) 14 | -------------------------------------------------------------------------------- /config/key/key_bench_test.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func BenchmarkConvertGroupAndNameToKey(b *testing.B) { 8 | for n := 0; n < b.N; n++ { 9 | ConvertGroupAndNameToKey("group", "name") 10 | } 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | *.iml 3 | .idea 4 | .vscode 5 | 6 | # OS 7 | .DS_Store 8 | 9 | # JS 10 | node_modules 11 | 12 | # Go 13 | /vendor 14 | 15 | # Misc 16 | *.db 17 | *.db-shm 18 | *.db-wal 19 | gatus 20 | config/config.yml 21 | config.yaml -------------------------------------------------------------------------------- /test/mock.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import "net/http" 4 | 5 | type MockRoundTripper func(r *http.Request) *http.Response 6 | 7 | func (f MockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { 8 | return f(r), nil 9 | } 10 | -------------------------------------------------------------------------------- /web/app/src/components/ui/card/CardContent.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /.examples/docker-compose-multiple-config-files/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gatus: 3 | image: twinproduction/gatus:latest 4 | ports: 5 | - "8080:8080" 6 | environment: 7 | - GATUS_CONFIG_PATH=/config 8 | volumes: 9 | - ./config:/config -------------------------------------------------------------------------------- /web/app/src/components/ui/card/index.js: -------------------------------------------------------------------------------- 1 | export { default as Card } from './Card.vue' 2 | export { default as CardHeader } from './CardHeader.vue' 3 | export { default as CardTitle } from './CardTitle.vue' 4 | export { default as CardContent } from './CardContent.vue' -------------------------------------------------------------------------------- /testdata/cert.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJh67FWpz8wrN1mM/ 3 | CebkZN0zF83691ZVD83XlbNLRUqhRANCAAScfyPxScqz+Z/yNtAID/FOORy9J6LM 4 | DUAJevGDvAZCMp/nh+Ps3nLrMoRlykcux3mq+N8HPlJ8R3eetB4S1tHY 5 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /web/app/src/components/ui/card/CardHeader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /storage/type.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | // Type of the store. 4 | type Type string 5 | 6 | const ( 7 | TypeMemory Type = "memory" // In-memory store 8 | TypeSQLite Type = "sqlite" // SQLite store 9 | TypePostgres Type = "postgres" // Postgres store 10 | ) 11 | -------------------------------------------------------------------------------- /api/cache.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/TwiN/gocache/v2" 7 | ) 8 | 9 | const ( 10 | cacheTTL = 10 * time.Second 11 | ) 12 | 13 | var ( 14 | cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut) 15 | ) 16 | -------------------------------------------------------------------------------- /web/app/src/components/ui/card/CardTitle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /web/app/src/components/ui/card/Card.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/grafana/provisioning/datasources/prometheus.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | isDefault: true 9 | version: 1 10 | editable: false 11 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'Prometheus' 5 | orgId: 1 6 | folder: '' 7 | type: file 8 | disableDeletion: false 9 | editable: true 10 | options: 11 | path: /etc/grafana/provisioning/dashboards -------------------------------------------------------------------------------- /.examples/docker-compose-multiple-config-files/config/frontend.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: make-sure-html-rendering-works 3 | group: frontend 4 | url: "https://example.org" 5 | interval: 5m 6 | conditions: 7 | - "[STATUS] == 200" 8 | - "[BODY] == pat(*

Example Domain

*)" # Check for header in HTML page 9 | -------------------------------------------------------------------------------- /api/custom_css.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | ) 6 | 7 | type CustomCSSHandler struct { 8 | customCSS string 9 | } 10 | 11 | func (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error { 12 | c.Set("Content-Type", "text/css") 13 | return c.Status(200).SendString(handler.customCSS) 14 | } 15 | -------------------------------------------------------------------------------- /config/endpoint/condition_result.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | // ConditionResult result of a Condition 4 | type ConditionResult struct { 5 | // Condition that was evaluated 6 | Condition string `json:"condition"` 7 | 8 | // Success whether the condition was met (successful) or not (failed) 9 | Success bool `json:"success"` 10 | } 11 | -------------------------------------------------------------------------------- /web/app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | labels: ["dependencies"] 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | open-pull-requests-limit: 3 11 | labels: ["dependencies"] 12 | schedule: 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/config/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: example 3 | url: https://nginx 4 | interval: 30s 5 | conditions: 6 | - "[STATUS] == 200" 7 | client: 8 | # mtls 9 | insecure: true 10 | tls: 11 | certificate-file: /certs/client.crt 12 | private-key-file: /certs/client.key 13 | renegotiation: once -------------------------------------------------------------------------------- /config/util.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // toPtr returns a pointer to the given value 4 | func toPtr[T any](value T) *T { 5 | return &value 6 | } 7 | 8 | // contains checks if a key exists in the slice 9 | func contains[T comparable](slice []T, key T) bool { 10 | for _, item := range slice { 11 | if item == key { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "storage/store/sql/specific_postgres.go" # Can't test for postgres 3 | - "watchdog/endpoint.go" 4 | - "watchdog/external_endpoint.go" 5 | - "watchdog/suite.go" 6 | - "watchdog/watchdog.go" 7 | comment: false 8 | coverage: 9 | status: 10 | patch: off 11 | project: 12 | default: 13 | target: 70% 14 | threshold: null 15 | 16 | -------------------------------------------------------------------------------- /jsonpath/jsonpath_bench_test.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import "testing" 4 | 5 | func BenchmarkEval(b *testing.B) { 6 | for i := 0; i < b.N; i++ { 7 | Eval("ids[0]", []byte(`{"ids": [1, 2]}`)) 8 | Eval("long.simple.walk", []byte(`{"long": {"simple": {"walk": "value"}}}`)) 9 | Eval("data[0].apps[1].name", []byte(`{"data": [{"apps": [{"name":"app1"}, {"name":"app2"}, {"name":"app3"}]}]}`)) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/test-ui.yml: -------------------------------------------------------------------------------- 1 | name: test-ui 2 | on: 3 | pull_request: 4 | paths: 5 | - 'web/**' 6 | push: 7 | branches: 8 | - master 9 | paths: 10 | - 'web/**' 11 | jobs: 12 | test-ui: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 30 15 | steps: 16 | - uses: actions/checkout@v5 17 | - run: make frontend-install-dependencies 18 | - run: make frontend-build -------------------------------------------------------------------------------- /web/app/README.md: -------------------------------------------------------------------------------- 1 | # app 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /storage/store/common/errors.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrEndpointNotFound = errors.New("endpoint not found") // When an endpoint does not exist in the store 7 | ErrSuiteNotFound = errors.New("suite not found") // When a suite does not exist in the store 8 | ErrInvalidTimeRange = errors.New("'from' cannot be older than 'to'") // When an invalid time range is provided 9 | ) 10 | -------------------------------------------------------------------------------- /config/endpoint/event_test.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewEventFromResult(t *testing.T) { 8 | if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy { 9 | t.Error("expected event.Type to be EventHealthy") 10 | } 11 | if event := NewEventFromResult(&Result{Success: false}); event.Type != EventUnhealthy { 12 | t.Error("expected event.Type to be EventUnhealthy") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.examples/docker-compose-mattermost/config/config.yaml: -------------------------------------------------------------------------------- 1 | alerting: 2 | mattermost: 3 | webhook-url: "http://mattermost:8065/hooks/tokengoeshere" 4 | insecure: true 5 | 6 | endpoints: 7 | - name: example 8 | url: https://example.org 9 | interval: 1m 10 | conditions: 11 | - "[STATUS] == 200" 12 | alerts: 13 | - type: mattermost 14 | description: "health check failed 3 times in a row" 15 | send-on-resolved: true 16 | -------------------------------------------------------------------------------- /pattern/pattern_bench_test.go: -------------------------------------------------------------------------------- 1 | package pattern 2 | 3 | import "testing" 4 | 5 | func BenchmarkMatch(b *testing.B) { 6 | for n := 0; n < b.N; n++ { 7 | if !Match("*ing*", "livingroom") { 8 | b.Error("should've matched") 9 | } 10 | } 11 | b.ReportAllocs() 12 | } 13 | 14 | func BenchmarkMatchWithBackslash(b *testing.B) { 15 | for n := 0; n < b.N; n++ { 16 | if !Match("*ing*", "living\\room") { 17 | b.Error("should've matched") 18 | } 19 | } 20 | b.ReportAllocs() 21 | } 22 | -------------------------------------------------------------------------------- /.examples/nixos/gatus.nix: -------------------------------------------------------------------------------- 1 | { 2 | services.gatus = { 3 | enable = true; 4 | 5 | settings = { 6 | web.port = 8080; 7 | 8 | endpoints = [ 9 | { 10 | name = "website"; 11 | url = "https://twin.sh/health"; 12 | interval = "5m"; 13 | 14 | conditions = [ 15 | "[STATUS] == 200" 16 | "[BODY].status == UP" 17 | "[RESPONSE_TIME] < 300" 18 | ]; 19 | } 20 | ]; 21 | }; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /.examples/docker-compose-mattermost/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gatus: 3 | container_name: gatus 4 | image: twinproduction/gatus:latest 5 | ports: 6 | - "8080:8080" 7 | volumes: 8 | - ./config:/config 9 | networks: 10 | - default 11 | 12 | mattermost: 13 | container_name: mattermost 14 | image: mattermost/mattermost-preview:5.26.0 15 | ports: 16 | - "8065:8065" 17 | networks: 18 | - default 19 | 20 | networks: 21 | default: 22 | driver: bridge 23 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | 4 | ssl_certificate /etc/nginx/certs/server.crt; 5 | ssl_certificate_key /etc/nginx/certs/server.key; 6 | ssl_client_certificate /etc/nginx/certs/ca.crt; 7 | ssl_verify_client on; 8 | 9 | location / { 10 | if ($ssl_client_verify != SUCCESS) { 11 | return 403; 12 | } 13 | root /usr/share/nginx/html; 14 | index index.html index.htm; 15 | } 16 | } -------------------------------------------------------------------------------- /config/endpoint/result_test.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestResult_AddError(t *testing.T) { 8 | result := &Result{} 9 | result.AddError("potato") 10 | if len(result.Errors) != 1 { 11 | t.Error("should've had 1 error") 12 | } 13 | result.AddError("potato") 14 | if len(result.Errors) != 1 { 15 | t.Error("should've still had 1 error, because a duplicate error was added") 16 | } 17 | result.AddError("tomato") 18 | if len(result.Errors) != 2 { 19 | t.Error("should've had 2 error") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/endpoint/status_test.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewEndpointStatus(t *testing.T) { 8 | ep := &Endpoint{Name: "name", Group: "group"} 9 | status := NewStatus(ep.Group, ep.Name) 10 | if status.Name != ep.Name { 11 | t.Errorf("expected %s, got %s", ep.Name, status.Name) 12 | } 13 | if status.Group != ep.Group { 14 | t.Errorf("expected %s, got %s", ep.Group, status.Group) 15 | } 16 | if status.Key != "group_name" { 17 | t.Errorf("expected %s, got %s", "group_name", status.Key) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /testdata/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBaDCCAQ2gAwIBAgICBNIwCgYIKoZIzj0EAwIwFTETMBEGA1UEChMKR2F0dXMg 3 | dGVzdDAgFw0yMzA0MjIxODUwMDVaGA8yMjk3MDIwNDE4NTAwNVowFTETMBEGA1UE 4 | ChMKR2F0dXMgdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJx/I/FJyrP5 5 | n/I20AgP8U45HL0noswNQAl68YO8BkIyn+eH4+zecusyhGXKRy7Hear43wc+UnxH 6 | d560HhLW0dijSzBJMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcD 7 | ATAMBgNVHRMBAf8EAjAAMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAKBggqhkjOPQQD 8 | AgNJADBGAiEA/SdthKOoNw3azSHuPid7XJsXYB8DisIC9LBwcb/QTMECIQCAB36Y 9 | OI15ao+J/RUz2sXdPXCAN8hlohi6OnmZmJB32g== 10 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /web/app/src/utils/format.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a duration from nanoseconds to a human-readable string 3 | * @param {number} duration - Duration in nanoseconds 4 | * @returns {string} Formatted duration string (e.g., "123ms", "1.23s") 5 | */ 6 | export const formatDuration = (duration) => { 7 | if (!duration && duration !== 0) return 'N/A' 8 | 9 | // Convert nanoseconds to milliseconds 10 | const durationMs = duration / 1000000 11 | 12 | if (durationMs < 1000) { 13 | return `${Math.trunc(durationMs)}ms` 14 | } else { 15 | return `${(durationMs / 1000).toFixed(2)}s` 16 | } 17 | } -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | image: nginx:stable 4 | volumes: 5 | - ./certs/server:/etc/nginx/certs 6 | - ./nginx:/etc/nginx/conf.d 7 | ports: 8 | - "8443:443" 9 | networks: 10 | - mtls 11 | 12 | gatus: 13 | image: twinproduction/gatus:latest 14 | restart: always 15 | ports: 16 | - "8080:8080" 17 | volumes: 18 | - ./config:/config 19 | - ./certs/client:/certs 20 | environment: 21 | - GATUS_CONFIG_PATH=/config 22 | networks: 23 | - mtls 24 | 25 | networks: 26 | mtls: 27 | -------------------------------------------------------------------------------- /config/endpoint/heartbeat/heartbeat.go: -------------------------------------------------------------------------------- 1 | package heartbeat 2 | 3 | import "time" 4 | 5 | // Config used to check if the external endpoint has received new results when it should have. 6 | // This configuration is used to trigger alerts when an external endpoint has no new results for a defined period of time 7 | type Config struct { 8 | // Interval is the time interval at which Gatus verifies whether the external endpoint has received new results 9 | // If no new result is received within the interval, the endpoint is marked as failed and alerts are triggered 10 | Interval time.Duration `yaml:"interval"` 11 | } 12 | -------------------------------------------------------------------------------- /alerting/provider/incidentio/dedup.go: -------------------------------------------------------------------------------- 1 | package incidentio 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/TwiN/gatus/v5/alerting/alert" 10 | "github.com/TwiN/gatus/v5/config/endpoint" 11 | ) 12 | 13 | // generateDeduplicationKey generates a unique deduplication_key for incident.io 14 | func generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string { 15 | data := fmt.Sprintf("%s|%s|%s|%d", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano()) 16 | hash := sha256.Sum256([]byte(data)) 17 | return hex.EncodeToString(hash[:]) 18 | } 19 | -------------------------------------------------------------------------------- /.examples/docker-compose-multiple-config-files/config/backend.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: check-if-api-is-healthy 3 | group: backend 4 | url: "https://twin.sh/health" 5 | interval: 5m 6 | conditions: 7 | - "[STATUS] == 200" 8 | - "[BODY].status == UP" 9 | - "[RESPONSE_TIME] < 1000" 10 | 11 | - name: check-if-website-is-pingable 12 | url: "icmp://example.org" 13 | interval: 1m 14 | conditions: 15 | - "[CONNECTED] == true" 16 | 17 | - name: check-domain-expiration 18 | url: "https://example.org" 19 | interval: 6h 20 | conditions: 21 | - "[DOMAIN_EXPIRATION] > 720h" 22 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/config/config.yaml: -------------------------------------------------------------------------------- 1 | metrics: true 2 | endpoints: 3 | - name: website 4 | url: https://twin.sh/health 5 | interval: 5m 6 | conditions: 7 | - "[STATUS] == 200" 8 | 9 | - name: example 10 | url: https://example.com/ 11 | interval: 5m 12 | conditions: 13 | - "[STATUS] == 200" 14 | 15 | - name: github 16 | url: https://api.github.com/healthz 17 | interval: 5m 18 | conditions: 19 | - "[STATUS] == 200" 20 | 21 | - name: check-domain-expiration 22 | url: "https://example.org/" 23 | interval: 1h 24 | conditions: 25 | - "[DOMAIN_EXPIRATION] > 720h" -------------------------------------------------------------------------------- /config/key/key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import "strings" 4 | 5 | // ConvertGroupAndNameToKey converts a group and a name to a key 6 | func ConvertGroupAndNameToKey(groupName, name string) string { 7 | return sanitize(groupName) + "_" + sanitize(name) 8 | } 9 | 10 | func sanitize(s string) string { 11 | s = strings.TrimSpace(strings.ToLower(s)) 12 | s = strings.ReplaceAll(s, "/", "-") 13 | s = strings.ReplaceAll(s, "_", "-") 14 | s = strings.ReplaceAll(s, ".", "-") 15 | s = strings.ReplaceAll(s, ",", "-") 16 | s = strings.ReplaceAll(s, " ", "-") 17 | s = strings.ReplaceAll(s, "#", "-") 18 | s = strings.ReplaceAll(s, "+", "-") 19 | s = strings.ReplaceAll(s, "&", "-") 20 | return s 21 | } 22 | -------------------------------------------------------------------------------- /storage/store/common/paging/suite_status_params.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | // SuiteStatusParams represents the parameters for suite status queries 4 | type SuiteStatusParams struct { 5 | Page int // Page number 6 | PageSize int // Number of results per page 7 | } 8 | 9 | // NewSuiteStatusParams creates a new SuiteStatusParams 10 | func NewSuiteStatusParams() *SuiteStatusParams { 11 | return &SuiteStatusParams{ 12 | Page: 1, 13 | PageSize: 20, 14 | } 15 | } 16 | 17 | // WithPagination sets the page and page size 18 | func (params *SuiteStatusParams) WithPagination(page, pageSize int) *SuiteStatusParams { 19 | params.Page = page 20 | params.PageSize = pageSize 21 | return params 22 | } -------------------------------------------------------------------------------- /web/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gatus", 3 | "name": "Gatus", 4 | "short_name": "Gatus", 5 | "description": "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue", 6 | "lang": "en", 7 | "scope": "/", 8 | "start_url": "/", 9 | "theme_color": "#f7f9fb", 10 | "background_color": "#f7f9fb", 11 | "display": "standalone", 12 | "icons": [ 13 | { 14 | "src": "/logo-192x192.png", 15 | "sizes": "192x192", 16 | "type": "image/png" 17 | }, 18 | { 19 | "src": "/logo-512x512.png", 20 | "sizes": "512x512", 21 | "type": "image/png" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /pattern/pattern.go: -------------------------------------------------------------------------------- 1 | package pattern 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | // Match checks whether a string matches a pattern 9 | func Match(pattern, s string) bool { 10 | if pattern == "*" { 11 | return true 12 | } 13 | // Separators found in the string break filepath.Match, so we'll remove all of them. 14 | // This has a pretty significant impact on performance when there are separators in 15 | // the strings, but at least it doesn't break filepath.Match. 16 | s = strings.ReplaceAll(s, string(filepath.Separator), "") 17 | pattern = strings.ReplaceAll(pattern, string(filepath.Separator), "") 18 | matched, _ := filepath.Match(pattern, s) 19 | return matched 20 | } 21 | -------------------------------------------------------------------------------- /web/app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gatus", 3 | "name": "Gatus", 4 | "short_name": "Gatus", 5 | "description": "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue", 6 | "lang": "en", 7 | "scope": "/", 8 | "start_url": "/", 9 | "theme_color": "#f7f9fb", 10 | "background_color": "#f7f9fb", 11 | "display": "standalone", 12 | "icons": [ 13 | { 14 | "src": "/logo-192x192.png", 15 | "sizes": "192x192", 16 | "type": "image/png" 17 | }, 18 | { 19 | "src": "/logo-512x512.png", 20 | "sizes": "512x512", 21 | "type": "image/png" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /security/basic.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | // BasicConfig is the configuration for Basic authentication 4 | type BasicConfig struct { 5 | // Username is the name which will need to be used for a successful authentication 6 | Username string `yaml:"username"` 7 | 8 | // PasswordBcryptHashBase64Encoded is the base64 encoded string of the Bcrypt hash of the password to use to 9 | // authenticate using basic auth. 10 | PasswordBcryptHashBase64Encoded string `yaml:"password-bcrypt-base64"` 11 | } 12 | 13 | // isValid returns whether the basic security configuration is valid or not 14 | func (c *BasicConfig) isValid() bool { 15 | return len(c.Username) > 0 && len(c.PasswordBcryptHashBase64Encoded) > 0 16 | } 17 | -------------------------------------------------------------------------------- /config/suite/suite_status.go: -------------------------------------------------------------------------------- 1 | package suite 2 | 3 | // Status represents the status of a suite 4 | type Status struct { 5 | // Name of the suite 6 | Name string `json:"name,omitempty"` 7 | 8 | // Group the suite is a part of. Used for grouping multiple suites together on the front end. 9 | Group string `json:"group,omitempty"` 10 | 11 | // Key of the Suite 12 | Key string `json:"key"` 13 | 14 | // Results is the list of suite execution results 15 | Results []*Result `json:"results"` 16 | } 17 | 18 | // NewStatus creates a new Status for a given Suite 19 | func NewStatus(s *Suite) *Status { 20 | return &Status{ 21 | Name: s.Name, 22 | Group: s.Group, 23 | Key: s.Key(), 24 | Results: []*Result{}, 25 | } 26 | } -------------------------------------------------------------------------------- /storage/store/memory/util_bench_test.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/TwiN/gatus/v5/config/endpoint" 7 | "github.com/TwiN/gatus/v5/storage" 8 | "github.com/TwiN/gatus/v5/storage/store/common/paging" 9 | ) 10 | 11 | func BenchmarkShallowCopyEndpointStatus(b *testing.B) { 12 | ep := &testEndpoint 13 | status := endpoint.NewStatus(ep.Group, ep.Name) 14 | for i := 0; i < storage.DefaultMaximumNumberOfResults; i++ { 15 | AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents) 16 | } 17 | for n := 0; n < b.N; n++ { 18 | ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20)) 19 | } 20 | b.ReportAllocs() 21 | } 22 | -------------------------------------------------------------------------------- /security/basic_test.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import "testing" 4 | 5 | func TestBasicConfig_IsValidUsingBcrypt(t *testing.T) { 6 | basicConfig := &BasicConfig{ 7 | Username: "admin", 8 | PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT", 9 | } 10 | if !basicConfig.isValid() { 11 | t.Error("basicConfig should've been valid") 12 | } 13 | } 14 | 15 | func TestBasicConfig_IsValidWhenPasswordIsInvalidUsingBcrypt(t *testing.T) { 16 | basicConfig := &BasicConfig{ 17 | Username: "admin", 18 | PasswordBcryptHashBase64Encoded: "", 19 | } 20 | if basicConfig.isValid() { 21 | t.Error("basicConfig shouldn't have been valid") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/app/vue.config.js: -------------------------------------------------------------------------------- 1 | // Note: The fs.Stats deprecation warning is from Vue CLI's webpack dependencies 2 | // which are not yet compatible with Node.js v23. This is suppressed in the build 3 | // script. All user dependencies have been updated to their latest versions. 4 | // Consider migrating to Vite for better Node.js v23+ compatibility. 5 | module.exports = { 6 | filenameHashing: false, 7 | productionSourceMap: false, 8 | outputDir: '../static', 9 | publicPath: '/', 10 | devServer: { 11 | port: 8081, 12 | https: false, 13 | client: { 14 | webSocketURL:'auto://0.0.0.0/ws' 15 | }, 16 | proxy: { 17 | '^/api|^/css|^/oicd': { 18 | target: "http://localhost:8080", 19 | changeOrigin: true, 20 | secure: false, 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.examples/docker-compose-postgres-storage/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres 4 | volumes: 5 | - ./data/db:/var/lib/postgresql/data 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | - POSTGRES_DB=gatus 10 | - POSTGRES_USER=username 11 | - POSTGRES_PASSWORD=password 12 | networks: 13 | - web 14 | 15 | gatus: 16 | image: twinproduction/gatus:latest 17 | restart: always 18 | ports: 19 | - "8080:8080" 20 | environment: 21 | - POSTGRES_USER=username 22 | - POSTGRES_PASSWORD=password 23 | - POSTGRES_DB=gatus 24 | volumes: 25 | - ./config:/config 26 | networks: 27 | - web 28 | depends_on: 29 | - postgres 30 | 31 | networks: 32 | web: 33 | -------------------------------------------------------------------------------- /web/app/src/router/index.js: -------------------------------------------------------------------------------- 1 | import {createRouter, createWebHistory} from 'vue-router' 2 | import Home from '@/views/Home' 3 | import EndpointDetails from "@/views/EndpointDetails"; 4 | import SuiteDetails from '@/views/SuiteDetails'; 5 | 6 | const routes = [ 7 | { 8 | path: '/', 9 | name: 'Home', 10 | component: Home 11 | }, 12 | { 13 | path: '/endpoints/:key', 14 | name: 'EndpointDetails', 15 | component: EndpointDetails, 16 | }, 17 | { 18 | path: '/suites/:key', 19 | name: 'SuiteDetails', 20 | component: SuiteDetails 21 | } 22 | ]; 23 | 24 | const router = createRouter({ 25 | history: createWebHistory(process.env.BASE_URL), 26 | routes 27 | }); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /storage/store/memory/uptime_bench_test.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/TwiN/gatus/v5/config/endpoint" 8 | ) 9 | 10 | func BenchmarkProcessUptimeAfterResult(b *testing.B) { 11 | uptime := endpoint.NewUptime() 12 | now := time.Now() 13 | now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) 14 | // Start 12000 days ago 15 | timestamp := now.Add(-12000 * 24 * time.Hour) 16 | for n := 0; n < b.N; n++ { 17 | processUptimeAfterResult(uptime, &endpoint.Result{ 18 | Duration: 18 * time.Millisecond, 19 | Success: n%15 == 0, 20 | Timestamp: timestamp, 21 | }) 22 | // Simulate an endpoint with an interval of 3 minutes 23 | timestamp = timestamp.Add(3 * time.Minute) 24 | } 25 | b.ReportAllocs() 26 | } 27 | -------------------------------------------------------------------------------- /config/endpoint/dns/dns_test.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestConfig_ValidateAndSetDefault(t *testing.T) { 8 | dns := &Config{ 9 | QueryType: "A", 10 | QueryName: "", 11 | } 12 | err := dns.ValidateAndSetDefault() 13 | if err == nil { 14 | t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns") 15 | } 16 | } 17 | 18 | func TestConfig_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) { 19 | dns := &Config{ 20 | QueryType: "B", 21 | QueryName: "example.com", 22 | } 23 | err := dns.ValidateAndSetDefault() 24 | if err == nil { 25 | t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the go application into a binary 2 | FROM golang:alpine AS builder 3 | RUN apk --update add ca-certificates 4 | WORKDIR /app 5 | COPY . ./ 6 | RUN go mod tidy -diff 7 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus . 8 | 9 | # Run Tests inside docker image if you don't have a configured go environment 10 | #RUN apk update && apk add --virtual build-dependencies build-base gcc 11 | #RUN go test ./... -mod vendor 12 | 13 | # Run the binary on an empty container 14 | FROM scratch 15 | COPY --from=builder /app/gatus . 16 | COPY --from=builder /app/config.yaml ./config/config.yaml 17 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 18 | ENV GATUS_CONFIG_PATH="" 19 | ENV GATUS_LOG_LEVEL="INFO" 20 | ENV PORT="8080" 21 | EXPOSE ${PORT} 22 | ENTRYPOINT ["/gatus"] 23 | -------------------------------------------------------------------------------- /web/app/src/components/Loading.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /web/app/src/components/ui/input/Input.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY=gatus 2 | 3 | .PHONY: install 4 | install: 5 | go build -v -o $(BINARY) . 6 | 7 | .PHONY: run 8 | run: 9 | ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go 10 | 11 | .PHONY: run-binary 12 | run-binary: 13 | ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY) 14 | 15 | .PHONY: clean 16 | clean: 17 | rm $(BINARY) 18 | 19 | .PHONY: test 20 | test: 21 | go test ./... -cover 22 | 23 | 24 | ########## 25 | # Docker # 26 | ########## 27 | 28 | docker-build: 29 | docker build -t twinproduction/gatus:latest . 30 | 31 | docker-run: 32 | docker run -p 8080:8080 --name gatus twinproduction/gatus:latest 33 | 34 | docker-build-and-run: docker-build docker-run 35 | 36 | 37 | ############# 38 | # Front end # 39 | ############# 40 | 41 | frontend-install-dependencies: 42 | npm --prefix web/app install 43 | 44 | frontend-build: 45 | npm --prefix web/app run build 46 | 47 | frontend-run: 48 | npm --prefix web/app run serve 49 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: benchmark 2 | on: 3 | workflow_run: 4 | workflows: [publish-latest] 5 | branches: [master] 6 | types: [completed] 7 | workflow_dispatch: 8 | inputs: 9 | repository: 10 | description: "Repository to checkout. Useful for benchmarking a fork. Format should be /." 11 | required: true 12 | default: "TwiN/gatus" 13 | ref: 14 | description: "Branch, tag or SHA to checkout" 15 | required: true 16 | default: "master" 17 | jobs: 18 | build: 19 | name: benchmark 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 15 22 | steps: 23 | - uses: actions/setup-go@v6 24 | with: 25 | go-version: 1.24.4 26 | repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" 27 | ref: "${{ github.event.inputs.ref || 'master' }}" 28 | - uses: actions/checkout@v5 29 | - name: Benchmark 30 | run: go test -bench=. ./storage/store 31 | -------------------------------------------------------------------------------- /config/endpoint/uptime.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | // Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself 4 | // and some other statistics 5 | type Uptime struct { 6 | // HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key) 7 | // 8 | // Used only if the storage type is memory 9 | HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"` 10 | } 11 | 12 | // HourlyUptimeStatistics is a struct containing all metrics collected over the course of an hour 13 | type HourlyUptimeStatistics struct { 14 | TotalExecutions uint64 // Total number of checks 15 | SuccessfulExecutions uint64 // Number of successful executions 16 | TotalExecutionsResponseTime uint64 // Total response time for all executions in milliseconds 17 | } 18 | 19 | // NewUptime creates a new Uptime 20 | func NewUptime() *Uptime { 21 | return &Uptime{ 22 | HourlyStatistics: make(map[int64]*HourlyUptimeStatistics), 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.examples/docker-compose-sqlite-storage/config/config.yaml: -------------------------------------------------------------------------------- 1 | storage: 2 | type: sqlite 3 | path: /data/data.db 4 | 5 | endpoints: 6 | - name: back-end 7 | group: core 8 | url: "https://example.org/" 9 | interval: 5m 10 | conditions: 11 | - "[STATUS] == 200" 12 | - "[CERTIFICATE_EXPIRATION] > 48h" 13 | 14 | - name: monitoring 15 | group: internal 16 | url: "https://example.org/" 17 | interval: 5m 18 | conditions: 19 | - "[STATUS] == 200" 20 | 21 | - name: nas 22 | group: internal 23 | url: "https://example.org/" 24 | interval: 5m 25 | conditions: 26 | - "[STATUS] == 200" 27 | 28 | - name: example-dns-query 29 | url: "8.8.8.8" # Address of the DNS server to use 30 | interval: 5m 31 | dns: 32 | query-name: "example.com" 33 | query-type: "A" 34 | conditions: 35 | - "[BODY] == 93.184.215.14" 36 | - "[DNS_RCODE] == NOERROR" 37 | 38 | - name: icmp-ping 39 | url: "icmp://example.org" 40 | interval: 1m 41 | conditions: 42 | - "[CONNECTED] == true" 43 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gatus: 3 | container_name: gatus 4 | image: twinproduction/gatus 5 | restart: always 6 | ports: 7 | - "8080:8080" 8 | volumes: 9 | - ./config:/config 10 | networks: 11 | - metrics 12 | 13 | prometheus: 14 | container_name: prometheus 15 | image: prom/prometheus:v3.5.0 16 | restart: always 17 | command: --config.file=/etc/prometheus/prometheus.yml 18 | ports: 19 | - "9090:9090" 20 | volumes: 21 | - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml 22 | networks: 23 | - metrics 24 | 25 | grafana: 26 | container_name: grafana 27 | image: grafana/grafana:12.1.0 28 | restart: always 29 | environment: 30 | GF_SECURITY_ADMIN_PASSWORD: secret 31 | ports: 32 | - "3000:3000" 33 | volumes: 34 | - ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro 35 | - ./grafana/provisioning/:/etc/grafana/provisioning/:ro 36 | networks: 37 | - metrics 38 | 39 | networks: 40 | metrics: 41 | driver: bridge 42 | -------------------------------------------------------------------------------- /storage/store/common/paging/endpoint_status_params.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | // EndpointStatusParams represents all parameters that can be used for paging purposes 4 | type EndpointStatusParams struct { 5 | EventsPage int // Number of the event page 6 | EventsPageSize int // Size of the event page 7 | ResultsPage int // Number of the result page 8 | ResultsPageSize int // Size of the result page 9 | } 10 | 11 | // NewEndpointStatusParams creates a new EndpointStatusParams 12 | func NewEndpointStatusParams() *EndpointStatusParams { 13 | return &EndpointStatusParams{} 14 | } 15 | 16 | // WithEvents sets the values for EventsPage and EventsPageSize 17 | func (params *EndpointStatusParams) WithEvents(page, pageSize int) *EndpointStatusParams { 18 | params.EventsPage = page 19 | params.EventsPageSize = pageSize 20 | return params 21 | } 22 | 23 | // WithResults sets the values for ResultsPage and ResultsPageSize 24 | func (params *EndpointStatusParams) WithResults(page, pageSize int) *EndpointStatusParams { 25 | params.ResultsPage = page 26 | params.ResultsPageSize = pageSize 27 | return params 28 | } 29 | -------------------------------------------------------------------------------- /.examples/docker-compose-postgres-storage/config/config.yaml: -------------------------------------------------------------------------------- 1 | storage: 2 | type: postgres 3 | path: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" 4 | 5 | endpoints: 6 | - name: back-end 7 | group: core 8 | url: "https://example.org/" 9 | interval: 5m 10 | conditions: 11 | - "[STATUS] == 200" 12 | - "[CERTIFICATE_EXPIRATION] > 48h" 13 | 14 | - name: monitoring 15 | group: internal 16 | url: "https://example.org/" 17 | interval: 5m 18 | conditions: 19 | - "[STATUS] == 200" 20 | 21 | - name: nas 22 | group: internal 23 | url: "https://example.org/" 24 | interval: 5m 25 | conditions: 26 | - "[STATUS] == 200" 27 | 28 | - name: example-dns-query 29 | url: "8.8.8.8" # Address of the DNS server to use 30 | interval: 5m 31 | dns: 32 | query-name: "example.com" 33 | query-type: "A" 34 | conditions: 35 | - "[BODY] == 93.184.215.14" 36 | - "[DNS_RCODE] == NOERROR" 37 | 38 | - name: icmp-ping 39 | url: "icmp://example.org" 40 | interval: 1m 41 | conditions: 42 | - "[CONNECTED] == true" 43 | -------------------------------------------------------------------------------- /config/endpoint/common.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/TwiN/gatus/v5/alerting/alert" 8 | ) 9 | 10 | var ( 11 | // ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name 12 | ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint") 13 | 14 | // ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't 15 | ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\") 16 | ) 17 | 18 | // validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint 19 | func validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error { 20 | if len(name) == 0 { 21 | return ErrEndpointWithNoName 22 | } 23 | if strings.ContainsAny(name, "\"\\") || strings.ContainsAny(group, "\"\\") { 24 | return ErrEndpointWithInvalidNameOrGroup 25 | } 26 | for _, endpointAlert := range alerts { 27 | if err := endpointAlert.ValidateAndSetDefaults(); err != nil { 28 | return err 29 | } 30 | } 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /config/endpoint/event.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Event is something that happens at a specific time 8 | type Event struct { 9 | // Type is the kind of event 10 | Type EventType `json:"type"` 11 | 12 | // Timestamp is the moment at which the event happened 13 | Timestamp time.Time `json:"timestamp"` 14 | } 15 | 16 | // EventType is, uh, the types of events? 17 | type EventType string 18 | 19 | var ( 20 | // EventStart is a type of event that represents when an endpoint starts being monitored 21 | EventStart EventType = "START" 22 | 23 | // EventHealthy is a type of event that represents an endpoint passing all of its conditions 24 | EventHealthy EventType = "HEALTHY" 25 | 26 | // EventUnhealthy is a type of event that represents an endpoint failing one or more of its conditions 27 | EventUnhealthy EventType = "UNHEALTHY" 28 | ) 29 | 30 | // NewEventFromResult creates an Event from a Result 31 | func NewEventFromResult(result *Result) *Event { 32 | event := &Event{Timestamp: result.Timestamp} 33 | if result.Success { 34 | event.Type = EventHealthy 35 | } else { 36 | event.Type = EventUnhealthy 37 | } 38 | return event 39 | } 40 | -------------------------------------------------------------------------------- /config/endpoint/dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/miekg/dns" 8 | ) 9 | 10 | var ( 11 | // ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name 12 | ErrDNSWithNoQueryName = errors.New("you must specify a query name in the DNS configuration") 13 | 14 | // ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type 15 | ErrDNSWithInvalidQueryType = errors.New("invalid query type in the DNS configuration") 16 | ) 17 | 18 | // Config for an Endpoint of type DNS 19 | type Config struct { 20 | // QueryType is the type for the DNS records like A, AAAA, CNAME... 21 | QueryType string `yaml:"query-type"` 22 | 23 | // QueryName is the query for DNS 24 | QueryName string `yaml:"query-name"` 25 | } 26 | 27 | func (d *Config) ValidateAndSetDefault() error { 28 | if len(d.QueryName) == 0 { 29 | return ErrDNSWithNoQueryName 30 | } 31 | if !strings.HasSuffix(d.QueryName, ".") { 32 | d.QueryName += "." 33 | } 34 | if _, ok := dns.StringToType[d.QueryType]; !ok { 35 | return ErrDNSWithInvalidQueryType 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - '*.md' 6 | - '.examples/**' 7 | push: 8 | branches: 9 | - master 10 | paths-ignore: 11 | - '*.md' 12 | - '.github/**' 13 | - '.examples/**' 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 30 18 | steps: 19 | - uses: actions/setup-go@v6 20 | with: 21 | go-version: 1.24.4 22 | - uses: actions/checkout@v5 23 | - name: Build binary to make sure it works 24 | run: go build 25 | - name: Test 26 | # We're using "sudo" because one of the tests leverages ping, which requires super-user privileges. 27 | # As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that 28 | # was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable) 29 | run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic 30 | - name: Codecov 31 | uses: codecov/codecov-action@v5.5.2 32 | with: 33 | files: ./coverage.txt 34 | token: ${{ secrets.CODECOV_TOKEN }} 35 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/grafana/grafana.ini: -------------------------------------------------------------------------------- 1 | [paths] 2 | 3 | [server] 4 | 5 | [database] 6 | 7 | [session] 8 | 9 | [dataproxy] 10 | 11 | [analytics] 12 | reporting_enabled = false 13 | 14 | [security] 15 | 16 | [snapshots] 17 | 18 | [dashboards] 19 | 20 | [users] 21 | allow_sign_up = false 22 | default_theme = light 23 | 24 | [auth] 25 | 26 | [auth.anonymous] 27 | enabled = true 28 | org_name = Main Org. 29 | org_role = Admin 30 | 31 | [auth.github] 32 | 33 | [auth.google] 34 | 35 | [auth.generic_oauth] 36 | 37 | [auth.grafana_com] 38 | 39 | [auth.proxy] 40 | 41 | [auth.basic] 42 | 43 | [auth.ldap] 44 | 45 | [smtp] 46 | 47 | [emails] 48 | 49 | [log] 50 | mode = console 51 | 52 | [log.console] 53 | 54 | [log.file] 55 | 56 | [log.syslog] 57 | 58 | [alerting] 59 | 60 | [explore] 61 | 62 | [metrics] 63 | enabled = true 64 | 65 | [metrics.graphite] 66 | 67 | [tracing.jaeger] 68 | 69 | [grafana_com] 70 | 71 | [external_image_storage] 72 | 73 | [external_image_storage.s3] 74 | 75 | [external_image_storage.webdav] 76 | 77 | [external_image_storage.gcs] 78 | 79 | [external_image_storage.azure_blob] 80 | 81 | [external_image_storage.local] 82 | 83 | [rendering] 84 | 85 | [enterprise] 86 | -------------------------------------------------------------------------------- /web/app/src/components/Social.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 13 | 14 | -------------------------------------------------------------------------------- /config/endpoint/status.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import "github.com/TwiN/gatus/v5/config/key" 4 | 5 | // Status contains the evaluation Results of an Endpoint 6 | // This is essentially a DTO 7 | type Status struct { 8 | // Name of the endpoint 9 | Name string `json:"name,omitempty"` 10 | 11 | // Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end. 12 | Group string `json:"group,omitempty"` 13 | 14 | // Key of the Endpoint 15 | Key string `json:"key"` 16 | 17 | // Results is the list of endpoint evaluation results 18 | Results []*Result `json:"results"` 19 | 20 | // Events is a list of events 21 | Events []*Event `json:"events,omitempty"` 22 | 23 | // Uptime information on the endpoint's uptime 24 | // 25 | // Used by the memory store. 26 | // 27 | // To retrieve the uptime between two time, use store.GetUptimeByKey. 28 | Uptime *Uptime `json:"-"` 29 | } 30 | 31 | // NewStatus creates a new Status 32 | func NewStatus(group, name string) *Status { 33 | return &Status{ 34 | Name: name, 35 | Group: group, 36 | Key: key.ConvertGroupAndNameToKey(group, name), 37 | Results: make([]*Result, 0), 38 | Events: make([]*Event, 0), 39 | Uptime: NewUptime(), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /web/app/src/components/ui/badge/Badge.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /config/endpoint/ssh/ssh_test.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestSSH_validatePasswordCfg(t *testing.T) { 9 | cfg := &Config{} 10 | if err := cfg.Validate(); err != nil { 11 | t.Error("didn't expect an error") 12 | } 13 | cfg.Username = "username" 14 | if err := cfg.Validate(); err == nil { 15 | t.Error("expected an error") 16 | } else if !errors.Is(err, ErrEndpointWithoutSSHAuth) { 17 | t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err) 18 | } 19 | cfg.Password = "password" 20 | if err := cfg.Validate(); err != nil { 21 | t.Errorf("expected no error, got '%v'", err) 22 | } 23 | } 24 | 25 | func TestSSH_validatePrivateKeyCfg(t *testing.T) { 26 | t.Run("fail when username missing but private key provided", func(t *testing.T) { 27 | cfg := &Config{PrivateKey: "-----BEGIN"} 28 | if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) { 29 | t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err) 30 | } 31 | }) 32 | t.Run("success when username with private key", func(t *testing.T) { 33 | cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"} 34 | if err := cfg.Validate(); err != nil { 35 | t.Fatalf("expected no error, got %v", err) 36 | } 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /config/remote/remote.go: -------------------------------------------------------------------------------- 1 | package remote 2 | 3 | import ( 4 | "github.com/TwiN/gatus/v5/client" 5 | "github.com/TwiN/logr" 6 | ) 7 | 8 | // NOTICE: This is an experimental alpha feature and may be updated/removed in future versions. 9 | // For more information, see https://github.com/TwiN/gatus/issues/64 10 | 11 | type Config struct { 12 | // Instances is a list of remote instances to retrieve endpoint statuses from. 13 | Instances []Instance `yaml:"instances,omitempty"` 14 | 15 | // ClientConfig is the configuration of the client used to communicate with the provider's target 16 | ClientConfig *client.Config `yaml:"client,omitempty"` 17 | } 18 | 19 | type Instance struct { 20 | EndpointPrefix string `yaml:"endpoint-prefix"` 21 | URL string `yaml:"url"` 22 | } 23 | 24 | func (c *Config) ValidateAndSetDefaults() error { 25 | if c.ClientConfig == nil { 26 | c.ClientConfig = client.GetDefaultConfig() 27 | } else { 28 | if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil { 29 | return err 30 | } 31 | } 32 | if len(c.Instances) > 0 { 33 | logr.Warn("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.") 34 | logr.Warn("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information") 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/TwiN/gatus/v5/api" 8 | "github.com/TwiN/gatus/v5/config" 9 | "github.com/TwiN/logr" 10 | "github.com/gofiber/fiber/v2" 11 | ) 12 | 13 | var ( 14 | app *fiber.App 15 | ) 16 | 17 | // Handle creates the router and starts the server 18 | func Handle(cfg *config.Config) { 19 | api := api.New(cfg) 20 | app = api.Router() 21 | server := app.Server() 22 | server.ReadTimeout = 15 * time.Second 23 | server.WriteTimeout = 15 * time.Second 24 | server.IdleTimeout = 15 * time.Second 25 | if os.Getenv("ROUTER_TEST") == "true" { 26 | return 27 | } 28 | logr.Info("[controller.Handle] Listening on " + cfg.Web.SocketAddress()) 29 | if cfg.Web.HasTLS() { 30 | err := app.ListenTLS(cfg.Web.SocketAddress(), cfg.Web.TLS.CertificateFile, cfg.Web.TLS.PrivateKeyFile) 31 | if err != nil { 32 | logr.Fatalf("[controller.Handle] %s", err.Error()) 33 | } 34 | } else { 35 | err := app.Listen(cfg.Web.SocketAddress()) 36 | if err != nil { 37 | logr.Fatalf("[controller.Handle] %s", err.Error()) 38 | } 39 | } 40 | logr.Info("[controller.Handle] Server has shut down successfully") 41 | } 42 | 43 | // Shutdown stops the server 44 | func Shutdown() { 45 | if app != nil { 46 | _ = app.Shutdown() 47 | app = nil 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/publish-experimental.yml: -------------------------------------------------------------------------------- 1 | name: publish-experimental 2 | on: [workflow_dispatch] 3 | jobs: 4 | publish-experimental: 5 | runs-on: ubuntu-latest 6 | timeout-minutes: 60 7 | steps: 8 | - uses: actions/checkout@v5 9 | - name: Set up QEMU 10 | uses: docker/setup-qemu-action@v3 11 | - name: Set up Docker Buildx 12 | uses: docker/setup-buildx-action@v3 13 | - name: Get image repository 14 | run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 15 | - name: Login to Docker Registry 16 | uses: docker/login-action@v3 17 | with: 18 | username: ${{ secrets.DOCKER_USERNAME }} 19 | password: ${{ secrets.DOCKER_PASSWORD }} 20 | - name: Docker meta 21 | id: meta 22 | uses: docker/metadata-action@v5 23 | with: 24 | images: ${{ env.IMAGE_REPOSITORY }} 25 | tags: | 26 | type=raw,value=experimental 27 | - name: Build and push Docker image 28 | uses: docker/build-push-action@v6 29 | with: 30 | platforms: linux/amd64 31 | pull: true 32 | push: true 33 | tags: ${{ steps.meta.outputs.tags }} 34 | labels: ${{ steps.meta.outputs.labels }} 35 | -------------------------------------------------------------------------------- /.examples/docker-compose-grafana-prometheus/README.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | Gatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`. 3 | 4 | To run this example, all you need to do is execute the following command: 5 | ```console 6 | docker-compose up 7 | ``` 8 | Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`. 9 | 10 | ![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png) 11 | 12 | 13 | ## Queries 14 | By default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find 15 | a list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's 16 | group and name. 17 | 18 | ### Success rate 19 | ``` 20 | sum(rate(gatus_results_total{success="true"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key) 21 | ``` 22 | 23 | ### Response time 24 | ``` 25 | gatus_results_duration_seconds 26 | ``` 27 | 28 | ### Total results per minute 29 | ``` 30 | sum(rate(gatus_results_total[5m])*60) by (key) 31 | ``` 32 | 33 | ### Total successful results per minute 34 | ``` 35 | sum(rate(gatus_results_total{success="true"}[5m])*60) by (key) 36 | ``` 37 | 38 | ### Total unsuccessful results per minute 39 | ``` 40 | sum(rate(gatus_results_total{success="false"}[5m])*60) by (key) 41 | ``` 42 | -------------------------------------------------------------------------------- /api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | const ( 10 | // DefaultPage is the default page to use if none is specified or an invalid value is provided 11 | DefaultPage = 1 12 | 13 | // DefaultPageSize is the default page size to use if none is specified or an invalid value is provided 14 | DefaultPageSize = 50 15 | ) 16 | 17 | func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) { 18 | var err error 19 | if pageParameter := c.Query("page"); len(pageParameter) == 0 { 20 | page = DefaultPage 21 | } else { 22 | page, err = strconv.Atoi(pageParameter) 23 | if err != nil { 24 | page = DefaultPage 25 | } 26 | if page < 1 { 27 | page = DefaultPage 28 | } 29 | } 30 | if pageSizeParameter := c.Query("pageSize"); len(pageSizeParameter) == 0 { 31 | pageSize = DefaultPageSize 32 | } else { 33 | pageSize, err = strconv.Atoi(pageSizeParameter) 34 | if err != nil { 35 | pageSize = DefaultPageSize 36 | } 37 | } 38 | if page == 1 && pageSize > maximumNumberOfResults { 39 | // If the page is 1 and the page size is greater than the maximum number of results, return 40 | // no more than the maximum number of results 41 | pageSize = maximumNumberOfResults 42 | } else if pageSize < 1 { 43 | pageSize = DefaultPageSize 44 | } 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: front-end 3 | group: core 4 | url: "https://twin.sh/health" 5 | interval: 5m 6 | conditions: 7 | - "[STATUS] == 200" 8 | - "[BODY].status == UP" 9 | - "[RESPONSE_TIME] < 150" 10 | 11 | - name: back-end 12 | group: core 13 | url: "https://example.org/" 14 | interval: 5m 15 | conditions: 16 | - "[STATUS] == 200" 17 | - "[CERTIFICATE_EXPIRATION] > 48h" 18 | 19 | - name: monitoring 20 | group: internal 21 | url: "https://example.org/" 22 | interval: 5m 23 | conditions: 24 | - "[STATUS] == 200" 25 | 26 | - name: nas 27 | group: internal 28 | url: "https://example.org/" 29 | interval: 5m 30 | conditions: 31 | - "[STATUS] == 200" 32 | 33 | - name: example-dns-query 34 | url: "8.8.8.8" # Address of the DNS server to use 35 | interval: 5m 36 | dns: 37 | query-name: "example.com" 38 | query-type: "A" 39 | conditions: 40 | - "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address 41 | - "[DNS_RCODE] == NOERROR" 42 | 43 | - name: icmp-ping 44 | url: "icmp://example.org" 45 | interval: 1m 46 | conditions: 47 | - "[CONNECTED] == true" 48 | 49 | - name: check-domain-expiration 50 | url: "https://example.org/" 51 | interval: 1h 52 | conditions: 53 | - "[DOMAIN_EXPIRATION] > 720h" 54 | -------------------------------------------------------------------------------- /config/endpoint/common_test.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/TwiN/gatus/v5/alerting/alert" 8 | ) 9 | 10 | func TestValidateEndpointNameGroupAndAlerts(t *testing.T) { 11 | scenarios := []struct { 12 | name string 13 | group string 14 | alerts []*alert.Alert 15 | expectedErr error 16 | }{ 17 | { 18 | name: "n", 19 | group: "g", 20 | alerts: []*alert.Alert{{Type: "slack"}}, 21 | }, 22 | { 23 | name: "n", 24 | alerts: []*alert.Alert{{Type: "slack"}}, 25 | }, 26 | { 27 | group: "g", 28 | alerts: []*alert.Alert{{Type: "slack"}}, 29 | expectedErr: ErrEndpointWithNoName, 30 | }, 31 | { 32 | name: "\"", 33 | alerts: []*alert.Alert{{Type: "slack"}}, 34 | expectedErr: ErrEndpointWithInvalidNameOrGroup, 35 | }, 36 | { 37 | name: "n", 38 | group: "\\", 39 | alerts: []*alert.Alert{{Type: "slack"}}, 40 | expectedErr: ErrEndpointWithInvalidNameOrGroup, 41 | }, 42 | } 43 | for _, scenario := range scenarios { 44 | t.Run(scenario.name, func(t *testing.T) { 45 | err := validateEndpointNameGroupAndAlerts(scenario.name, scenario.group, scenario.alerts) 46 | if !errors.Is(err, scenario.expectedErr) { 47 | t.Errorf("expected error to be %v but got %v", scenario.expectedErr, err) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/endpoint/ui/ui_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestValidateAndSetDefaults(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | config *Config 12 | wantErr error 13 | }{ 14 | { 15 | name: "with-valid-config", 16 | config: &Config{ 17 | Badge: &Badge{ 18 | ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500, 750}}, 19 | }, 20 | }, 21 | wantErr: nil, 22 | }, 23 | { 24 | name: "with-invalid-threshold-length", 25 | config: &Config{ 26 | Badge: &Badge{ 27 | ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500}}, 28 | }, 29 | }, 30 | wantErr: ErrInvalidBadgeResponseTimeConfig, 31 | }, 32 | { 33 | name: "with-invalid-thresholds-order", 34 | config: &Config{ 35 | Badge: &Badge{ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 500, 300, 750}}}, 36 | }, 37 | wantErr: ErrInvalidBadgeResponseTimeConfig, 38 | }, 39 | { 40 | name: "with-no-badge-configured", // should give default badge cfg 41 | config: &Config{}, 42 | wantErr: nil, 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | if err := tt.config.ValidateAndSetDefaults(); !errors.Is(err, tt.wantErr) { 49 | t.Errorf("Expected error %v, got %v", tt.wantErr, err) 50 | } 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/TwiN/gatus/v5/config" 8 | "github.com/TwiN/gatus/v5/security" 9 | "github.com/gofiber/fiber/v2" 10 | ) 11 | 12 | type ConfigHandler struct { 13 | securityConfig *security.Config 14 | config *config.Config 15 | } 16 | 17 | func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error { 18 | hasOIDC := false 19 | isAuthenticated := true // Default to true if no security config is set 20 | if handler.securityConfig != nil { 21 | hasOIDC = handler.securityConfig.OIDC != nil 22 | isAuthenticated = handler.securityConfig.IsAuthenticated(c) 23 | } 24 | 25 | // Prepare response with announcements 26 | response := map[string]interface{}{ 27 | "oidc": hasOIDC, 28 | "authenticated": isAuthenticated, 29 | } 30 | // Add announcements if available, otherwise use empty slice 31 | if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 { 32 | response["announcements"] = handler.config.Announcements 33 | } else { 34 | response["announcements"] = []interface{}{} 35 | } 36 | 37 | // Return the config as JSON 38 | c.Set("Content-Type", "application/json") 39 | responseBytes, err := json.Marshal(response) 40 | if err != nil { 41 | return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error())) 42 | } 43 | return c.Status(200).Send(responseBytes) 44 | } 45 | -------------------------------------------------------------------------------- /web/app/src/components/StatusBadge.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /.github/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/app/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/key/key_test.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import "testing" 4 | 5 | func TestConvertGroupAndNameToKey(t *testing.T) { 6 | type Scenario struct { 7 | GroupName string 8 | Name string 9 | ExpectedOutput string 10 | } 11 | scenarios := []Scenario{ 12 | { 13 | GroupName: "Core", 14 | Name: "Front End", 15 | ExpectedOutput: "core_front-end", 16 | }, 17 | { 18 | GroupName: "Load balancers", 19 | Name: "us-west-2", 20 | ExpectedOutput: "load-balancers_us-west-2", 21 | }, 22 | { 23 | GroupName: "a/b test", 24 | Name: "a", 25 | ExpectedOutput: "a-b-test_a", 26 | }, 27 | { 28 | GroupName: "", 29 | Name: "name", 30 | ExpectedOutput: "_name", 31 | }, 32 | { 33 | GroupName: "API (v1)", 34 | Name: "endpoint", 35 | ExpectedOutput: "api-(v1)_endpoint", 36 | }, 37 | { 38 | GroupName: "website (admin)", 39 | Name: "test", 40 | ExpectedOutput: "website-(admin)_test", 41 | }, 42 | { 43 | GroupName: "search", 44 | Name: "query&filter", 45 | ExpectedOutput: "search_query-filter", 46 | }, 47 | } 48 | for _, scenario := range scenarios { 49 | t.Run(scenario.ExpectedOutput, func(t *testing.T) { 50 | output := ConvertGroupAndNameToKey(scenario.GroupName, scenario.Name) 51 | if output != scenario.ExpectedOutput { 52 | t.Errorf("expected '%s', got '%s'", scenario.ExpectedOutput, output) 53 | } 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /config/endpoint/ssh/ssh.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user. 9 | ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint") 10 | 11 | // ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key. 12 | ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint") 13 | ) 14 | 15 | type Config struct { 16 | Username string `yaml:"username,omitempty"` 17 | Password string `yaml:"password,omitempty"` 18 | PrivateKey string `yaml:"private-key,omitempty"` 19 | } 20 | 21 | // Validate the SSH configuration 22 | func (cfg *Config) Validate() error { 23 | // If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid 24 | if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 { 25 | return nil 26 | } 27 | // If any authentication method is provided (password or private key), a username is required 28 | if len(cfg.Username) == 0 { 29 | return ErrEndpointWithoutSSHUsername 30 | } 31 | // If a username is provided, require at least a password or a private key 32 | if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 { 33 | return ErrEndpointWithoutSSHAuth 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /config/connectivity/connectivity.go: -------------------------------------------------------------------------------- 1 | package connectivity 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | 8 | "github.com/TwiN/gatus/v5/client" 9 | ) 10 | 11 | var ( 12 | ErrInvalidInterval = errors.New("connectivity.checker.interval must be 5s or higher") 13 | ErrInvalidDNSTarget = errors.New("connectivity.checker.target must be suffixed with :53") 14 | ) 15 | 16 | // Config is the configuration for the connectivity checker. 17 | type Config struct { 18 | Checker *Checker `yaml:"checker,omitempty"` 19 | } 20 | 21 | func (c *Config) ValidateAndSetDefaults() error { 22 | if c.Checker != nil { 23 | if c.Checker.Interval == 0 { 24 | c.Checker.Interval = 60 * time.Second 25 | } else if c.Checker.Interval < 5*time.Second { 26 | return ErrInvalidInterval 27 | } 28 | if !strings.HasSuffix(c.Checker.Target, ":53") { 29 | return ErrInvalidDNSTarget 30 | } 31 | } 32 | return nil 33 | } 34 | 35 | // Checker is the configuration for making sure Gatus has access to the internet. 36 | type Checker struct { 37 | Target string `yaml:"target"` // e.g. 1.1.1.1:53 38 | Interval time.Duration `yaml:"interval,omitempty"` 39 | 40 | isConnected bool 41 | lastCheck time.Time 42 | } 43 | 44 | func (c *Checker) Check() bool { 45 | connected, _ := client.CanCreateNetworkConnection("tcp", c.Target, "", &client.Config{Timeout: 5 * time.Second}) 46 | return connected 47 | } 48 | 49 | func (c *Checker) IsConnected() bool { 50 | if now := time.Now(); now.After(c.lastCheck.Add(c.Interval)) { 51 | c.lastCheck, c.isConnected = now, c.Check() 52 | } 53 | return c.isConnected 54 | } 55 | -------------------------------------------------------------------------------- /web/app/src/utils/markdown.js: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked' 2 | import DOMPurify from 'dompurify' 3 | 4 | const escapeHtml = (value) => { 5 | if (value === null || value === undefined) { 6 | return '' 7 | } 8 | return String(value) 9 | .replace(/&/g, '&') 10 | .replace(//g, '>') 12 | .replace(/"/g, '"') 13 | .replace(/'/g, ''') 14 | } 15 | 16 | const renderer = new marked.Renderer() 17 | 18 | renderer.link = (tokenOrHref, title, text) => { 19 | const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null 20 | ? tokenOrHref 21 | : null 22 | const href = tokenObject ? tokenObject.href : tokenOrHref 23 | const resolvedTitle = tokenObject ? tokenObject.title : title 24 | const resolvedText = tokenObject ? tokenObject.text : text 25 | const url = escapeHtml(href || '') 26 | const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : '' 27 | const linkText = resolvedText || '' 28 | return `${linkText}` 29 | } 30 | 31 | marked.use({ 32 | renderer, 33 | breaks: true, 34 | gfm: true, 35 | headerIds: false, 36 | mangle: false 37 | }) 38 | 39 | export const formatAnnouncementMessage = (message) => { 40 | if (!message) { 41 | return '' 42 | } 43 | const markdown = String(message) 44 | const html = marked.parse(markdown) 45 | return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] }) 46 | } -------------------------------------------------------------------------------- /pattern/pattern_test.go: -------------------------------------------------------------------------------- 1 | package pattern 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestMatch(t *testing.T) { 9 | testMatch(t, "*", "livingroom_123", true) 10 | testMatch(t, "**", "livingroom_123", true) 11 | testMatch(t, "living*", "livingroom_123", true) 12 | testMatch(t, "*living*", "livingroom_123", true) 13 | testMatch(t, "*123", "livingroom_123", true) 14 | testMatch(t, "*_*", "livingroom_123", true) 15 | testMatch(t, "living*_*3", "livingroom_123", true) 16 | testMatch(t, "living*room_*3", "livingroom_123", true) 17 | testMatch(t, "living*room_*3", "livingroom_123", true) 18 | testMatch(t, "*vin*om*2*", "livingroom_123", true) 19 | testMatch(t, "livingroom_123", "livingroom_123", true) 20 | testMatch(t, "*livingroom_123*", "livingroom_123", true) 21 | testMatch(t, "*test*", "\\test", true) 22 | testMatch(t, "livingroom", "livingroom_123", false) 23 | testMatch(t, "livingroom123", "livingroom_123", false) 24 | testMatch(t, "what", "livingroom_123", false) 25 | testMatch(t, "*what*", "livingroom_123", false) 26 | testMatch(t, "*.*", "livingroom_123", false) 27 | testMatch(t, "room*123", "livingroom_123", false) 28 | } 29 | 30 | func testMatch(t *testing.T, pattern, key string, expectedToMatch bool) { 31 | t.Run(fmt.Sprintf("pattern '%s' from '%s'", pattern, key), func(t *testing.T) { 32 | matched := Match(pattern, key) 33 | if expectedToMatch { 34 | if !matched { 35 | t.Errorf("%s should've matched pattern '%s'", key, pattern) 36 | } 37 | } else { 38 | if matched { 39 | t.Errorf("%s shouldn't have matched pattern '%s'", key, pattern) 40 | } 41 | } 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /api/config_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/TwiN/gatus/v5/security" 9 | "github.com/gofiber/fiber/v2" 10 | ) 11 | 12 | func TestConfigHandler_ServeHTTP(t *testing.T) { 13 | securityConfig := &security.Config{ 14 | OIDC: &security.OIDCConfig{ 15 | IssuerURL: "https://sso.gatus.io/", 16 | RedirectURL: "http://localhost:80/authorization-code/callback", 17 | Scopes: []string{"openid"}, 18 | AllowedSubjects: []string{"user1@example.com"}, 19 | }, 20 | } 21 | handler := ConfigHandler{securityConfig: securityConfig} 22 | // Create a fake router. We're doing this because I need the gate to be initialized. 23 | app := fiber.New() 24 | app.Get("/api/v1/config", handler.GetConfig) 25 | err := securityConfig.ApplySecurityMiddleware(app) 26 | if err != nil { 27 | t.Error("expected err to be nil, but was", err) 28 | } 29 | // Test the config handler 30 | request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody) 31 | response, err := app.Test(request) 32 | if err != nil { 33 | t.Error("expected err to be nil, but was", err) 34 | } 35 | defer response.Body.Close() 36 | if response.StatusCode != http.StatusOK { 37 | t.Error("expected code to be 200, but was", response.StatusCode) 38 | } 39 | body, err := io.ReadAll(response.Body) 40 | if err != nil { 41 | t.Error("expected err to be nil, but was", err) 42 | } 43 | if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` { 44 | t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/suite/result.go: -------------------------------------------------------------------------------- 1 | package suite 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/TwiN/gatus/v5/config/endpoint" 7 | ) 8 | 9 | // Result represents the result of a suite execution 10 | type Result struct { 11 | // Name of the suite 12 | Name string `json:"name,omitempty"` 13 | 14 | // Group of the suite 15 | Group string `json:"group,omitempty"` 16 | 17 | // Success indicates whether all required endpoints succeeded 18 | Success bool `json:"success"` 19 | 20 | // Timestamp is when the suite execution started 21 | Timestamp time.Time `json:"timestamp"` 22 | 23 | // Duration is how long the entire suite execution took 24 | Duration time.Duration `json:"duration"` 25 | 26 | // EndpointResults contains the results of each endpoint execution 27 | EndpointResults []*endpoint.Result `json:"endpointResults"` 28 | 29 | // Context is the final state of the context after all endpoints executed 30 | Context map[string]interface{} `json:"-"` 31 | 32 | // Errors contains any suite-level errors 33 | Errors []string `json:"errors,omitempty"` 34 | } 35 | 36 | // AddError adds an error to the suite result 37 | func (r *Result) AddError(err string) { 38 | r.Errors = append(r.Errors, err) 39 | } 40 | 41 | // CalculateSuccess determines if the suite execution was successful 42 | func (r *Result) CalculateSuccess() { 43 | r.Success = true 44 | // Check if any endpoints failed (all endpoints are required) 45 | for _, epResult := range r.EndpointResults { 46 | if !epResult.Success { 47 | r.Success = false 48 | break 49 | } 50 | } 51 | // Also check for suite-level errors 52 | if len(r.Errors) > 0 { 53 | r.Success = false 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/publish-custom.yml: -------------------------------------------------------------------------------- 1 | name: publish-custom 2 | run-name: "${{ inputs.tag }}" 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: Custom tag to publish 8 | platforms: 9 | description: Platforms to publish to (comma separated list) 10 | default: linux/amd64 11 | type: choice 12 | options: 13 | - linux/amd64 14 | - linux/arm/v7 15 | - linux/arm64 16 | 17 | jobs: 18 | publish-custom: 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 60 21 | steps: 22 | - uses: actions/checkout@v5 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: Get image repository 28 | run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 29 | - name: Login to GitHub Container Registry 30 | uses: docker/login-action@v3 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Docker meta 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ${{ env.GHCR_IMAGE_REPOSITORY }} 40 | tags: | 41 | type=raw,value=${{ inputs.tag }} 42 | - name: Build and push Docker image 43 | uses: docker/build-push-action@v6 44 | with: 45 | platforms: ${{ inputs.platforms }} 46 | pull: true 47 | push: true 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | -------------------------------------------------------------------------------- /api/spa.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | _ "embed" 5 | "html/template" 6 | 7 | "github.com/TwiN/gatus/v5/config/ui" 8 | static "github.com/TwiN/gatus/v5/web" 9 | "github.com/TwiN/logr" 10 | "github.com/gofiber/fiber/v2" 11 | ) 12 | 13 | func SinglePageApplication(uiConfig *ui.Config) fiber.Handler { 14 | return func(c *fiber.Ctx) error { 15 | vd := ui.ViewData{UI: uiConfig} 16 | { 17 | themeFromCookie := string(c.Request().Header.Cookie("theme")) 18 | if len(themeFromCookie) > 0 { 19 | if themeFromCookie == "dark" { 20 | vd.Theme = "dark" 21 | } 22 | } else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode 23 | vd.Theme = "dark" 24 | } 25 | } 26 | t, err := template.ParseFS(static.FileSystem, static.IndexPath) 27 | if err != nil { 28 | // This should never happen, because ui.ValidateAndSetDefaults validates that the template works. 29 | logr.Errorf("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s", err.Error()) 30 | return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") 31 | } 32 | c.Set("Content-Type", "text/html") 33 | err = t.Execute(c, vd) 34 | if err != nil { 35 | // This should never happen, because ui.ValidateAndSetDefaults validates that the template works. 36 | logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error()) 37 | return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") 38 | } 39 | return c.SendStatus(200) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /web/app/src/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /storage/store/memory/uptime.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/TwiN/gatus/v5/config/endpoint" 7 | ) 8 | 9 | const ( 10 | uptimeCleanUpThreshold = 32 * 24 11 | uptimeRetention = 30 * 24 * time.Hour 12 | ) 13 | 14 | // processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime 15 | // if necessary 16 | func processUptimeAfterResult(uptime *endpoint.Uptime, result *endpoint.Result) { 17 | if uptime.HourlyStatistics == nil { 18 | uptime.HourlyStatistics = make(map[int64]*endpoint.HourlyUptimeStatistics) 19 | } 20 | unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix() 21 | hourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour] 22 | if hourlyStats == nil { 23 | hourlyStats = &endpoint.HourlyUptimeStatistics{} 24 | uptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats 25 | } 26 | if result.Success { 27 | hourlyStats.SuccessfulExecutions++ 28 | } 29 | hourlyStats.TotalExecutions++ 30 | hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds()) 31 | // Clean up only when we're starting to have too many useless keys 32 | // Note that this is only triggered when there are more entries than there should be after 33 | // 32 days, despite the fact that we are deleting everything that's older than 30 days. 34 | // This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 30 days. 35 | if len(uptime.HourlyStatistics) > uptimeCleanUpThreshold { 36 | sevenDaysAgo := time.Now().Add(-(uptimeRetention + time.Hour)).Unix() 37 | for hourlyUnixTimestamp := range uptime.HourlyStatistics { 38 | if sevenDaysAgo > hourlyUnixTimestamp { 39 | delete(uptime.HourlyStatistics, hourlyUnixTimestamp) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/certs/server/ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL 3 | BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw 4 | MTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC 5 | DwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl 6 | VJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT 7 | CCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE 8 | Ws3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa 9 | ljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA 10 | ALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R 11 | cPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/ 12 | oOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C 13 | z70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr 14 | pmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp 15 | HtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB 16 | o0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU 17 | bh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r 18 | 0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0 19 | VyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx 20 | hl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B 21 | DaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0 22 | bZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs 23 | 5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1 24 | y1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5 25 | RgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b 26 | LkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst 27 | 2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv 28 | ShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: labeler 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | issues: 7 | types: 8 | - opened 9 | jobs: 10 | labeler: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | permissions: 14 | issues: write 15 | pull-requests: write 16 | steps: 17 | - name: Label 18 | continue-on-error: true 19 | env: 20 | TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }} 21 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | GH_REPO: ${{ github.repository }} 23 | NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }} 24 | run: | 25 | if [[ $TITLE == "feat"* ]]; then 26 | gh issue edit "$NUMBER" --add-label "feature" 27 | elif [[ $TITLE == "fix"* ]]; then 28 | gh issue edit "$NUMBER" --add-label "bug" 29 | elif [[ $TITLE == "docs"* ]]; then 30 | gh issue edit "$NUMBER" --add-label "documentation" 31 | fi 32 | if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then 33 | gh issue edit "$NUMBER" --add-label "area/alerting" 34 | fi 35 | if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then 36 | gh issue edit "$NUMBER" --add-label "area/ui" 37 | fi 38 | if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then 39 | gh issue edit "$NUMBER" --add-label "area/storage" 40 | fi 41 | if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then 42 | gh issue edit "$NUMBER" --add-label "area/security" 43 | fi 44 | if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then 45 | gh issue edit "$NUMBER" --add-label "area/metrics" 46 | fi 47 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/certs/client/client.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL 3 | BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw 4 | MTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP 5 | ADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf 6 | aoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO 7 | ieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B 8 | T5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN 9 | etSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3 10 | zzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6 11 | aQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14 12 | O3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX 13 | VinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe 14 | zgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU 15 | LYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj 16 | VTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV 17 | xUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN 18 | AQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm 19 | +UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H 20 | Rv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne 21 | ZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL 22 | TlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM 23 | jAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt 24 | hSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW 25 | yOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx 26 | N/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw 27 | gne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS 28 | ChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/certs/server/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF 3 | ADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx 4 | NDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw 5 | ggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2 6 | +SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy 7 | dG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet 8 | zgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA 9 | 6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN 10 | 647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM 11 | ISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc 12 | SD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN 13 | CHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT 14 | J82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv 15 | ovSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w 16 | XTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC 17 | oNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN 18 | BgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp 19 | MmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls 20 | xQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp 21 | /PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5 22 | wBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ 23 | G7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6 24 | 1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI 25 | jQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6 26 | enVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR 27 | rWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz 28 | 95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU 29 | eOk= 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /api/suite_status.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/TwiN/gatus/v5/config" 7 | "github.com/TwiN/gatus/v5/config/suite" 8 | "github.com/TwiN/gatus/v5/storage/store" 9 | "github.com/TwiN/gatus/v5/storage/store/common/paging" 10 | "github.com/gofiber/fiber/v2" 11 | ) 12 | 13 | // SuiteStatuses handles requests to retrieve all suite statuses 14 | func SuiteStatuses(cfg *config.Config) fiber.Handler { 15 | return func(c *fiber.Ctx) error { 16 | page, pageSize := extractPageAndPageSizeFromRequest(c, 100) 17 | params := paging.NewSuiteStatusParams().WithPagination(page, pageSize) 18 | suiteStatuses, err := store.Get().GetAllSuiteStatuses(params) 19 | if err != nil { 20 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 21 | "error": fmt.Sprintf("Failed to retrieve suite statuses: %v", err), 22 | }) 23 | } 24 | // If no statuses exist yet, create empty ones from config 25 | if len(suiteStatuses) == 0 { 26 | for _, s := range cfg.Suites { 27 | if s.IsEnabled() { 28 | suiteStatuses = append(suiteStatuses, suite.NewStatus(s)) 29 | } 30 | } 31 | } 32 | return c.Status(fiber.StatusOK).JSON(suiteStatuses) 33 | } 34 | } 35 | 36 | // SuiteStatus handles requests to retrieve a single suite's status 37 | func SuiteStatus(cfg *config.Config) fiber.Handler { 38 | return func(c *fiber.Ctx) error { 39 | page, pageSize := extractPageAndPageSizeFromRequest(c, 100) 40 | key := c.Params("key") 41 | params := paging.NewSuiteStatusParams().WithPagination(page, pageSize) 42 | status, err := store.Get().GetSuiteStatusByKey(key, params) 43 | if err != nil || status == nil { 44 | // Try to find the suite in config 45 | for _, s := range cfg.Suites { 46 | if s.Key() == key { 47 | status = suite.NewStatus(s) 48 | break 49 | } 50 | } 51 | if status == nil { 52 | return c.Status(404).JSON(fiber.Map{ 53 | "error": fmt.Sprintf("Suite with key '%s' not found", key), 54 | }) 55 | } 56 | } 57 | return c.Status(fiber.StatusOK).JSON(status) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: publish-release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish-release: 7 | name: publish-release 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 240 10 | steps: 11 | - uses: actions/checkout@v5 12 | - name: Set up QEMU 13 | uses: docker/setup-qemu-action@v3 14 | - name: Set up Docker Buildx 15 | uses: docker/setup-buildx-action@v3 16 | - name: Get image repository 17 | run: | 18 | echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 19 | echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 20 | - name: Get the release 21 | run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV 22 | - name: Login to Docker Registry 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | - name: Docker meta 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: | 38 | ${{ env.DOCKER_IMAGE_REPOSITORY }} 39 | ${{ env.GHCR_IMAGE_REPOSITORY }} 40 | tags: | 41 | type=raw,value=${{ env.RELEASE }} 42 | type=raw,value=stable 43 | type=raw,value=latest 44 | - name: Build and push Docker image 45 | uses: docker/build-push-action@v6 46 | with: 47 | platforms: linux/amd64,linux/arm/v7,linux/arm64 48 | pull: true 49 | push: true 50 | tags: ${{ steps.meta.outputs.tags }} 51 | labels: ${{ steps.meta.outputs.labels }} 52 | -------------------------------------------------------------------------------- /web/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatus", 3 | "version": "4.0.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve --mode development", 7 | "build": "vue-cli-service build --modern --mode production", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "chart.js": "^4.5.1", 12 | "chartjs-adapter-date-fns": "^3.0.0", 13 | "chartjs-plugin-annotation": "^3.1.0", 14 | "class-variance-authority": "^0.7.1", 15 | "clsx": "^2.1.1", 16 | "core-js": "^3.45.0", 17 | "date-fns": "^4.1.0", 18 | "dompurify": "^3.3.0", 19 | "lucide-vue-next": "^0.539.0", 20 | "marked": "^16.4.1", 21 | "tailwind-merge": "^3.3.1", 22 | "vue": "^3.5.18", 23 | "vue-chartjs": "^5.3.2", 24 | "vue-router": "^4.5.1" 25 | }, 26 | "devDependencies": { 27 | "@babel/eslint-parser": "^7.25.1", 28 | "@vue/cli-plugin-babel": "^5.0.8", 29 | "@vue/cli-plugin-eslint": "^5.0.8", 30 | "@vue/cli-plugin-router": "^5.0.8", 31 | "@vue/cli-service": "^5.0.8", 32 | "@vue/compiler-sfc": "^3.5.18", 33 | "autoprefixer": "^10.4.21", 34 | "eslint": "^8.57.1", 35 | "eslint-plugin-vue": "^9.28.0", 36 | "postcss": "^8.5.6", 37 | "tailwindcss": "^3.1.8" 38 | }, 39 | "eslintConfig": { 40 | "root": true, 41 | "env": { 42 | "node": true 43 | }, 44 | "extends": [ 45 | "plugin:vue/vue3-essential", 46 | "eslint:recommended" 47 | ], 48 | "parserOptions": { 49 | "parser": "@babel/eslint-parser", 50 | "requireConfigFile": false 51 | }, 52 | "rules": { 53 | "vue/multi-word-component-names": ["error", { 54 | "ignores": ["Home", "Details", "Loading", "Settings", "Social", "Tooltip", "Pagination", "Button", "Badge", "Card", "Input", "Select"] 55 | }] 56 | }, 57 | "globals": { 58 | "defineProps": "readonly", 59 | "defineEmits": "readonly", 60 | "defineExpose": "readonly", 61 | "withDefaults": "readonly" 62 | } 63 | }, 64 | "browserslist": [ 65 | "defaults", 66 | "> 1%", 67 | "last 2 versions", 68 | "not dead" 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /web/app/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 222.2 47.4% 11.2%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 222.2 84% 4.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | :root.dark { 30 | --background: 222.2 84% 4.9%; 31 | --foreground: 210 40% 98%; 32 | --card: 222.2 84% 4.9%; 33 | --card-foreground: 210 40% 98%; 34 | --popover: 222.2 84% 4.9%; 35 | --popover-foreground: 210 40% 98%; 36 | --primary: 210 40% 98%; 37 | --primary-foreground: 222.2 47.4% 11.2%; 38 | --secondary: 217.2 32.6% 17.5%; 39 | --secondary-foreground: 210 40% 98%; 40 | --muted: 217.2 32.6% 17.5%; 41 | --muted-foreground: 215 20.2% 65.1%; 42 | --accent: 217.2 32.6% 17.5%; 43 | --accent-foreground: 210 40% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 210 40% 98%; 46 | --border: 217.2 32.6% 17.5%; 47 | --input: 217.2 32.6% 17.5%; 48 | --ring: 212.7 26.8% 83.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | 61 | .bg-success { 62 | background-color: #28a745; 63 | } 64 | 65 | 66 | html { 67 | height: 100%; 68 | } 69 | 70 | body { 71 | min-height: 100vh; 72 | } 73 | 74 | @media screen and (max-width: 1279px) { 75 | body { 76 | padding-top: 0; 77 | padding-bottom: 0; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /web/app/src/components/Pagination.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | -------------------------------------------------------------------------------- /web/static/index.html: -------------------------------------------------------------------------------- 1 | {{ .UI.Title }}
-------------------------------------------------------------------------------- /web/static_test.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "io/fs" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestEmbed(t *testing.T) { 10 | scenarios := []struct { 11 | path string 12 | shouldExist bool 13 | expectedContainString string 14 | }{ 15 | { 16 | path: "index.html", 17 | shouldExist: true, 18 | expectedContainString: "", 19 | }, 20 | { 21 | path: "favicon.ico", 22 | shouldExist: true, 23 | expectedContainString: "", // not checking because it's an image 24 | }, 25 | { 26 | path: "img/logo.svg", 27 | shouldExist: true, 28 | expectedContainString: "", 29 | }, 30 | { 31 | path: "css/app.css", 32 | shouldExist: true, 33 | expectedContainString: "background-color", 34 | }, 35 | { 36 | path: "js/app.js", 37 | shouldExist: true, 38 | expectedContainString: "function", 39 | }, 40 | { 41 | path: "js/chunk-vendors.js", 42 | shouldExist: true, 43 | expectedContainString: "function", 44 | }, 45 | { 46 | path: "file-that-does-not-exist.html", 47 | shouldExist: false, 48 | }, 49 | } 50 | staticFileSystem, err := fs.Sub(FileSystem, RootPath) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | for _, scenario := range scenarios { 55 | t.Run(scenario.path, func(t *testing.T) { 56 | content, err := fs.ReadFile(staticFileSystem, scenario.path) 57 | if !scenario.shouldExist { 58 | if err == nil { 59 | t.Errorf("%s should not have existed", scenario.path) 60 | } 61 | } else { 62 | if err != nil { 63 | t.Errorf("opening %s should not have returned an error, got %s", scenario.path, err.Error()) 64 | } 65 | if len(content) == 0 { 66 | t.Errorf("%s should have existed in the static FileSystem, but was empty", scenario.path) 67 | } 68 | if !strings.Contains(string(content), scenario.expectedContainString) { 69 | t.Errorf("%s should have contained %s, but did not", scenario.path, scenario.expectedContainString) 70 | } 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /storage/config.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | const ( 8 | DefaultMaximumNumberOfResults = 100 9 | DefaultMaximumNumberOfEvents = 50 10 | ) 11 | 12 | var ( 13 | ErrSQLStorageRequiresPath = errors.New("sql storage requires a non-empty path to be defined") 14 | ErrMemoryStorageDoesNotSupportPath = errors.New("memory storage does not support persistence, use sqlite if you want persistence on file") 15 | ) 16 | 17 | // Config is the configuration for storage 18 | type Config struct { 19 | // Path is the path used by the store to achieve persistence 20 | // If blank, persistence is disabled. 21 | // Note that not all Type support persistence 22 | Path string `yaml:"path"` 23 | 24 | // Type of store 25 | // If blank, uses the default in-memory store 26 | Type Type `yaml:"type"` 27 | 28 | // Caching is whether to enable caching. 29 | // This is used to drastically decrease read latency by pre-emptively caching writes 30 | // as they happen, also known as the write-through caching strategy. 31 | // Does not apply if Config.Type is not TypePostgres or TypeSQLite. 32 | Caching bool `yaml:"caching,omitempty"` 33 | 34 | // MaximumNumberOfResults is the number of results each endpoint should be able to provide 35 | MaximumNumberOfResults int `yaml:"maximum-number-of-results,omitempty"` 36 | 37 | // MaximumNumberOfEvents is the number of events each endpoint should be able to provide 38 | MaximumNumberOfEvents int `yaml:"maximum-number-of-events,omitempty"` 39 | } 40 | 41 | // ValidateAndSetDefaults validates the configuration and sets the default values (if applicable) 42 | func (c *Config) ValidateAndSetDefaults() error { 43 | if c.Type == "" { 44 | c.Type = TypeMemory 45 | } 46 | if (c.Type == TypePostgres || c.Type == TypeSQLite) && len(c.Path) == 0 { 47 | return ErrSQLStorageRequiresPath 48 | } 49 | if c.Type == TypeMemory && len(c.Path) > 0 { 50 | return ErrMemoryStorageDoesNotSupportPath 51 | } 52 | if c.MaximumNumberOfResults <= 0 { 53 | c.MaximumNumberOfResults = DefaultMaximumNumberOfResults 54 | } 55 | if c.MaximumNumberOfEvents <= 0 { 56 | c.MaximumNumberOfEvents = DefaultMaximumNumberOfEvents 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /config/connectivity/connectivity_test.go: -------------------------------------------------------------------------------- 1 | package connectivity 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestConfig(t *testing.T) { 10 | scenarios := []struct { 11 | name string 12 | cfg *Config 13 | expectedErr error 14 | expectedInterval time.Duration 15 | }{ 16 | { 17 | name: "good-config", 18 | cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}}, 19 | expectedInterval: 10 * time.Second, 20 | }, 21 | { 22 | name: "good-config-with-default-interval", 23 | cfg: &Config{Checker: &Checker{Target: "8.8.8.8:53", Interval: 0}}, 24 | expectedInterval: 60 * time.Second, 25 | }, 26 | { 27 | name: "config-with-interval-too-low", 28 | cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 4 * time.Second}}, 29 | expectedErr: ErrInvalidInterval, 30 | }, 31 | { 32 | name: "config-with-invalid-target-due-to-missing-port", 33 | cfg: &Config{Checker: &Checker{Target: "1.1.1.1", Interval: 15 * time.Second}}, 34 | expectedErr: ErrInvalidDNSTarget, 35 | }, 36 | { 37 | name: "config-with-invalid-target-due-to-invalid-dns-port", 38 | cfg: &Config{Checker: &Checker{Target: "1.1.1.1:52", Interval: 15 * time.Second}}, 39 | expectedErr: ErrInvalidDNSTarget, 40 | }, 41 | } 42 | for _, scenario := range scenarios { 43 | t.Run(scenario.name, func(t *testing.T) { 44 | err := scenario.cfg.ValidateAndSetDefaults() 45 | if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", scenario.expectedErr) { 46 | t.Errorf("expected error %v, got %v", scenario.expectedErr, err) 47 | } 48 | if err == nil && scenario.expectedErr == nil { 49 | if scenario.cfg.Checker.Interval != scenario.expectedInterval { 50 | t.Errorf("expected interval %v, got %v", scenario.expectedInterval, scenario.cfg.Checker.Interval) 51 | } 52 | } 53 | }) 54 | } 55 | } 56 | 57 | func TestChecker_IsConnected(t *testing.T) { 58 | checker := &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second} 59 | if !checker.IsConnected() { 60 | t.Error("expected checker.IsConnected() to be true") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /config/tunneling/tunneling.go: -------------------------------------------------------------------------------- 1 | package tunneling 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | 8 | "github.com/TwiN/gatus/v5/config/tunneling/sshtunnel" 9 | ) 10 | 11 | // Config represents the tunneling configuration 12 | type Config struct { 13 | // Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel 14 | Tunnels map[string]*sshtunnel.Config `yaml:",inline"` 15 | 16 | mu sync.RWMutex `yaml:"-"` 17 | connections map[string]*sshtunnel.SSHTunnel `yaml:"-"` 18 | } 19 | 20 | // ValidateAndSetDefaults validates the tunneling configuration and sets defaults 21 | func (tc *Config) ValidateAndSetDefaults() error { 22 | if tc.connections == nil { 23 | tc.connections = make(map[string]*sshtunnel.SSHTunnel) 24 | } 25 | for name, config := range tc.Tunnels { 26 | if err := config.ValidateAndSetDefaults(); err != nil { 27 | return fmt.Errorf("tunnel '%s': %w", name, err) 28 | } 29 | } 30 | return nil 31 | } 32 | 33 | // GetTunnel returns the SSH tunnel for the given name, creating it if necessary 34 | func (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) { 35 | if name == "" { 36 | return nil, fmt.Errorf("tunnel name cannot be empty") 37 | } 38 | tc.mu.Lock() 39 | defer tc.mu.Unlock() 40 | // Check if tunnel already exists 41 | if tunnel, exists := tc.connections[name]; exists { 42 | return tunnel, nil 43 | } 44 | // Get config for this tunnel 45 | config, exists := tc.Tunnels[name] 46 | if !exists { 47 | return nil, fmt.Errorf("tunnel '%s' not found in configuration", name) 48 | } 49 | // Create and store new tunnel 50 | tunnel := sshtunnel.New(config) 51 | tc.connections[name] = tunnel 52 | return tunnel, nil 53 | } 54 | 55 | // Close closes all SSH tunnel connections 56 | func (tc *Config) Close() error { 57 | tc.mu.Lock() 58 | defer tc.mu.Unlock() 59 | var errors []string 60 | for name, tunnel := range tc.connections { 61 | if err := tunnel.Close(); err != nil { 62 | errors = append(errors, fmt.Sprintf("tunnel '%s': %v", name, err)) 63 | } 64 | delete(tc.connections, name) 65 | } 66 | if len(errors) > 0 { 67 | return fmt.Errorf("failed to close tunnels: %s", strings.Join(errors, ", ")) 68 | } 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/publish-latest.yml: -------------------------------------------------------------------------------- 1 | name: publish-latest 2 | on: 3 | workflow_run: 4 | workflows: [test] 5 | branches: [master] 6 | types: [completed] 7 | concurrency: 8 | group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }} 9 | cancel-in-progress: true 10 | jobs: 11 | publish-latest: 12 | runs-on: ubuntu-latest 13 | if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }} 14 | timeout-minutes: 240 15 | steps: 16 | - uses: actions/checkout@v5 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | - name: Get image repository 22 | run: | 23 | echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 24 | echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 25 | - name: Login to Docker Registry 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKER_USERNAME }} 29 | password: ${{ secrets.DOCKER_PASSWORD }} 30 | - name: Login to GitHub Container Registry 31 | uses: docker/login-action@v3 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Docker meta 37 | id: meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | images: | 41 | ${{ env.DOCKER_IMAGE_REPOSITORY }} 42 | ${{ env.GHCR_IMAGE_REPOSITORY }} 43 | tags: | 44 | type=raw,value=latest 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@v6 47 | with: 48 | platforms: linux/amd64,linux/arm/v7,linux/arm64 49 | pull: true 50 | push: true 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | -------------------------------------------------------------------------------- /web/app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | {{ .UI.Title }} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/assets/gatus-diagram.drawio: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /config/endpoint/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "errors" 4 | 5 | // Config is the UI configuration for endpoint.Endpoint 6 | type Config struct { 7 | // HideConditions whether to hide the condition results on the UI 8 | HideConditions bool `yaml:"hide-conditions"` 9 | 10 | // HideHostname whether to hide the hostname in the Result 11 | HideHostname bool `yaml:"hide-hostname"` 12 | 13 | // HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. 14 | HideURL bool `yaml:"hide-url"` 15 | 16 | // HidePort whether to hide the port in the Result 17 | HidePort bool `yaml:"hide-port"` 18 | 19 | // HideErrors whether to hide the errors in the Result 20 | HideErrors bool `yaml:"hide-errors"` 21 | 22 | // DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI 23 | DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"` 24 | 25 | // Badge is the configuration for the badges generated 26 | Badge *Badge `yaml:"badge"` 27 | } 28 | 29 | type Badge struct { 30 | ResponseTime *ResponseTime `yaml:"response-time"` 31 | } 32 | 33 | type ResponseTime struct { 34 | Thresholds []int `yaml:"thresholds"` 35 | } 36 | 37 | var ( 38 | ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values") 39 | ) 40 | 41 | // ValidateAndSetDefaults validates the UI configuration and sets the default values 42 | func (config *Config) ValidateAndSetDefaults() error { 43 | if config.Badge != nil { 44 | if len(config.Badge.ResponseTime.Thresholds) != 5 { 45 | return ErrInvalidBadgeResponseTimeConfig 46 | } 47 | for i := 4; i > 0; i-- { 48 | if config.Badge.ResponseTime.Thresholds[i] < config.Badge.ResponseTime.Thresholds[i-1] { 49 | return ErrInvalidBadgeResponseTimeConfig 50 | } 51 | } 52 | } else { 53 | config.Badge = GetDefaultConfig().Badge 54 | } 55 | return nil 56 | } 57 | 58 | // GetDefaultConfig retrieves the default UI configuration 59 | func GetDefaultConfig() *Config { 60 | return &Config{ 61 | HideHostname: false, 62 | HideURL: false, 63 | HidePort: false, 64 | HideErrors: false, 65 | DontResolveFailedConditions: false, 66 | HideConditions: false, 67 | Badge: &Badge{ 68 | ResponseTime: &ResponseTime{ 69 | Thresholds: []int{50, 200, 300, 500, 750}, 70 | }, 71 | }, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /web/app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './public/index.html', 4 | './src/**/*.{vue,js,ts,jsx,tsx}' 5 | ], 6 | darkMode: 'class', // or 'media' or 'class' 7 | theme: { 8 | fontFamily: { 9 | 'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace'], 10 | 'sans': ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'] 11 | }, 12 | extend: { 13 | colors: { 14 | border: 'hsl(var(--border))', 15 | input: 'hsl(var(--input))', 16 | ring: 'hsl(var(--ring))', 17 | background: 'hsl(var(--background))', 18 | foreground: 'hsl(var(--foreground))', 19 | primary: { 20 | DEFAULT: 'hsl(var(--primary))', 21 | foreground: 'hsl(var(--primary-foreground))', 22 | }, 23 | secondary: { 24 | DEFAULT: 'hsl(var(--secondary))', 25 | foreground: 'hsl(var(--secondary-foreground))', 26 | }, 27 | destructive: { 28 | DEFAULT: 'hsl(var(--destructive))', 29 | foreground: 'hsl(var(--destructive-foreground))', 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))', 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))', 38 | }, 39 | popover: { 40 | DEFAULT: 'hsl(var(--popover))', 41 | foreground: 'hsl(var(--popover-foreground))', 42 | }, 43 | card: { 44 | DEFAULT: 'hsl(var(--card))', 45 | foreground: 'hsl(var(--card-foreground))', 46 | }, 47 | }, 48 | borderRadius: { 49 | lg: 'var(--radius)', 50 | md: 'calc(var(--radius) - 2px)', 51 | sm: 'calc(var(--radius) - 4px)', 52 | }, 53 | keyframes: { 54 | "accordion-down": { 55 | from: { height: '0' }, 56 | to: { height: 'var(--radix-accordion-content-height)' }, 57 | }, 58 | "accordion-up": { 59 | from: { height: 'var(--radix-accordion-content-height)' }, 60 | to: { height: '0' }, 61 | }, 62 | }, 63 | animation: { 64 | "accordion-down": "accordion-down 0.2s ease-out", 65 | "accordion-up": "accordion-up 0.2s ease-out", 66 | }, 67 | }, 68 | }, 69 | variants: { 70 | extend: {}, 71 | }, 72 | plugins: [], 73 | future: { 74 | hoverOnlyWhenSupported: true, 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /client/grpc.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "net" 7 | "time" 8 | 9 | "github.com/TwiN/logr" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/credentials" 12 | "google.golang.org/grpc/credentials/insecure" 13 | health "google.golang.org/grpc/health/grpc_health_v1" 14 | ) 15 | 16 | // PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC. 17 | // Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration. 18 | func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) { 19 | if cfg == nil { 20 | cfg = GetDefaultConfig() 21 | } 22 | ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) 23 | defer cancel() 24 | 25 | var opts []grpc.DialOption 26 | // Transport credentials 27 | if useTLS { 28 | tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure} 29 | if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil { 30 | tlsCfg = configureTLS(tlsCfg, *cfg.TLS) 31 | } 32 | opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) 33 | } else { 34 | opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) 35 | } 36 | // Custom dialer for DNS resolver or SSH tunnel 37 | opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { 38 | if cfg.ResolvedTunnel != nil { 39 | return cfg.ResolvedTunnel.Dial("tcp", addr) 40 | } 41 | if cfg.HasCustomDNSResolver() { 42 | resolverCfg, err := cfg.parseDNSResolver() 43 | if err != nil { 44 | // Shouldn't happen because already validated; log and fall back 45 | logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err) 46 | } else { 47 | d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { 48 | d := net.Dialer{} 49 | return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port) 50 | }}} 51 | return d.DialContext(ctx, "tcp", addr) 52 | } 53 | } 54 | var d net.Dialer 55 | return d.DialContext(ctx, "tcp", addr) 56 | })) 57 | 58 | start := time.Now() 59 | conn, err := grpc.DialContext(ctx, address, opts...) 60 | if err != nil { 61 | return false, "", err, time.Since(start) 62 | } 63 | defer conn.Close() 64 | 65 | client := health.NewHealthClient(conn) 66 | resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""}) 67 | if err != nil { 68 | return false, "", err, time.Since(start) 69 | } 70 | return true, resp.GetStatus().String(), nil, time.Since(start) 71 | } 72 | -------------------------------------------------------------------------------- /api/util_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/TwiN/gatus/v5/storage" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/valyala/fasthttp" 10 | ) 11 | 12 | func TestExtractPageAndPageSizeFromRequest(t *testing.T) { 13 | type Scenario struct { 14 | Name string 15 | Page string 16 | PageSize string 17 | ExpectedPage int 18 | ExpectedPageSize int 19 | MaximumNumberOfResults int 20 | } 21 | scenarios := []Scenario{ 22 | { 23 | Page: "1", 24 | PageSize: "20", 25 | ExpectedPage: 1, 26 | ExpectedPageSize: 20, 27 | MaximumNumberOfResults: 20, 28 | }, 29 | { 30 | Page: "2", 31 | PageSize: "10", 32 | ExpectedPage: 2, 33 | ExpectedPageSize: 10, 34 | MaximumNumberOfResults: 40, 35 | }, 36 | { 37 | Page: "2", 38 | PageSize: "10", 39 | ExpectedPage: 2, 40 | ExpectedPageSize: 10, 41 | MaximumNumberOfResults: 200, 42 | }, 43 | { 44 | Page: "1", 45 | PageSize: "999999", 46 | ExpectedPage: 1, 47 | ExpectedPageSize: storage.DefaultMaximumNumberOfResults, 48 | MaximumNumberOfResults: 100, 49 | }, 50 | { 51 | Page: "-1", 52 | PageSize: "-1", 53 | ExpectedPage: DefaultPage, 54 | ExpectedPageSize: DefaultPageSize, 55 | MaximumNumberOfResults: 20, 56 | }, 57 | { 58 | Page: "invalid", 59 | PageSize: "invalid", 60 | ExpectedPage: DefaultPage, 61 | ExpectedPageSize: DefaultPageSize, 62 | MaximumNumberOfResults: 100, 63 | }, 64 | } 65 | for _, scenario := range scenarios { 66 | t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) { 67 | //request := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody) 68 | app := fiber.New() 69 | c := app.AcquireCtx(&fasthttp.RequestCtx{}) 70 | defer app.ReleaseCtx(c) 71 | c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize)) 72 | actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c, scenario.MaximumNumberOfResults) 73 | if actualPage != scenario.ExpectedPage { 74 | t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage) 75 | } 76 | if actualPageSize != scenario.ExpectedPageSize { 77 | t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize) 78 | } 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /config/endpoint/result.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Result of the evaluation of an Endpoint 8 | type Result struct { 9 | // HTTPStatus is the HTTP response status code 10 | HTTPStatus int `json:"status,omitempty"` 11 | 12 | // DNSRCode is the response code of a DNS query in a human-readable format 13 | // 14 | // Possible values: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED 15 | DNSRCode string `json:"-"` 16 | 17 | // Hostname extracted from Endpoint.URL 18 | Hostname string `json:"hostname,omitempty"` 19 | 20 | // IP resolved from the Endpoint URL 21 | IP string `json:"-"` 22 | 23 | // Connected whether a connection to the host was established successfully 24 | Connected bool `json:"-"` 25 | 26 | // Duration time that the request took 27 | Duration time.Duration `json:"duration"` 28 | 29 | // Errors encountered during the evaluation of the Endpoint's health 30 | Errors []string `json:"errors,omitempty"` 31 | 32 | // ConditionResults are the results of each of the Endpoint's Condition 33 | ConditionResults []*ConditionResult `json:"conditionResults,omitempty"` 34 | 35 | // Success whether the result signifies a success or not 36 | Success bool `json:"success"` 37 | 38 | // Timestamp when the request was sent 39 | Timestamp time.Time `json:"timestamp"` 40 | 41 | // CertificateExpiration is the duration before the certificate expires 42 | CertificateExpiration time.Duration `json:"-"` 43 | 44 | // DomainExpiration is the duration before the domain expires 45 | DomainExpiration time.Duration `json:"-"` 46 | 47 | // Body is the response body 48 | // 49 | // Note that this field is not persisted in the storage. 50 | // It is used for health evaluation as well as debugging purposes. 51 | Body []byte `json:"-"` 52 | 53 | /////////////////////////////////////////////////////////////////////// 54 | // Below is used only for the UI and is not persisted in the storage // 55 | /////////////////////////////////////////////////////////////////////// 56 | port string `yaml:"-"` // used for endpoints[].ui.hide-port 57 | 58 | /////////////////////////////////// 59 | // BELOW IS ONLY USED FOR SUITES // 60 | /////////////////////////////////// 61 | // Name of the endpoint (ONLY USED FOR SUITES) 62 | // Group is not needed because it's inherited from the suite 63 | Name string `json:"name,omitempty"` 64 | } 65 | 66 | // AddError adds an error to the result's list of errors. 67 | // It also ensures that there are no duplicates. 68 | func (r *Result) AddError(error string) { 69 | for _, resultError := range r.Errors { 70 | if resultError == error { 71 | // If the error already exists, don't add it 72 | return 73 | } 74 | } 75 | r.Errors = append(r.Errors, error+"") 76 | } 77 | -------------------------------------------------------------------------------- /watchdog/watchdog.go: -------------------------------------------------------------------------------- 1 | package watchdog 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/TwiN/gatus/v5/config" 8 | "golang.org/x/sync/semaphore" 9 | ) 10 | 11 | const ( 12 | // UnlimitedConcurrencyWeight is the semaphore weight used when concurrency is set to 0 (unlimited). 13 | // This provides a practical upper limit while allowing very high concurrency for large deployments. 14 | UnlimitedConcurrencyWeight = 10000 15 | ) 16 | 17 | var ( 18 | // monitoringSemaphore is used to limit the number of endpoints/suites that can be evaluated concurrently. 19 | // Without this, conditions using response time may become inaccurate. 20 | monitoringSemaphore *semaphore.Weighted 21 | 22 | ctx context.Context 23 | cancelFunc context.CancelFunc 24 | ) 25 | 26 | // Monitor loops over each endpoint and starts a goroutine to monitor each endpoint separately 27 | func Monitor(cfg *config.Config) { 28 | ctx, cancelFunc = context.WithCancel(context.Background()) 29 | // Initialize semaphore based on concurrency configuration 30 | if cfg.Concurrency == 0 { 31 | // Unlimited concurrency - use a very high limit 32 | monitoringSemaphore = semaphore.NewWeighted(UnlimitedConcurrencyWeight) 33 | } else { 34 | // Limited concurrency based on configuration 35 | monitoringSemaphore = semaphore.NewWeighted(int64(cfg.Concurrency)) 36 | } 37 | extraLabels := cfg.GetUniqueExtraMetricLabels() 38 | for _, endpoint := range cfg.Endpoints { 39 | if endpoint.IsEnabled() { 40 | // To prevent multiple requests from running at the same time, we'll wait for a little before each iteration 41 | time.Sleep(222 * time.Millisecond) 42 | go monitorEndpoint(endpoint, cfg, extraLabels, ctx) 43 | } 44 | } 45 | for _, externalEndpoint := range cfg.ExternalEndpoints { 46 | // Check if the external endpoint is enabled and is using heartbeat 47 | // If the external endpoint does not use heartbeat, then it does not need to be monitored periodically, because 48 | // alerting is checked every time an external endpoint is pushed to Gatus, unlike normal endpoints. 49 | if externalEndpoint.IsEnabled() && externalEndpoint.Heartbeat.Interval > 0 { 50 | go monitorExternalEndpointHeartbeat(externalEndpoint, cfg, extraLabels, ctx) 51 | } 52 | } 53 | for _, suite := range cfg.Suites { 54 | if suite.IsEnabled() { 55 | time.Sleep(222 * time.Millisecond) 56 | go monitorSuite(suite, cfg, extraLabels, ctx) 57 | } 58 | } 59 | } 60 | 61 | // Shutdown stops monitoring all endpoints 62 | func Shutdown(cfg *config.Config) { 63 | // Stop in-flight HTTP connections 64 | for _, ep := range cfg.Endpoints { 65 | ep.Close() 66 | } 67 | for _, s := range cfg.Suites { 68 | for _, ep := range s.Endpoints { 69 | ep.Close() 70 | } 71 | } 72 | cancelFunc() 73 | } 74 | -------------------------------------------------------------------------------- /api/raw.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/TwiN/gatus/v5/storage/store" 10 | "github.com/TwiN/gatus/v5/storage/store/common" 11 | "github.com/gofiber/fiber/v2" 12 | ) 13 | 14 | func UptimeRaw(c *fiber.Ctx) error { 15 | duration := c.Params("duration") 16 | var from time.Time 17 | switch duration { 18 | case "30d": 19 | from = time.Now().Add(-30 * 24 * time.Hour) 20 | case "7d": 21 | from = time.Now().Add(-7 * 24 * time.Hour) 22 | case "24h": 23 | from = time.Now().Add(-24 * time.Hour) 24 | case "1h": 25 | from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little 26 | default: 27 | return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") 28 | } 29 | key, err := url.QueryUnescape(c.Params("key")) 30 | if err != nil { 31 | return c.Status(400).SendString("invalid key encoding") 32 | } 33 | uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) 34 | if err != nil { 35 | if errors.Is(err, common.ErrEndpointNotFound) { 36 | return c.Status(404).SendString(err.Error()) 37 | } else if errors.Is(err, common.ErrInvalidTimeRange) { 38 | return c.Status(400).SendString(err.Error()) 39 | } 40 | return c.Status(500).SendString(err.Error()) 41 | } 42 | 43 | c.Set("Content-Type", "text/plain") 44 | c.Set("Cache-Control", "no-cache, no-store, must-revalidate") 45 | c.Set("Expires", "0") 46 | return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime))) 47 | } 48 | 49 | func ResponseTimeRaw(c *fiber.Ctx) error { 50 | duration := c.Params("duration") 51 | var from time.Time 52 | switch duration { 53 | case "30d": 54 | from = time.Now().Add(-30 * 24 * time.Hour) 55 | case "7d": 56 | from = time.Now().Add(-7 * 24 * time.Hour) 57 | case "24h": 58 | from = time.Now().Add(-24 * time.Hour) 59 | case "1h": 60 | from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little 61 | default: 62 | return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") 63 | } 64 | key, err := url.QueryUnescape(c.Params("key")) 65 | if err != nil { 66 | return c.Status(400).SendString("invalid key encoding") 67 | } 68 | responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) 69 | if err != nil { 70 | if errors.Is(err, common.ErrEndpointNotFound) { 71 | return c.Status(404).SendString(err.Error()) 72 | } else if errors.Is(err, common.ErrInvalidTimeRange) { 73 | return c.Status(400).SendString(err.Error()) 74 | } 75 | return c.Status(500).SendString(err.Error()) 76 | } 77 | 78 | c.Set("Content-Type", "text/plain") 79 | c.Set("Cache-Control", "no-cache, no-store, must-revalidate") 80 | c.Set("Expires", "0") 81 | return c.Status(200).Send([]byte(fmt.Sprintf("%d", responseTime))) 82 | } 83 | -------------------------------------------------------------------------------- /config/endpoint/condition_bench_test.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) { 8 | condition := Condition("[BODY].name == any(john.doe, jane.doe)") 9 | for n := 0; n < b.N; n++ { 10 | result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} 11 | condition.evaluate(result, false, nil) 12 | } 13 | b.ReportAllocs() 14 | } 15 | 16 | func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) { 17 | condition := Condition("[BODY].name == any(john.doe, jane.doe)") 18 | for n := 0; n < b.N; n++ { 19 | result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} 20 | condition.evaluate(result, false, nil) 21 | } 22 | b.ReportAllocs() 23 | } 24 | 25 | func BenchmarkCondition_evaluateWithBodyString(b *testing.B) { 26 | condition := Condition("[BODY].name == john.doe") 27 | for n := 0; n < b.N; n++ { 28 | result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} 29 | condition.evaluate(result, false, nil) 30 | } 31 | b.ReportAllocs() 32 | } 33 | 34 | func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) { 35 | condition := Condition("[BODY].name == john.doe") 36 | for n := 0; n < b.N; n++ { 37 | result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} 38 | condition.evaluate(result, false, nil) 39 | } 40 | b.ReportAllocs() 41 | } 42 | 43 | func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) { 44 | condition := Condition("[BODY].user.name == bob.doe") 45 | for n := 0; n < b.N; n++ { 46 | result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} 47 | condition.evaluate(result, false, nil) 48 | } 49 | b.ReportAllocs() 50 | } 51 | 52 | func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) { 53 | condition := Condition("len([BODY].name) == 8") 54 | for n := 0; n < b.N; n++ { 55 | result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} 56 | condition.evaluate(result, false, nil) 57 | } 58 | b.ReportAllocs() 59 | } 60 | 61 | func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) { 62 | condition := Condition("len([BODY].name) == 8") 63 | for n := 0; n < b.N; n++ { 64 | result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} 65 | condition.evaluate(result, false, nil) 66 | } 67 | b.ReportAllocs() 68 | } 69 | 70 | func BenchmarkCondition_evaluateWithStatus(b *testing.B) { 71 | condition := Condition("[STATUS] == 200") 72 | for n := 0; n < b.N; n++ { 73 | result := &Result{HTTPStatus: 200} 74 | condition.evaluate(result, false, nil) 75 | } 76 | b.ReportAllocs() 77 | } 78 | 79 | func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) { 80 | condition := Condition("[STATUS] == 200") 81 | for n := 0; n < b.N; n++ { 82 | result := &Result{HTTPStatus: 400} 83 | condition.evaluate(result, false, nil) 84 | } 85 | b.ReportAllocs() 86 | } 87 | -------------------------------------------------------------------------------- /storage/store/common/paging/endpoint_status_params_test.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | import "testing" 4 | 5 | func TestNewEndpointStatusParams(t *testing.T) { 6 | type Scenario struct { 7 | Name string 8 | Params *EndpointStatusParams 9 | ExpectedEventsPage int 10 | ExpectedEventsPageSize int 11 | ExpectedResultsPage int 12 | ExpectedResultsPageSize int 13 | } 14 | scenarios := []Scenario{ 15 | { 16 | Name: "empty-params", 17 | Params: NewEndpointStatusParams(), 18 | ExpectedEventsPage: 0, 19 | ExpectedEventsPageSize: 0, 20 | ExpectedResultsPage: 0, 21 | ExpectedResultsPageSize: 0, 22 | }, 23 | { 24 | Name: "with-events-page-2-size-7", 25 | Params: NewEndpointStatusParams().WithEvents(2, 7), 26 | ExpectedEventsPage: 2, 27 | ExpectedEventsPageSize: 7, 28 | ExpectedResultsPage: 0, 29 | ExpectedResultsPageSize: 0, 30 | }, 31 | { 32 | Name: "with-events-page-4-size-3-uptime", 33 | Params: NewEndpointStatusParams().WithEvents(4, 3), 34 | ExpectedEventsPage: 4, 35 | ExpectedEventsPageSize: 3, 36 | ExpectedResultsPage: 0, 37 | ExpectedResultsPageSize: 0, 38 | }, 39 | { 40 | Name: "with-results-page-1-size-20-uptime", 41 | Params: NewEndpointStatusParams().WithResults(1, 20), 42 | ExpectedEventsPage: 0, 43 | ExpectedEventsPageSize: 0, 44 | ExpectedResultsPage: 1, 45 | ExpectedResultsPageSize: 20, 46 | }, 47 | { 48 | Name: "with-results-page-2-size-10-events-page-3-size-50", 49 | Params: NewEndpointStatusParams().WithResults(2, 10).WithEvents(3, 50), 50 | ExpectedEventsPage: 3, 51 | ExpectedEventsPageSize: 50, 52 | ExpectedResultsPage: 2, 53 | ExpectedResultsPageSize: 10, 54 | }, 55 | } 56 | for _, scenario := range scenarios { 57 | t.Run(scenario.Name, func(t *testing.T) { 58 | if scenario.Params.EventsPage != scenario.ExpectedEventsPage { 59 | t.Errorf("expected ExpectedEventsPage to be %d, was %d", scenario.ExpectedEventsPageSize, scenario.Params.EventsPage) 60 | } 61 | if scenario.Params.EventsPageSize != scenario.ExpectedEventsPageSize { 62 | t.Errorf("expected EventsPageSize to be %d, was %d", scenario.ExpectedEventsPageSize, scenario.Params.EventsPageSize) 63 | } 64 | if scenario.Params.ResultsPage != scenario.ExpectedResultsPage { 65 | t.Errorf("expected ResultsPage to be %d, was %d", scenario.ExpectedResultsPage, scenario.Params.ResultsPage) 66 | } 67 | if scenario.Params.ResultsPageSize != scenario.ExpectedResultsPageSize { 68 | t.Errorf("expected ResultsPageSize to be %d, was %d", scenario.ExpectedResultsPageSize, scenario.Params.ResultsPageSize) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.examples/kubernetes/gatus.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: gatus 5 | namespace: kube-system 6 | data: 7 | config.yaml: | 8 | metrics: true 9 | endpoints: 10 | - name: website 11 | url: https://twin.sh/health 12 | interval: 5m 13 | conditions: 14 | - "[STATUS] == 200" 15 | - "[BODY].status == UP" 16 | 17 | - name: github 18 | url: https://api.github.com/healthz 19 | interval: 5m 20 | conditions: 21 | - "[STATUS] == 200" 22 | 23 | - name: cat-fact 24 | url: "https://cat-fact.herokuapp.com/facts/random" 25 | interval: 5m 26 | conditions: 27 | - "[STATUS] == 200" 28 | - "[BODY].deleted == false" 29 | - "len([BODY].text) > 0" 30 | - "[BODY].text == pat(*cat*)" 31 | - "[STATUS] == pat(2*)" 32 | - "[CONNECTED] == true" 33 | 34 | - name: example 35 | url: https://example.com/ 36 | conditions: 37 | - "[STATUS] == 200" 38 | --- 39 | apiVersion: v1 40 | kind: ServiceAccount 41 | metadata: 42 | name: gatus 43 | namespace: kube-system 44 | --- 45 | apiVersion: apps/v1 46 | kind: Deployment 47 | metadata: 48 | name: gatus 49 | namespace: kube-system 50 | spec: 51 | replicas: 1 52 | selector: 53 | matchLabels: 54 | app: gatus 55 | template: 56 | metadata: 57 | name: gatus 58 | namespace: kube-system 59 | labels: 60 | app: gatus 61 | spec: 62 | serviceAccountName: gatus 63 | terminationGracePeriodSeconds: 5 64 | containers: 65 | - image: twinproduction/gatus 66 | imagePullPolicy: IfNotPresent 67 | name: gatus 68 | ports: 69 | - containerPort: 8080 70 | name: http 71 | protocol: TCP 72 | resources: 73 | limits: 74 | cpu: 250m 75 | memory: 100M 76 | requests: 77 | cpu: 50m 78 | memory: 30M 79 | readinessProbe: 80 | httpGet: 81 | path: /health 82 | port: 8080 83 | initialDelaySeconds: 5 84 | periodSeconds: 10 85 | successThreshold: 1 86 | failureThreshold: 3 87 | livenessProbe: 88 | httpGet: 89 | path: /health 90 | port: 8080 91 | initialDelaySeconds: 10 92 | periodSeconds: 10 93 | successThreshold: 1 94 | failureThreshold: 5 95 | volumeMounts: 96 | - mountPath: /config 97 | name: gatus-config 98 | volumes: 99 | - configMap: 100 | name: gatus 101 | name: gatus-config 102 | --- 103 | apiVersion: v1 104 | kind: Service 105 | metadata: 106 | name: gatus 107 | namespace: kube-system 108 | spec: 109 | ports: 110 | - name: http 111 | port: 8080 112 | protocol: TCP 113 | targetPort: 8080 114 | selector: 115 | app: gatus -------------------------------------------------------------------------------- /controller/controller_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "math/rand" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | 10 | "github.com/TwiN/gatus/v5/config" 11 | "github.com/TwiN/gatus/v5/config/endpoint" 12 | "github.com/TwiN/gatus/v5/config/web" 13 | "github.com/gofiber/fiber/v2" 14 | ) 15 | 16 | func TestHandle(t *testing.T) { 17 | cfg := &config.Config{ 18 | Web: &web.Config{ 19 | Address: "0.0.0.0", 20 | Port: rand.Intn(65534), 21 | }, 22 | Endpoints: []*endpoint.Endpoint{ 23 | { 24 | Name: "frontend", 25 | Group: "core", 26 | }, 27 | { 28 | Name: "backend", 29 | Group: "core", 30 | }, 31 | }, 32 | } 33 | _ = os.Setenv("ROUTER_TEST", "true") 34 | _ = os.Setenv("ENVIRONMENT", "dev") 35 | defer os.Clearenv() 36 | Handle(cfg) 37 | defer Shutdown() 38 | request := httptest.NewRequest("GET", "/health", http.NoBody) 39 | response, err := app.Test(request) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | if response.StatusCode != 200 { 44 | t.Error("expected GET /health to return status code 200") 45 | } 46 | if app == nil { 47 | t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)") 48 | } 49 | } 50 | 51 | func TestHandleTLS(t *testing.T) { 52 | scenarios := []struct { 53 | name string 54 | tls *web.TLSConfig 55 | expectedStatusCode int 56 | }{ 57 | { 58 | name: "good-tls-config", 59 | tls: &web.TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}, 60 | expectedStatusCode: 200, 61 | }, 62 | } 63 | for _, scenario := range scenarios { 64 | t.Run(scenario.name, func(t *testing.T) { 65 | cfg := &config.Config{ 66 | Web: &web.Config{Address: "0.0.0.0", Port: rand.Intn(65534), TLS: scenario.tls}, 67 | Endpoints: []*endpoint.Endpoint{ 68 | {Name: "frontend", Group: "core"}, 69 | {Name: "backend", Group: "core"}, 70 | }, 71 | } 72 | if err := cfg.Web.ValidateAndSetDefaults(); err != nil { 73 | t.Error("expected no error from web (TLS) validation, got", err) 74 | } 75 | _ = os.Setenv("ROUTER_TEST", "true") 76 | _ = os.Setenv("ENVIRONMENT", "dev") 77 | defer os.Clearenv() 78 | Handle(cfg) 79 | defer Shutdown() 80 | request := httptest.NewRequest("GET", "/health", http.NoBody) 81 | response, err := app.Test(request) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | if response.StatusCode != scenario.expectedStatusCode { 86 | t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.expectedStatusCode, response.StatusCode) 87 | } 88 | if app == nil { 89 | t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)") 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestShutdown(t *testing.T) { 96 | // Pretend that we called controller.Handle(), which initializes the server variable 97 | app = fiber.New() 98 | Shutdown() 99 | if app != nil { 100 | t.Error("server should've been shut down") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /web/app/src/utils/time.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a human-readable relative time string (e.g., "2 hours ago") 3 | * @param {string|Date} timestamp - The timestamp to convert 4 | * @returns {string} Relative time string 5 | */ 6 | export const generatePrettyTimeAgo = (timestamp) => { 7 | let differenceInMs = new Date().getTime() - new Date(timestamp).getTime(); 8 | if (differenceInMs < 500) { 9 | return "now"; 10 | } 11 | if (differenceInMs > 3 * 86400000) { // If it was more than 3 days ago, we'll display the number of days ago 12 | let days = (differenceInMs / 86400000).toFixed(0); 13 | return days + " day" + (days !== "1" ? "s" : "") + " ago"; 14 | } 15 | if (differenceInMs > 3600000) { // If it was more than 1h ago, display the number of hours ago 16 | let hours = (differenceInMs / 3600000).toFixed(0); 17 | return hours + " hour" + (hours !== "1" ? "s" : "") + " ago"; 18 | } 19 | if (differenceInMs > 60000) { 20 | let minutes = (differenceInMs / 60000).toFixed(0); 21 | return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago"; 22 | } 23 | let seconds = (differenceInMs / 1000).toFixed(0); 24 | return seconds + " second" + (seconds !== "1" ? "s" : "") + " ago"; 25 | } 26 | 27 | /** 28 | * Generates a pretty time difference string between two timestamps 29 | * @param {string|Date} start - Start timestamp 30 | * @param {string|Date} end - End timestamp 31 | * @returns {string} Time difference string 32 | */ 33 | export const generatePrettyTimeDifference = (start, end) => { 34 | const ms = new Date(start) - new Date(end) 35 | const seconds = Math.floor(ms / 1000) 36 | const minutes = Math.floor(seconds / 60) 37 | const hours = Math.floor(minutes / 60) 38 | 39 | if (hours > 0) { 40 | const remainingMinutes = minutes % 60 41 | const hoursText = hours + (hours === 1 ? ' hour' : ' hours') 42 | if (remainingMinutes > 0) { 43 | return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes') 44 | } 45 | return hoursText 46 | } else if (minutes > 0) { 47 | const remainingSeconds = seconds % 60 48 | const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes') 49 | if (remainingSeconds > 0) { 50 | return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds') 51 | } 52 | return minutesText 53 | } else { 54 | return seconds + (seconds === 1 ? ' second' : ' seconds') 55 | } 56 | } 57 | 58 | /** 59 | * Formats a timestamp into YYYY-MM-DD HH:mm:ss format 60 | * @param {string|Date} timestamp - The timestamp to format 61 | * @returns {string} Formatted timestamp 62 | */ 63 | export const prettifyTimestamp = (timestamp) => { 64 | let date = new Date(timestamp); 65 | let YYYY = date.getFullYear(); 66 | let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1); 67 | let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate()); 68 | let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours()); 69 | let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes()); 70 | let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds()); 71 | return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss; 72 | } -------------------------------------------------------------------------------- /config/announcement/announcement.go: -------------------------------------------------------------------------------- 1 | package announcement 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "time" 7 | ) 8 | 9 | const ( 10 | // TypeOutage represents a service outage 11 | TypeOutage = "outage" 12 | 13 | // TypeWarning represents a warning or potential issue 14 | TypeWarning = "warning" 15 | 16 | // TypeInformation represents general information 17 | TypeInformation = "information" 18 | 19 | // TypeOperational represents operational status or resolved issues 20 | TypeOperational = "operational" 21 | 22 | // TypeNone represents no specific type (default) 23 | TypeNone = "none" 24 | ) 25 | 26 | var ( 27 | // ErrInvalidAnnouncementType is returned when an invalid announcement type is specified 28 | ErrInvalidAnnouncementType = errors.New("invalid announcement type") 29 | 30 | // ErrEmptyMessage is returned when an announcement has an empty message 31 | ErrEmptyMessage = errors.New("announcement message cannot be empty") 32 | 33 | // ErrMissingTimestamp is returned when an announcement has an empty timestamp 34 | ErrMissingTimestamp = errors.New("announcement timestamp must be set") 35 | 36 | // validTypes contains all valid announcement types 37 | validTypes = map[string]bool{ 38 | TypeOutage: true, 39 | TypeWarning: true, 40 | TypeInformation: true, 41 | TypeOperational: true, 42 | TypeNone: true, 43 | } 44 | ) 45 | 46 | // Announcement represents a system-wide announcement 47 | type Announcement struct { 48 | // Timestamp is the UTC timestamp when the announcement was made 49 | Timestamp time.Time `yaml:"timestamp" json:"timestamp"` 50 | 51 | // Type is the type of announcement (outage, warning, information, operational, none) 52 | Type string `yaml:"type" json:"type"` 53 | 54 | // Message is the user-facing text describing the announcement 55 | Message string `yaml:"message" json:"message"` 56 | 57 | // Archived indicates whether the announcement should be displayed in the historical section 58 | // instead of at the top of the status page 59 | Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"` 60 | } 61 | 62 | // ValidateAndSetDefaults validates the announcement and sets default values if necessary 63 | func (a *Announcement) ValidateAndSetDefaults() error { 64 | // Validate message 65 | if a.Message == "" { 66 | return ErrEmptyMessage 67 | } 68 | // Set default type if empty 69 | if a.Type == "" { 70 | a.Type = TypeNone 71 | } 72 | // Validate type 73 | if !validTypes[a.Type] { 74 | return ErrInvalidAnnouncementType 75 | } 76 | // If timestamp is zero, return an error 77 | if a.Timestamp.IsZero() { 78 | return ErrMissingTimestamp 79 | } 80 | return nil 81 | } 82 | 83 | // SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first) 84 | func SortByTimestamp(announcements []*Announcement) { 85 | sort.Slice(announcements, func(i, j int) bool { 86 | return announcements[i].Timestamp.After(announcements[j].Timestamp) 87 | }) 88 | } 89 | 90 | // ValidateAndSetDefaults validates a slice of announcements and sets defaults 91 | func ValidateAndSetDefaults(announcements []*Announcement) error { 92 | for _, announcement := range announcements { 93 | if err := announcement.ValidateAndSetDefaults(); err != nil { 94 | return err 95 | } 96 | } 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /watchdog/suite.go: -------------------------------------------------------------------------------- 1 | package watchdog 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/TwiN/gatus/v5/config" 8 | "github.com/TwiN/gatus/v5/config/suite" 9 | "github.com/TwiN/gatus/v5/metrics" 10 | "github.com/TwiN/gatus/v5/storage/store" 11 | "github.com/TwiN/logr" 12 | ) 13 | 14 | // monitorSuite monitors a suite by executing it at regular intervals 15 | func monitorSuite(s *suite.Suite, cfg *config.Config, extraLabels []string, ctx context.Context) { 16 | // Execute immediately on start 17 | executeSuite(s, cfg, extraLabels) 18 | // Set up ticker for periodic execution 19 | ticker := time.NewTicker(s.Interval) 20 | defer ticker.Stop() 21 | for { 22 | select { 23 | case <-ctx.Done(): 24 | logr.Warnf("[watchdog.monitorSuite] Canceling monitoring for suite=%s", s.Name) 25 | return 26 | case <-ticker.C: 27 | executeSuite(s, cfg, extraLabels) 28 | } 29 | } 30 | } 31 | 32 | // executeSuite executes a suite with proper concurrency control 33 | func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) { 34 | // Acquire semaphore to limit concurrent suite monitoring 35 | if err := monitoringSemaphore.Acquire(ctx, 1); err != nil { 36 | // Only fails if context is cancelled (during shutdown) 37 | logr.Debugf("[watchdog.executeSuite] Context cancelled, skipping execution: %s", err.Error()) 38 | return 39 | } 40 | defer monitoringSemaphore.Release(1) 41 | // Check connectivity if configured 42 | if cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() { 43 | logr.Infof("[watchdog.executeSuite] No connectivity; skipping suite=%s", s.Name) 44 | return 45 | } 46 | logr.Debugf("[watchdog.executeSuite] Monitoring group=%s; suite=%s; key=%s", s.Group, s.Name, s.Key()) 47 | // Execute the suite using its Execute method 48 | result := s.Execute() 49 | // Publish metrics for the suite execution 50 | if cfg.Metrics { 51 | metrics.PublishMetricsForSuite(s, result, extraLabels) 52 | } 53 | // Store result 54 | UpdateSuiteStatus(s, result) 55 | // Handle alerting for suite endpoints 56 | for i, ep := range s.Endpoints { 57 | if i < len(result.EndpointResults) { 58 | epResult := result.EndpointResults[i] 59 | // Handle alerting if configured and not under maintenance 60 | if cfg.Alerting != nil && !cfg.Maintenance.IsUnderMaintenance() { 61 | // Check if endpoint is under maintenance 62 | inEndpointMaintenanceWindow := false 63 | for _, maintenanceWindow := range ep.MaintenanceWindows { 64 | if maintenanceWindow.IsUnderMaintenance() { 65 | logr.Debug("[watchdog.executeSuite] Endpoint under maintenance window") 66 | inEndpointMaintenanceWindow = true 67 | break 68 | } 69 | } 70 | if !inEndpointMaintenanceWindow { 71 | HandleAlerting(ep, epResult, cfg.Alerting) 72 | } 73 | } 74 | } 75 | } 76 | logr.Infof("[watchdog.executeSuite] Completed suite=%s; success=%v; errors=%d; duration=%v; endpoints_executed=%d/%d", s.Name, result.Success, len(result.Errors), result.Duration, len(result.EndpointResults), len(s.Endpoints)) 77 | } 78 | 79 | // UpdateSuiteStatus persists the suite result in the database 80 | func UpdateSuiteStatus(s *suite.Suite, result *suite.Result) { 81 | if err := store.Get().InsertSuiteResult(s, result); err != nil { 82 | logr.Errorf("[watchdog.executeSuite] Failed to insert suite result for suite=%s: %v", s.Name, err) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/certs/client/client.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq 3 | O59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy 4 | tQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ 5 | PQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu 6 | dY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl 7 | WnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33 8 | inppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC 9 | TXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l 10 | JJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ 11 | 4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW 12 | edQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA 13 | AQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE 14 | 7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9 15 | q1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k 16 | /fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y 17 | FI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR 18 | 047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5 19 | nfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK 20 | /WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH 21 | 2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX 22 | Sax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT 23 | 1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw 24 | nqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0 25 | 0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw 26 | Sk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J 27 | NGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91 28 | 0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF 29 | QdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU 30 | hiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq 31 | u8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v 32 | ZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz 33 | yPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E 34 | uTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9 35 | F3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd 36 | e19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/ 37 | Jv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o 38 | Mwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM 39 | XgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7 40 | J2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK 41 | iWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi 42 | me7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP 43 | d/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ 44 | uDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc 45 | Wr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU 46 | EZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V 47 | cpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO 48 | NlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5 49 | rWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub 50 | jzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /.examples/docker-compose-mtls/certs/server/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ 3 | KaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK 4 | nVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb 5 | yDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65 6 | jFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT 7 | EV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+ 8 | oA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv 9 | PBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy 10 | jewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU 11 | nu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx 12 | LIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA 13 | AQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU 14 | 71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI 15 | +g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS 16 | l0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju 17 | TzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr 18 | WHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH 19 | YLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK 20 | VviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn 21 | 082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6 22 | 2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v 23 | kRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8 24 | 7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE 25 | eLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo 26 | /F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N 27 | 0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz 28 | 6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L 29 | IcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX 30 | 1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX 31 | Dkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz 32 | zTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A 33 | LLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG 34 | V/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ 35 | gK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2 36 | qPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I 37 | x+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT 38 | 4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j 39 | YyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx 40 | i6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y 41 | f2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo 42 | x7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad 43 | twnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW 44 | FMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo 45 | sdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd 46 | zH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt 47 | bpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq 48 | WaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u 49 | Elr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9 50 | pix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /config/gontext/gontext.go: -------------------------------------------------------------------------------- 1 | package gontext 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | var ( 11 | // ErrGontextPathNotFound is returned when a gontext path doesn't exist 12 | ErrGontextPathNotFound = errors.New("gontext path not found") 13 | ) 14 | 15 | // Gontext holds values that can be shared between endpoints in a suite 16 | type Gontext struct { 17 | mu sync.RWMutex 18 | values map[string]interface{} 19 | } 20 | 21 | // New creates a new gontext with initial values 22 | func New(initial map[string]interface{}) *Gontext { 23 | if initial == nil { 24 | initial = make(map[string]interface{}) 25 | } 26 | // Create a deep copy to avoid external modifications 27 | values := make(map[string]interface{}) 28 | for k, v := range initial { 29 | values[k] = deepCopyValue(v) 30 | } 31 | return &Gontext{ 32 | values: values, 33 | } 34 | } 35 | 36 | // Get retrieves a value from the gontext using dot notation 37 | func (g *Gontext) Get(path string) (interface{}, error) { 38 | g.mu.RLock() 39 | defer g.mu.RUnlock() 40 | parts := strings.Split(path, ".") 41 | current := interface{}(g.values) 42 | for _, part := range parts { 43 | switch v := current.(type) { 44 | case map[string]interface{}: 45 | val, exists := v[part] 46 | if !exists { 47 | return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path) 48 | } 49 | current = val 50 | default: 51 | return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path) 52 | } 53 | } 54 | return current, nil 55 | } 56 | 57 | // Set stores a value in the gontext using dot notation 58 | func (g *Gontext) Set(path string, value interface{}) error { 59 | g.mu.Lock() 60 | defer g.mu.Unlock() 61 | parts := strings.Split(path, ".") 62 | if len(parts) == 0 { 63 | return errors.New("empty path") 64 | } 65 | // Navigate to the parent of the target 66 | current := g.values 67 | for i := 0; i < len(parts)-1; i++ { 68 | part := parts[i] 69 | if next, exists := current[part]; exists { 70 | if nextMap, ok := next.(map[string]interface{}); ok { 71 | current = nextMap 72 | } else { 73 | // Path exists but is not a map, create a new map 74 | newMap := make(map[string]interface{}) 75 | current[part] = newMap 76 | current = newMap 77 | } 78 | } else { 79 | // Create intermediate maps 80 | newMap := make(map[string]interface{}) 81 | current[part] = newMap 82 | current = newMap 83 | } 84 | } 85 | // Set the final value 86 | current[parts[len(parts)-1]] = value 87 | return nil 88 | } 89 | 90 | // GetAll returns a copy of all gontext values 91 | func (g *Gontext) GetAll() map[string]interface{} { 92 | g.mu.RLock() 93 | defer g.mu.RUnlock() 94 | 95 | result := make(map[string]interface{}) 96 | for k, v := range g.values { 97 | result[k] = deepCopyValue(v) 98 | } 99 | return result 100 | } 101 | 102 | // deepCopyValue creates a deep copy of a value 103 | func deepCopyValue(v interface{}) interface{} { 104 | switch val := v.(type) { 105 | case map[string]interface{}: 106 | newMap := make(map[string]interface{}) 107 | for k, v := range val { 108 | newMap[k] = deepCopyValue(v) 109 | } 110 | return newMap 111 | case []interface{}: 112 | newSlice := make([]interface{}, len(val)) 113 | for i, v := range val { 114 | newSlice[i] = deepCopyValue(v) 115 | } 116 | return newSlice 117 | default: 118 | // For primitive types, return as-is (they're passed by value anyway) 119 | return val 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /storage/store/common/paging/suite_status_params_test.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewSuiteStatusParams(t *testing.T) { 8 | params := NewSuiteStatusParams() 9 | if params == nil { 10 | t.Fatal("NewSuiteStatusParams should not return nil") 11 | } 12 | if params.Page != 1 { 13 | t.Errorf("expected default Page to be 1, got %d", params.Page) 14 | } 15 | if params.PageSize != 20 { 16 | t.Errorf("expected default PageSize to be 20, got %d", params.PageSize) 17 | } 18 | } 19 | 20 | func TestSuiteStatusParams_WithPagination(t *testing.T) { 21 | tests := []struct { 22 | name string 23 | page int 24 | pageSize int 25 | expectedPage int 26 | expectedSize int 27 | }{ 28 | { 29 | name: "valid pagination", 30 | page: 2, 31 | pageSize: 50, 32 | expectedPage: 2, 33 | expectedSize: 50, 34 | }, 35 | { 36 | name: "zero page", 37 | page: 0, 38 | pageSize: 10, 39 | expectedPage: 0, 40 | expectedSize: 10, 41 | }, 42 | { 43 | name: "negative page", 44 | page: -1, 45 | pageSize: 20, 46 | expectedPage: -1, 47 | expectedSize: 20, 48 | }, 49 | { 50 | name: "zero page size", 51 | page: 1, 52 | pageSize: 0, 53 | expectedPage: 1, 54 | expectedSize: 0, 55 | }, 56 | { 57 | name: "negative page size", 58 | page: 1, 59 | pageSize: -10, 60 | expectedPage: 1, 61 | expectedSize: -10, 62 | }, 63 | { 64 | name: "large values", 65 | page: 1000, 66 | pageSize: 10000, 67 | expectedPage: 1000, 68 | expectedSize: 10000, 69 | }, 70 | } 71 | 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | params := NewSuiteStatusParams().WithPagination(tt.page, tt.pageSize) 75 | if params.Page != tt.expectedPage { 76 | t.Errorf("expected Page to be %d, got %d", tt.expectedPage, params.Page) 77 | } 78 | if params.PageSize != tt.expectedSize { 79 | t.Errorf("expected PageSize to be %d, got %d", tt.expectedSize, params.PageSize) 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func TestSuiteStatusParams_ChainedMethods(t *testing.T) { 86 | params := NewSuiteStatusParams(). 87 | WithPagination(3, 100) 88 | 89 | if params.Page != 3 { 90 | t.Errorf("expected Page to be 3, got %d", params.Page) 91 | } 92 | if params.PageSize != 100 { 93 | t.Errorf("expected PageSize to be 100, got %d", params.PageSize) 94 | } 95 | } 96 | 97 | func TestSuiteStatusParams_OverwritePagination(t *testing.T) { 98 | params := NewSuiteStatusParams() 99 | 100 | // Set initial pagination 101 | params.WithPagination(2, 50) 102 | if params.Page != 2 || params.PageSize != 50 { 103 | t.Error("initial pagination not set correctly") 104 | } 105 | 106 | // Overwrite pagination 107 | params.WithPagination(5, 200) 108 | if params.Page != 5 { 109 | t.Errorf("expected Page to be overwritten to 5, got %d", params.Page) 110 | } 111 | if params.PageSize != 200 { 112 | t.Errorf("expected PageSize to be overwritten to 200, got %d", params.PageSize) 113 | } 114 | } 115 | 116 | func TestSuiteStatusParams_ReturnsSelf(t *testing.T) { 117 | params := NewSuiteStatusParams() 118 | 119 | // Verify WithPagination returns the same instance 120 | result := params.WithPagination(1, 20) 121 | if result != params { 122 | t.Error("WithPagination should return the same instance for method chaining") 123 | } 124 | } -------------------------------------------------------------------------------- /api/spa_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/TwiN/gatus/v5/config" 12 | "github.com/TwiN/gatus/v5/config/endpoint" 13 | "github.com/TwiN/gatus/v5/config/ui" 14 | "github.com/TwiN/gatus/v5/storage/store" 15 | "github.com/TwiN/gatus/v5/watchdog" 16 | ) 17 | 18 | func TestSinglePageApplication(t *testing.T) { 19 | defer store.Get().Clear() 20 | defer cache.Clear() 21 | cfg := &config.Config{ 22 | Metrics: true, 23 | Endpoints: []*endpoint.Endpoint{ 24 | { 25 | Name: "frontend", 26 | Group: "core", 27 | }, 28 | { 29 | Name: "backend", 30 | Group: "core", 31 | }, 32 | }, 33 | UI: &ui.Config{ 34 | Title: "example-title", 35 | }, 36 | } 37 | watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) 38 | watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) 39 | api := New(cfg) 40 | router := api.Router() 41 | type Scenario struct { 42 | Name string 43 | Path string 44 | Gzip bool 45 | CookieDarkMode bool 46 | UIDarkMode bool 47 | ExpectedCode int 48 | ExpectedDarkTheme bool 49 | } 50 | scenarios := []Scenario{ 51 | { 52 | Name: "frontend-home", 53 | Path: "/", 54 | CookieDarkMode: true, 55 | UIDarkMode: false, 56 | ExpectedDarkTheme: true, 57 | ExpectedCode: 200, 58 | }, 59 | { 60 | Name: "frontend-endpoint-light", 61 | Path: "/endpoints/core_frontend", 62 | CookieDarkMode: false, 63 | UIDarkMode: false, 64 | ExpectedDarkTheme: false, 65 | ExpectedCode: 200, 66 | }, 67 | { 68 | Name: "frontend-endpoint-dark", 69 | Path: "/endpoints/core_frontend", 70 | CookieDarkMode: false, 71 | UIDarkMode: true, 72 | ExpectedDarkTheme: true, 73 | ExpectedCode: 200, 74 | }, 75 | } 76 | for _, scenario := range scenarios { 77 | t.Run(scenario.Name, func(t *testing.T) { 78 | cfg.UI.DarkMode = &scenario.UIDarkMode 79 | request := httptest.NewRequest("GET", scenario.Path, http.NoBody) 80 | if scenario.Gzip { 81 | request.Header.Set("Accept-Encoding", "gzip") 82 | } 83 | if scenario.CookieDarkMode { 84 | request.Header.Set("Cookie", "theme=dark") 85 | } 86 | response, err := router.Test(request) 87 | if err != nil { 88 | return 89 | } 90 | defer response.Body.Close() 91 | if response.StatusCode != scenario.ExpectedCode { 92 | t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode) 93 | } 94 | body, _ := io.ReadAll(response.Body) 95 | strBody := string(body) 96 | if !strings.Contains(strBody, cfg.UI.Title) { 97 | t.Errorf("%s %s should have contained the title", request.Method, request.URL) 98 | } 99 | if scenario.ExpectedDarkTheme && !strings.Contains(strBody, "class=\"dark\"") { 100 | t.Errorf("%s %s should have responded with dark mode headers", request.Method, request.URL) 101 | } 102 | if !scenario.ExpectedDarkTheme && strings.Contains(strBody, "class=\"dark\"") { 103 | t.Errorf("%s %s should not have responded with dark mode headers", request.Method, request.URL) 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /security/oidc_test.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/coreos/go-oidc/v3/oidc" 10 | ) 11 | 12 | func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) { 13 | c := &OIDCConfig{ 14 | IssuerURL: "https://sso.gatus.io/", 15 | RedirectURL: "http://localhost:80/authorization-code/callback", 16 | ClientID: "client-id", 17 | ClientSecret: "client-secret", 18 | Scopes: []string{"openid"}, 19 | AllowedSubjects: []string{"user1@example.com"}, 20 | SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL 21 | } 22 | if !c.ValidateAndSetDefaults() { 23 | t.Error("OIDCConfig should be valid") 24 | } 25 | if c.SessionTTL != DefaultOIDCSessionTTL { 26 | t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL") 27 | } 28 | } 29 | 30 | func TestOIDCConfig_callbackHandler(t *testing.T) { 31 | c := &OIDCConfig{ 32 | IssuerURL: "https://sso.gatus.io/", 33 | RedirectURL: "http://localhost:80/authorization-code/callback", 34 | ClientID: "client-id", 35 | ClientSecret: "client-secret", 36 | Scopes: []string{"openid"}, 37 | AllowedSubjects: []string{"user1@example.com"}, 38 | } 39 | if err := c.initialize(); err != nil { 40 | t.Fatal("expected no error, but got", err) 41 | } 42 | // Try with no state cookie 43 | request, _ := http.NewRequest("GET", "/authorization-code/callback", nil) 44 | responseRecorder := httptest.NewRecorder() 45 | c.callbackHandler(responseRecorder, request) 46 | if responseRecorder.Code != http.StatusBadRequest { 47 | t.Error("expected code to be 400, but was", responseRecorder.Code) 48 | } 49 | // Try with state cookie 50 | request, _ = http.NewRequest("GET", "/authorization-code/callback", nil) 51 | request.AddCookie(&http.Cookie{Name: cookieNameState, Value: "fake-state"}) 52 | responseRecorder = httptest.NewRecorder() 53 | c.callbackHandler(responseRecorder, request) 54 | if responseRecorder.Code != http.StatusBadRequest { 55 | t.Error("expected code to be 400, but was", responseRecorder.Code) 56 | } 57 | // Try with state cookie and state query parameter 58 | request, _ = http.NewRequest("GET", "/authorization-code/callback?state=fake-state", nil) 59 | request.AddCookie(&http.Cookie{Name: cookieNameState, Value: "fake-state"}) 60 | responseRecorder = httptest.NewRecorder() 61 | c.callbackHandler(responseRecorder, request) 62 | // Exchange should fail, so 500. 63 | if responseRecorder.Code != http.StatusInternalServerError { 64 | t.Error("expected code to be 500, but was", responseRecorder.Code) 65 | } 66 | } 67 | 68 | func TestOIDCConfig_setSessionCookie(t *testing.T) { 69 | c := &OIDCConfig{} 70 | responseRecorder := httptest.NewRecorder() 71 | c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"}) 72 | if len(responseRecorder.Result().Cookies()) == 0 { 73 | t.Error("expected cookie to be set") 74 | } 75 | } 76 | 77 | func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) { 78 | customTTL := 30 * time.Minute 79 | c := &OIDCConfig{SessionTTL: customTTL} 80 | responseRecorder := httptest.NewRecorder() 81 | c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"}) 82 | cookies := responseRecorder.Result().Cookies() 83 | if len(cookies) == 0 { 84 | t.Error("expected cookie to be set") 85 | } 86 | sessionCookie := cookies[0] 87 | if sessionCookie.MaxAge != int(customTTL.Seconds()) { 88 | t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /watchdog/endpoint.go: -------------------------------------------------------------------------------- 1 | package watchdog 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/TwiN/gatus/v5/config" 8 | "github.com/TwiN/gatus/v5/config/endpoint" 9 | "github.com/TwiN/gatus/v5/metrics" 10 | "github.com/TwiN/gatus/v5/storage/store" 11 | "github.com/TwiN/logr" 12 | ) 13 | 14 | // monitorEndpoint a single endpoint in a loop 15 | func monitorEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []string, ctx context.Context) { 16 | // Run it immediately on start 17 | executeEndpoint(ep, cfg, extraLabels) 18 | // Loop for the next executions 19 | ticker := time.NewTicker(ep.Interval) 20 | defer ticker.Stop() 21 | for { 22 | select { 23 | case <-ctx.Done(): 24 | logr.Warnf("[watchdog.monitorEndpoint] Canceling current execution of group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key()) 25 | return 26 | case <-ticker.C: 27 | executeEndpoint(ep, cfg, extraLabels) 28 | } 29 | } 30 | // Just in case somebody wandered all the way to here and wonders, "what about ExternalEndpoints?" 31 | // Alerting is checked every time an external endpoint is pushed to Gatus, so they're not monitored 32 | // periodically like they are for normal endpoints. 33 | } 34 | 35 | func executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []string) { 36 | // Acquire semaphore to limit concurrent endpoint monitoring 37 | if err := monitoringSemaphore.Acquire(ctx, 1); err != nil { 38 | // Only fails if context is cancelled (during shutdown) 39 | logr.Debugf("[watchdog.executeEndpoint] Context cancelled, skipping execution: %s", err.Error()) 40 | return 41 | } 42 | defer monitoringSemaphore.Release(1) 43 | // If there's a connectivity checker configured, check if Gatus has internet connectivity 44 | if cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() { 45 | logr.Infof("[watchdog.executeEndpoint] No connectivity; skipping execution") 46 | return 47 | } 48 | logr.Debugf("[watchdog.executeEndpoint] Monitoring group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key()) 49 | result := ep.EvaluateHealth() 50 | if cfg.Metrics { 51 | metrics.PublishMetricsForEndpoint(ep, result, extraLabels) 52 | } 53 | UpdateEndpointStatus(ep, result) 54 | if logr.GetThreshold() == logr.LevelDebug && !result.Success { 55 | logr.Debugf("[watchdog.executeEndpoint] Monitored group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s; body=%s", ep.Group, ep.Name, ep.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond), result.Body) 56 | } else { 57 | logr.Infof("[watchdog.executeEndpoint] Monitored group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s", ep.Group, ep.Name, ep.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond)) 58 | } 59 | inEndpointMaintenanceWindow := false 60 | for _, maintenanceWindow := range ep.MaintenanceWindows { 61 | if maintenanceWindow.IsUnderMaintenance() { 62 | logr.Debug("[watchdog.executeEndpoint] Under endpoint maintenance window") 63 | inEndpointMaintenanceWindow = true 64 | } 65 | } 66 | if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow { 67 | HandleAlerting(ep, result, cfg.Alerting) 68 | } else { 69 | logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window") 70 | } 71 | logr.Debugf("[watchdog.executeEndpoint] Waiting for interval=%s before monitoring group=%s endpoint=%s (key=%s) again", ep.Interval, ep.Group, ep.Name, ep.Key()) 72 | } 73 | 74 | // UpdateEndpointStatus persists the endpoint result in the storage 75 | func UpdateEndpointStatus(ep *endpoint.Endpoint, result *endpoint.Result) { 76 | if err := store.Get().InsertEndpointResult(ep, result); err != nil { 77 | logr.Errorf("[watchdog.UpdateEndpointStatus] Failed to insert result in storage: %s", err.Error()) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /config/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "math" 8 | ) 9 | 10 | const ( 11 | // DefaultAddress is the default address the application will bind to 12 | DefaultAddress = "0.0.0.0" 13 | 14 | // DefaultPort is the default port the application will listen on 15 | DefaultPort = 8080 16 | 17 | // DefaultReadBufferSize is the default value for ReadBufferSize 18 | DefaultReadBufferSize = 8192 19 | 20 | // MinimumReadBufferSize is the minimum value for ReadBufferSize, and also the default value set 21 | // for fiber.Config.ReadBufferSize 22 | MinimumReadBufferSize = 4096 23 | ) 24 | 25 | // Config is the structure which supports the configuration of the server listening to requests 26 | type Config struct { 27 | // Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress) 28 | Address string `yaml:"address"` 29 | 30 | // Port to listen on (default to 8080 specified by DefaultPort) 31 | Port int `yaml:"port"` 32 | 33 | // ReadBufferSize sets fiber.Config.ReadBufferSize, which is the buffer size for reading requests coming from a 34 | // single connection and also acts as a limit for the maximum header size. 35 | // 36 | // If you're getting occasional "Request Header Fields Too Large", you may want to try increasing this value. 37 | // 38 | // Defaults to DefaultReadBufferSize 39 | ReadBufferSize int `yaml:"read-buffer-size,omitempty"` 40 | 41 | // TLS configuration (optional) 42 | TLS *TLSConfig `yaml:"tls,omitempty"` 43 | } 44 | 45 | type TLSConfig struct { 46 | // CertificateFile is the public certificate for TLS in PEM format. 47 | CertificateFile string `yaml:"certificate-file,omitempty"` 48 | 49 | // PrivateKeyFile is the private key file for TLS in PEM format. 50 | PrivateKeyFile string `yaml:"private-key-file,omitempty"` 51 | } 52 | 53 | // GetDefaultConfig returns a Config struct with the default values 54 | func GetDefaultConfig() *Config { 55 | return &Config{ 56 | Address: DefaultAddress, 57 | Port: DefaultPort, 58 | ReadBufferSize: DefaultReadBufferSize, 59 | } 60 | } 61 | 62 | // ValidateAndSetDefaults validates the web configuration and sets the default values if necessary. 63 | func (web *Config) ValidateAndSetDefaults() error { 64 | // Validate the Address 65 | if len(web.Address) == 0 { 66 | web.Address = DefaultAddress 67 | } 68 | // Validate the Port 69 | if web.Port == 0 { 70 | web.Port = DefaultPort 71 | } else if web.Port < 0 || web.Port > math.MaxUint16 { 72 | return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16) 73 | } 74 | // Validate ReadBufferSize 75 | if web.ReadBufferSize == 0 { 76 | web.ReadBufferSize = DefaultReadBufferSize // Not set? Use the default value. 77 | } else if web.ReadBufferSize < MinimumReadBufferSize { 78 | web.ReadBufferSize = MinimumReadBufferSize // Below the minimum? Use the minimum value. 79 | } 80 | // Try to load the TLS certificates 81 | if web.TLS != nil { 82 | if err := web.TLS.isValid(); err != nil { 83 | return fmt.Errorf("invalid tls config: %w", err) 84 | } 85 | } 86 | return nil 87 | } 88 | 89 | func (web *Config) HasTLS() bool { 90 | return web.TLS != nil && len(web.TLS.CertificateFile) > 0 && len(web.TLS.PrivateKeyFile) > 0 91 | } 92 | 93 | // SocketAddress returns the combination of the Address and the Port 94 | func (web *Config) SocketAddress() string { 95 | return fmt.Sprintf("%s:%d", web.Address, web.Port) 96 | } 97 | 98 | func (t *TLSConfig) isValid() error { 99 | if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 { 100 | _, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile) 101 | if err != nil { 102 | return err 103 | } 104 | return nil 105 | } 106 | return errors.New("certificate-file and private-key-file must be specified") 107 | } 108 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/TwiN/gatus/v5/config" 9 | "github.com/TwiN/gatus/v5/config/ui" 10 | "github.com/TwiN/gatus/v5/security" 11 | "github.com/gofiber/fiber/v2" 12 | ) 13 | 14 | func TestNew(t *testing.T) { 15 | type Scenario struct { 16 | Name string 17 | Path string 18 | ExpectedCode int 19 | Gzip bool 20 | WithSecurity bool 21 | } 22 | scenarios := []Scenario{ 23 | { 24 | Name: "health", 25 | Path: "/health", 26 | ExpectedCode: fiber.StatusOK, 27 | }, 28 | { 29 | Name: "custom.css", 30 | Path: "/css/custom.css", 31 | ExpectedCode: fiber.StatusOK, 32 | }, 33 | { 34 | Name: "custom.css-gzipped", 35 | Path: "/css/custom.css", 36 | ExpectedCode: fiber.StatusOK, 37 | Gzip: true, 38 | }, 39 | { 40 | Name: "metrics", 41 | Path: "/metrics", 42 | ExpectedCode: fiber.StatusOK, 43 | }, 44 | { 45 | Name: "favicon.ico", 46 | Path: "/favicon.ico", 47 | ExpectedCode: fiber.StatusOK, 48 | }, 49 | { 50 | Name: "app.js", 51 | Path: "/js/app.js", 52 | ExpectedCode: fiber.StatusOK, 53 | }, 54 | { 55 | Name: "app.js-gzipped", 56 | Path: "/js/app.js", 57 | ExpectedCode: fiber.StatusOK, 58 | Gzip: true, 59 | }, 60 | { 61 | Name: "chunk-vendors.js", 62 | Path: "/js/chunk-vendors.js", 63 | ExpectedCode: fiber.StatusOK, 64 | }, 65 | { 66 | Name: "chunk-vendors.js-gzipped", 67 | Path: "/js/chunk-vendors.js", 68 | ExpectedCode: fiber.StatusOK, 69 | Gzip: true, 70 | }, 71 | { 72 | Name: "index", 73 | Path: "/", 74 | ExpectedCode: fiber.StatusOK, 75 | }, 76 | { 77 | Name: "index-html-redirect", 78 | Path: "/index.html", 79 | ExpectedCode: fiber.StatusMovedPermanently, 80 | }, 81 | { 82 | Name: "index-should-return-200-even-if-not-authenticated", 83 | Path: "/", 84 | ExpectedCode: fiber.StatusOK, 85 | WithSecurity: true, 86 | }, 87 | { 88 | Name: "endpoints-should-return-401-if-not-authenticated", 89 | Path: "/api/v1/endpoints/statuses", 90 | ExpectedCode: fiber.StatusUnauthorized, 91 | WithSecurity: true, 92 | }, 93 | { 94 | Name: "config-should-return-200-even-if-not-authenticated", 95 | Path: "/api/v1/config", 96 | ExpectedCode: fiber.StatusOK, 97 | WithSecurity: true, 98 | }, 99 | { 100 | Name: "config-should-always-return-200", 101 | Path: "/api/v1/config", 102 | ExpectedCode: fiber.StatusOK, 103 | WithSecurity: false, 104 | }, 105 | } 106 | for _, scenario := range scenarios { 107 | t.Run(scenario.Name, func(t *testing.T) { 108 | cfg := &config.Config{Metrics: true, UI: &ui.Config{}} 109 | if scenario.WithSecurity { 110 | cfg.Security = &security.Config{ 111 | Basic: &security.BasicConfig{ 112 | Username: "john.doe", 113 | PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT", 114 | }, 115 | } 116 | } 117 | api := New(cfg) 118 | router := api.Router() 119 | request := httptest.NewRequest("GET", scenario.Path, http.NoBody) 120 | if scenario.Gzip { 121 | request.Header.Set("Accept-Encoding", "gzip") 122 | } 123 | response, err := router.Test(request) 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | if response.StatusCode != scenario.ExpectedCode { 128 | t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode) 129 | } 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /web/app/src/components/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | --------------------------------------------------------------------------------