├── .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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/web/app/src/components/ui/card/Card.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/web/app/src/components/ui/input/Input.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
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 |
2 |
9 |
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 |
2 |
3 |
4 |
5 |
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 | 
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 |
2 |
3 |
4 | {{ label }}
5 |
6 |
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 |
2 |
6 |
7 |
8 |
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 |
2 |
3 |
10 |
11 | Previous
12 |
13 |
14 |
15 | Page {{ currentPage }} of {{ maxPages }}
16 |
17 |
18 |
25 | Next
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/web/static/index.html:
--------------------------------------------------------------------------------
1 | {{ .UI.Title }} Enable JavaScript to view this page.
--------------------------------------------------------------------------------
/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: "
34 | Enable JavaScript to view this page.
35 |
36 | ",
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 |
37 |