Compare commits

...

53 Commits

Author SHA1 Message Date
Salim B
6d579a4b48 docs: improve extra-labels description (#1208)
* docs: improve `extra-labels` description

* Update README.md

* Update README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2025-08-18 14:18:57 -04:00
TwiN
2c42aa8087 fix(ui): Update endpoints in-place instead of reloading every time (#1209)
* fix(ui): Update endpoints in-place instead of reloading every time

Fixes #1207

* Regenerate static assets
2025-08-18 12:36:16 -04:00
TwiN
12825a2b6f ci: Fix typo in if statement 2025-08-18 12:18:24 -04:00
dependabot[bot]
e1edc15337 chore(deps): bump actions/checkout from 4 to 5 (#1197)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-17 18:33:18 -04:00
Bo-Yi Wu
d17f51a1a1 feat(alerting): Support extra labels in email alerts (#1191)
feat: add support for extra labels in alert email notifications

- Add support for including extra labels in email alert messages if present
- Update tests to cover cases with and without extra labels in alert emails

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-08-17 12:28:04 -04:00
TwiN
a9fb48b26c chore: Regenerate static content 2025-08-16 10:01:13 -04:00
TwiN
131447f702 feat: Implement announcements (#1204)
* feat: Implement announcements

Fixes #1203

* Remove unnecessary code

* Fix new announcement test

* Update web/app/src/views/Home.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove useless garbage

* Require announcement timestamp

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-16 09:54:50 -04:00
TwiN
609a634df3 ci: Tag area/ui if the PR title has ui in it 2025-08-14 16:48:41 -04:00
TwiN
6c28de6950 fix(ui): Replace filter value "nothing" by "none" (#1202) 2025-08-14 16:44:17 -04:00
TwiN
440b732c71 feat(ui): New status page UI (#1198)
* feat(ui): New status page UI

* docs: Rename labels to extra-labels

* Fix domain expiration test

* feat(ui): Add ui.default-sort-by and ui.default-filter-by

* Change ui.header default value to Gatus

* Re-use EndpointCard in Details.vue as well to avoid duplicate code

* Fix flaky metrics test

* Add subtle green color to "Gatus"

* Remove duplicate title (tooltip is sufficient, no need for title on top of that)

* Fix collapsed group user preferences

* Update status page screenshots
2025-08-14 09:15:34 -04:00
Salim B
8d63462fcd docs: tweak minimum-reminder-interval description (#1194) 2025-08-11 10:43:34 -04:00
TwiN
daf67dc1e6 perf: Cap RANDOM_STRING_N to 8182 (#1193) 2025-08-11 09:36:55 -04:00
TwiN
3ebed01b4c docs: Add section for gatus-sdk (#1189) 2025-08-10 10:18:10 -04:00
TwiN
a2f5516b06 ci(labeler): Support documentation label 2025-08-10 10:18:02 -04:00
dependabot[bot]
a68e7e39bd chore(deps): bump modernc.org/sqlite from 1.38.0 to 1.38.2 (#1188)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.38.0 to 1.38.2.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.38.0...v1.38.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-09 21:27:12 -04:00
Sean Kelly
f9d7320a2a docs: Add example for alerts[].minimum-reminder-interval (#1187)
Update README.md for minimum-reminder-interval
2025-08-09 10:41:12 -04:00
Viktor Ziegler
c374649019 feat(alerting): Implement alert reminders (#1138)
* feat(alerting): add reminder-interval feature which allows setting an interval to run alert consecutively

* feat(test): add tests for reminder-interval feature

* feat(docs): modify documentation for reminder-interval feature

* chore: change "due" to "TRIGGERED" for easier log look through

* chore: update "reminder-interval" to "repeat-interval"

* chore: update reminder-interval to repeat-interval

* chore: adapt repeat interval feature after merge

* chore: adapt repeat interval feature after merge

* RepeatInterval => MinimumRepeatInterval

* fix merge issues

(cherry picked from commit 9b2161556bddf01d385f97dafac2515857190ae5)

* rename and move MiniumRepeatInterval

* move MiniumRepeatInterval (again)

---------

Co-authored-by: Bugra Kocabay <kocabay.bugra@gmail.com>
Co-authored-by: Bugra Kocabay <kocabaybugra@gmail.com>
Co-authored-by: Konstantin Nosov <nosovk@gmail.com>
Co-authored-by: Viktor Ziegler <Viktor.Ziegler@ti8m.ch>
Co-authored-by: TwiN <twin@linux.com>
2025-08-07 21:35:44 -04:00
dependabot[bot]
f6e938746f chore(deps): bump github.com/aws/aws-sdk-go from 1.55.7 to 1.55.8 (#1183)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.55.7 to 1.55.8.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Changelog](https://github.com/aws/aws-sdk-go/blob/main/CHANGELOG_PENDING.md)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.55.7...v1.55.8)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-version: 1.55.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-07 21:20:10 -04:00
TwiN
2c6fede468 ci: Bump timeout from 2h to 4h
The speed of the runners is getting worse every day...
2025-08-06 16:21:38 -04:00
Bo-Yi Wu
9205cb2890 fix(metrics): improve Prometheus metrics registration (#1186)
feat: improve Prometheus metrics registration and cleanup

- Add a function to unregister all previously registered Prometheus metrics
- Track metric initialization state to prevent duplicate registration
- Ensure metrics are unregistered before re-initializing them
- Store the current registerer for proper metric cleanup
- Call the new unregister function during application stop

ref: https://github.com/TwiN/gatus/pull/979#issuecomment-3157044249

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-08-06 08:04:08 -04:00
Bo-Yi Wu
6a9cbb1728 feat(metrics): add support for custom labels in Prometheus metrics (#979)
* feat: add dynamic labels support for Prometheus metrics

- Add `toBoolPtr` function to convert a bool to a bool pointer
- Add `contains` function to check if a key exists in a slice
- Add `GetMetricLabels` method to `Config` struct to return unique metric labels from enabled endpoints
- Change file permission notation from `0644` to `0o644` in `config_test.go`
- Add `Labels` field to `Endpoint` struct for key-value pairs
- Initialize Prometheus metrics with dynamic labels from configuration
- Modify `PublishMetricsForEndpoint` to include dynamic labels
- Add test for `GetMetricLabels` method in `config_test.go`
- Update `watchdog` to pass labels to monitoring and execution functions

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor pointer conversion utility and update related tests

- Rename `toBoolPtr` function to a generic `toPtr` function
- Update tests to use the new `toPtr` function instead of `toBoolPtr`

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor utility functions and improve test coverage

- Move `toPtr` and `contains` utility functions to a new file `util.go`

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* missing labels parameter

* refactor: reorder parameters in metrics-related functions and tests

- Reorder parameters in `PublishMetricsForEndpoint` function
- Update test cases to match the new parameter order in `PublishMetricsForEndpoint`
- Reorder parameters in `monitor` function
- Adjust `monitor` function calls to match the new parameter order
- Reorder parameters in `execute` function call to `PublishMetricsForEndpoint`

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update main.go

* Update config/config.go

* docs: improve documentation formatting, examples, and readability

- Add multiple blank lines for spacing in the README file
- Fix formatting issues in markdown tables
- Correct deprecated formatting for Teams alerts
- Replace single quotes with double quotes in JSON examples
- Add new sections and examples for various configurations and endpoints
- Improve readability and consistency in the documentation
- Update links and references to examples and configurations

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* docs: enhance custom labels support in Prometheus metrics

- Add a section for custom labels in the README
- Include an example configuration for custom labels in Prometheus metrics initialization

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* refactor: rename and refactor metric labels to use ExtraLabels

- Rename the endpoint metric labels field from Labels to ExtraLabels and update its YAML tag accordingly
- Update code and tests to use ExtraLabels instead of Labels for metrics
- Replace GetMetricLabels with GetUniqueExtraMetricLabels and adjust usages throughout the codebase
- Ensure all metric publishing and monitoring functions accept and use the new extraLabels naming and semantics
- Update tests to verify correct extraction and handling of ExtraLabels for enabled endpoints

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor parameter order for monitor and execute for consistency

- Change the order of parameters for monitor and execute functions to group extraLabels consistently as the last argument before context.
- Update all relevant function calls and signatures to reflect the new parameter order.
- Replace usage of labels with extraLabels for clarity and consistency.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* test: improve initialization and labeling of Prometheus metrics

- Add a test to verify that Prometheus metrics initialize correctly with extra labels.
- Ensure metrics variables are properly initialized and not nil.
- Check that WithLabelValues accepts both default and extra labels without causing a panic.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* test: improve Prometheus metrics testing for extra label handling

- Remove a redundant test for WithLabelValues label length.
- Add a new test to verify that extraLabels are correctly included in exported Prometheus metrics.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor metrics to support custom Prometheus registries

- Refactor metrics initialization to accept a custom Prometheus registry, defaulting to the global registry when nil
- Replace promauto with direct metric construction and explicit registration
- Update tests to use dedicated, isolated registries instead of the default global registry

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* Revert README.md to a previous version

* docs: document support for custom metric labels in endpoints

- Add documentation section explaining support for custom labels on metrics
- Provide YAML configuration example illustrating the new labels field for endpoints
- Update table of contents to include the custom labels section

Signed-off-by: appleboy <appleboy.tw@gmail.com>

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-05 12:26:50 -04:00
TwiN
4667fdbc15 chore: Use the right logging library 2025-08-02 17:31:53 -04:00
dependabot[bot]
501b71cab5 chore(deps): bump github.com/miekg/dns from 1.1.66 to 1.1.67 (#1166)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.66 to 1.1.67.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.66...v1.1.67)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.67
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-02 14:54:41 -04:00
dependabot[bot]
196be2b89c chore(deps): bump github.com/valyala/fasthttp from 1.62.0 to 1.64.0 (#1162)
---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-02 14:07:46 -04:00
Olexandr Dovgun
d27c63ded7 feat(websocket): add support for custom headers in WS check (#1180)
feat(websocket): add support for custom headers in QueryWebSocket function
2025-08-02 14:06:46 -04:00
Sevan
8c5ad54e71 chore(deps): Bump TwiN/whois dependency to v1.1.11 (#1175) 2025-07-31 09:05:20 -04:00
TwiN
6f9a2c7c32 feat(external-endpoint): Implement heartbeat (#1173)
Fixes #741
2025-07-30 12:18:10 -04:00
Yaroslav
aa08321239 fix(websocket): use tls config (#1165)
Co-authored-by: TwiN <twin@linux.com>
2025-07-29 14:49:36 -04:00
dependabot[bot]
ad5197f037 chore(deps): bump google.golang.org/api from 0.236.0 to 0.242.0 (#1164)
---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.242.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-20 23:29:29 -04:00
jasonshugart
bdaffbca77 feat: Add body to TCP, UDP, and TLS endpoints and templating (#1134)
* feat(endpoints): Add body to TCP, UDP, and TLS endpoints and templating

* Changed the template to be more consistent with the
rest of the application and added additional substritutions.

* Changed getModifiedBody to getParsedBody and fixed connected response

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Update client/client.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-07-19 21:24:03 -04:00
Jon Fuller
f4a667549e docs: Update example Grafana dashboard (#1163)
* Update gatus.json

* Update gatus.json
2025-07-16 18:54:56 -04:00
Dmitry
00419a4b4a feat(alerting): Support sending messages to Telegram topics in group (#928)
* feat(alerting): Added the ability to send messages to Telegram topics in groups.

* feat(alerting): Added the ability to send messages to Telegram topics in groups.

fix tests

* feat(alerting): Added the ability to send messages to Telegram topics in groups.

Rename TopicId to TopicID

* feat(alerting): Added the ability to send messages to Telegram topics in groups.

Fixed description for alerting.telegram.topic-id in README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2025-07-11 13:55:42 -04:00
TwiN
7c27fcb895 fix: Convert key to lowercase when looking up endpoint by key (#1150) 2025-07-08 12:21:56 -04:00
TwiN
3db5894e90 fix: Limit the pageSize to maximum-number-of-results on first page (#1149) 2025-07-08 12:08:27 -04:00
Bryan Cross
9b1d15c9e0 feat(api): Add optional duration to external endpoint results (#1092)
* feat(api): Add optional duration to external endpoint results

* Fix failing tests

* Parse duration regardless of success

* Use len instead of equality

* Update README.md

* Include error in output

* Fix result numbering

* Update README.md

* Update api/external_endpoint.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-07-08 11:53:57 -04:00
TwiN
1855718e46 ci: Increase workflow timeouts because GHA instances seems to have gotten slower 2025-07-01 18:28:49 -04:00
TwiN
d5f2d92e8e fix(ui): Explicitly omit MaximumNumberOfResults from being parsed to yaml in UI config 2025-07-01 18:04:37 -04:00
dependabot[bot]
20d1011a20 chore(deps): bump github.com/aws/aws-sdk-go from 1.55.6 to 1.55.7 (#1143)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.55.6 to 1.55.7.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Changelog](https://github.com/aws/aws-sdk-go/blob/main/CHANGELOG_PENDING.md)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.55.6...v1.55.7)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-version: 1.55.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 12:32:31 -04:00
dependabot[bot]
0888094fdb chore(deps): bump modernc.org/sqlite from 1.37.0 to 1.38.0 (#1135)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.37.0 to 1.38.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.37.0...v1.38.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-29 13:39:34 -04:00
dependabot[bot]
3f51536eaf chore(deps): bump golang.org/x/net from 0.40.0 to 0.41.0 (#1142)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/net/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-27 20:16:34 -04:00
dependabot[bot]
d8a1da81f0 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.6 to 2.52.8 (#1141)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.6 to 2.52.8.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.6...v2.52.8)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-version: 2.52.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 22:18:20 -04:00
dependabot[bot]
25b178bf94 chore(deps): bump github.com/miekg/dns from 1.1.65 to 1.1.66 (#1137)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.65 to 1.1.66.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.65...v1.1.66)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.66
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 21:06:55 -04:00
Tim Nguyen Van
e8e0b0f71c feat(alerting): Add ilert alert provider (#1082)
* feat(alerting): Add ilert alert provider

* fix: removed additional endpoint from Readme

* fix: removed newline

Co-authored-by: TwiN <twin@linux.com>

* fix: formatting

* fix: restAPIUrl typo

* fix: removed type field, adjusted tests

---------

Co-authored-by: TwiN <twin@linux.com>
2025-06-23 20:56:30 -04:00
dependabot[bot]
439ccaa372 chore(deps): bump github.com/prometheus/client_golang from 1.21.1 to 1.22.0 (#1136)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.1 to 1.22.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.21.1...v1.22.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-17 20:57:12 -04:00
dependabot[bot]
1bb490e068 chore(deps): bump github.com/valyala/fasthttp from 1.60.0 to 1.62.0 (#1101)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.60.0 to 1.62.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.60.0...v1.62.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 14:04:30 -04:00
dependabot[bot]
b78f3f85b0 chore(deps): bump golang.org/x/crypto from 0.38.0 to 0.39.0 (#1133)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-13 22:48:27 -04:00
TwiN
f0034f88b7 ci: Adjust codecov target 2025-06-11 15:44:07 -04:00
TwiN
659b81663e chore(alerting): Add missing typecast checks for Gotify provider 2025-06-11 15:26:08 -04:00
Vlom
2f12088823 feat(alerting): Add HomeAssistant support (#1125)
* Base homeassistant alerting setup

* Update homeassistant alert documenation

* sort alert.TypeHomeAssistant to comply alphabetic policy

* Validate homeassistant as a provider interfaces implementation on compile

---------

Co-authored-by: TwiN <twin@linux.com>
2025-06-11 15:14:32 -04:00
dependabot[bot]
5b666f924c chore(deps): bump google.golang.org/api from 0.228.0 to 0.236.0 (#1132)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.228.0 to 0.236.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.228.0...v0.236.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.236.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 21:39:05 -04:00
dependabot[bot]
b296d4bf4c chore(deps): bump code.gitea.io/sdk/gitea from 0.19.0 to 0.21.0 (#1084)
Bumps code.gitea.io/sdk/gitea from 0.19.0 to 0.21.0.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 20:20:38 -04:00
pizzapim
2b80b80769 docs: Add NixOS example (#1131)
* Add NixOS example

* Fix link
2025-06-08 23:59:37 -04:00
TwiN
40c274d36a ci: Bump timeout-minutes from 60 to 120 2025-05-31 20:13:52 -04:00
105 changed files with 7633 additions and 2943 deletions

View File

@@ -0,0 +1,4 @@
# NixOS
Gatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example
usage.

23
.examples/nixos/gatus.nix Normal file
View File

@@ -0,0 +1,23 @@
{
services.gatus = {
enable = true;
settings = {
web.port = 8080;
endpoints = [
{
name = "website";
url = "https://twin.sh/health";
interval = "5m";
conditions = [
"[STATUS] == 200"
"[BODY].status == UP"
"[RESPONSE_TIME] < 300"
];
}
];
};
};
}

BIN
.github/assets/dashboard-conditions.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

BIN
.github/assets/dashboard-dark.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/assets/endpoint-groups.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

BIN
.github/assets/example.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

2
.github/codecov.yml vendored
View File

@@ -7,6 +7,6 @@ coverage:
patch: off
project:
default:
target: 75%
target: 70%
threshold: null

View File

@@ -18,13 +18,13 @@ jobs:
build:
name: benchmark
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
steps:
- uses: actions/setup-go@v5
with:
go-version: 1.24.1
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Benchmark
run: go test -bench=. ./storage/store

View File

@@ -26,10 +26,15 @@ jobs:
gh issue edit "$NUMBER" --add-label "feature"
elif [[ $TITLE == "fix"* ]]; then
gh issue edit "$NUMBER" --add-label "bug"
elif [[ $TITLE == "docs"* ]]; then
gh issue edit "$NUMBER" --add-label "documentation"
fi
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
gh issue edit "$NUMBER" --add-label "area/alerting"
fi
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
gh issue edit "$NUMBER" --add-label "area/ui"
fi
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
gh issue edit "$NUMBER" --add-label "area/storage"
fi
@@ -39,4 +44,3 @@ jobs:
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
gh issue edit "$NUMBER" --add-label "area/metrics"
fi

View File

@@ -8,9 +8,9 @@ on:
jobs:
publish-custom:
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@@ -3,9 +3,9 @@ on: [workflow_dispatch]
jobs:
publish-experimental:
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@@ -11,9 +11,9 @@ jobs:
publish-latest:
runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 90
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@@ -6,9 +6,9 @@ jobs:
publish-release:
name: publish-release
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@@ -11,8 +11,8 @@ on:
jobs:
test-ui:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- run: make frontend-install-dependencies
- run: make frontend-build

View File

@@ -14,12 +14,12 @@ on:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 30
steps:
- uses: actions/setup-go@v5
with:
go-version: 1.24.1
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build binary to make sure it works
run: go build
- name: Test

316
README.md
View File

@@ -32,7 +32,7 @@ For more details, see [Usage](#usage)
> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).
![Gatus dashboard](.github/assets/dashboard-dark.png)
![Gatus dashboard](.github/assets/dashboard-dark.jpg)
Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
@@ -59,6 +59,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring GitLab alerts](#configuring-gitlab-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Gotify alerts](#configuring-gotify-alerts)
- [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)
- [Configuring Ilert alerts](#configuring-ilert-alerts)
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
@@ -76,12 +78,14 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Announcements](#announcements)
- [Maintenance](#maintenance)
- [Security](#security)
- [Basic Authentication](#basic-authentication)
- [OIDC](#oidc)
- [TLS Encryption](#tls-encryption)
- [Metrics](#metrics)
- [Custom Labels](#custom-labels)
- [Connectivity](#connectivity)
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
- [Deployment](#deployment)
@@ -121,6 +125,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Response time (chart)](#response-time-chart)
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
- [API](#api)
- [Interacting with the API programmatically](#interacting-with-the-api-programmatically)
- [Raw Data](#raw-data)
- [Uptime](#uptime-1)
- [Response Time](#response-time-1)
@@ -158,7 +163,7 @@ The main features of Gatus are:
- **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
- **Dark mode**
![Gatus dashboard conditions](.github/assets/dashboard-conditions.png)
![Gatus dashboard conditions](.github/assets/dashboard-conditions.jpg)
## Usage
@@ -197,7 +202,7 @@ endpoints:
This example would look similar to this:
![Simple example](.github/assets/example.png)
![Simple example](.github/assets/example.jpg)
By default, the configuration file is expected to be at `config/config.yaml`.
@@ -218,34 +223,37 @@ If you want to test it locally, see [Docker](#docker).
## Configuration
| Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Health Status` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
| Parameter | Description | Default |
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `announcements` | [Announcements configuration](#announcements). | `[]` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
@@ -285,6 +293,14 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
You may use the following placeholders in the body (`endpoints[].body`):
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[LOCAL_ADDRESS]` (resolves to the local IP and port like `192.0.2.1:25` or `[2001:db8::1]:80`)
- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N (max: 8192))
### External Endpoints
@@ -296,14 +312,16 @@ For instance:
- You can monitor services that are not supported by Gatus
- You can implement your own monitoring system while using Gatus as the dashboard
| Parameter | Description | Default |
|:-------------------------------|:-----------------------------------------------------------------------------------------------------------------------|:--------------|
| `external-endpoints` | List of endpoints to monitor. | `[]` |
| `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
| `external-endpoints[].token` | Bearer token required to push status to. | Required `""` |
| `external-endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
| Parameter | Description | Default |
|:------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------|:---------------|
| `external-endpoints` | List of endpoints to monitor. | `[]` |
| `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
| `external-endpoints[].token` | Bearer token required to push status to. | Required `""` |
| `external-endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
| `external-endpoints[].heartbeat` | Heartbeat configuration for monitoring when the external endpoint stops sending updates. | `{}` |
| `external-endpoints[].heartbeat.interval` | Expected interval between updates. If no update is received within this interval, alerts will be triggered. Must be at least 10s. | `0` (disabled) |
Example:
```yaml
@@ -311,6 +329,8 @@ external-endpoints:
- name: ext-ep-test
group: core
token: "potato"
heartbeat:
interval: 30m # Automatically create a failure if no update is received within 30 minutes
alerts:
- type: discord
description: "healthcheck failed"
@@ -319,13 +339,14 @@ external-endpoints:
To push the status of an external endpoint, the request would have to look like this:
```
POST /api/v1/endpoints/{key}/external?success={success}&error={error}
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- Using the example configuration above, the key would be `core_ext-ep-test`.
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
- `{error}`: a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, it can be omitted or left empty.
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful.
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
You must also pass the token as a `Bearer` token in the `Authorization` header.
@@ -381,6 +402,38 @@ Here are some examples of conditions you can use:
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Announcements
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
| Parameter | Description | Default |
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
| `announcements` | List of announcements to display | `[]` |
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
| `announcements[].message` | The message to display to users | Required |
Types:
- **outage**: Indicates service disruptions or critical issues (red theme)
- **warning**: Indicates potential issues or important notices (yellow theme)
- **information**: General information or updates (blue theme)
- **operational**: Indicates resolved issues or normal operations (green theme)
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
Example Configuration:
```yaml
announcements:
- timestamp: 2025-08-15T14:00:00Z
type: outage
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
- timestamp: 2025-08-15T16:15:00Z
type: operational
message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-08-15T12:00:00Z
type: information
message: "New monitoring dashboard features will be deployed next week"
```
### Storage
| Parameter | Description | Default |
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
@@ -538,16 +591,17 @@ individual endpoints with configurable descriptions and thresholds.
Alerts are configured at the endpoint level like so:
| Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. | `0` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
Here's an example of what an alert configuration might look like at the endpoint level:
```yaml
@@ -589,6 +643,7 @@ endpoints:
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
| `alerting.ilert` | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
@@ -604,6 +659,7 @@ endpoints:
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`. <br />See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` |
#### Configuring AWS SES alerts
@@ -895,6 +951,51 @@ endpoints:
| `alerting.gotify.title` | Title of the notification | `"Gatus: <endpoint>"` |
| `alerting.gotify.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
#### Configuring ilert alerts
| Parameter | Description | Default |
|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` |
| `alerting.ilert.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` |
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `ilert`, because unlike other alerts, the operation resulting from setting said
parameter to `true` will not create another alert but mark the alert as resolved on
ilert instead.
Behavior:
- By default, `alerting.ilert.integration-key` is used as the integration key
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s
```yaml
alerting:
ilert:
integration-key: "********************************"
# You can also add group-specific integration keys, which will
# override the integration key above for the specified groups
overrides:
- group: "core"
integration-key: "********************************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: ilert
failure-threshold: 3
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
```yaml
alerting:
gotify:
@@ -920,6 +1021,75 @@ Here's an example of what the notifications look like:
![Gotify notifications](.github/assets/gotify-alerts.png)
#### Configuring HomeAssistant alerts
To configure HomeAssistant alerts, you'll need to add the following to your configuration file:
```yaml
alerting:
homeassistant:
url: "http://homeassistant:8123" # URL of your HomeAssistant instance
token: "YOUR_LONG_LIVED_ACCESS_TOKEN" # Long-lived access token from HomeAssistant
endpoints:
- name: my-service
url: "https://my-service.com"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: homeassistant
enabled: true
send-on-resolved: true
description: "My service health check"
failure-threshold: 3
success-threshold: 2
```
The alerts will be sent as events to HomeAssistant with the event type `gatus_alert`. The event data includes:
- `status`: "triggered" or "resolved"
- `endpoint`: The name of the monitored endpoint
- `description`: The alert description if provided
- `conditions`: List of conditions and their results
- `failure_count`: Number of consecutive failures (when triggered)
- `success_count`: Number of consecutive successes (when resolved)
You can use these events in HomeAssistant automations to:
- Send notifications
- Control devices
- Trigger scenes
- Log to history
- And more
Example HomeAssistant automation:
```yaml
automation:
- alias: "Gatus Alert Handler"
trigger:
platform: event
event_type: gatus_alert
action:
- service: notify.notify
data_template:
title: "Gatus Alert: {{ trigger.event.data.endpoint }}"
message: >
Status: {{ trigger.event.data.status }}
{% if trigger.event.data.description %}
Description: {{ trigger.event.data.description }}
{% endif %}
{% for condition in trigger.event.data.conditions %}
{{ '✅' if condition.success else '❌' }} {{ condition.condition }}
{% endfor %}
```
To get your HomeAssistant long-lived access token:
1. Open HomeAssistant
2. Click on your profile name (bottom left)
3. Scroll down to "Long-Lived Access Tokens"
4. Click "Create Token"
5. Give it a name (e.g., "Gatus")
6. Copy the token - you'll only see it once!
#### Configuring Incident.io alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
@@ -1432,6 +1602,7 @@ Here's an example of what the notifications look like:
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
@@ -1444,6 +1615,7 @@ alerting:
telegram:
token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
id: "0123456789"
topic-id: "7"
endpoints:
- name: website
@@ -1817,6 +1989,23 @@ endpoint on the same port your application is configured to run on (`web.port`).
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
#### Custom Labels
You can add custom labels to your endpoints Prometheus metrics by defining keyvalue pairs under the `extra-labels` field. For example:
```yaml
endpoints:
- name: front-end
group: core
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 150"
extra-labels:
environment: staging
```
### Connectivity
| Parameter | Description | Default |
@@ -2004,8 +2193,9 @@ endpoints:
conditions:
- "[CONNECTED] == true"
```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
@@ -2025,7 +2215,9 @@ endpoints:
- "[CONNECTED] == true"
```
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for UDP endpoints.
This works for UDP based application.
@@ -2060,7 +2252,8 @@ endpoints:
```
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
shows whether the connection was successfully established.
shows whether the connection was successfully established. You can use Go template
syntax. The functions LocalAddr and RandomString with a length can be used.
### Monitoring an endpoint using ICMP
@@ -2172,6 +2365,11 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 48h"
```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TLS endpoints.
### Monitoring domain expiration
You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`
@@ -2270,9 +2468,9 @@ endpoints:
- "[STATUS] == 200"
```
The configuration above will result in a dashboard that looks like this:
The configuration above will result in a dashboard that looks like this when sorting by group:
![Gatus Endpoint Groups](.github/assets/endpoint-groups.png)
![Gatus Endpoint Groups](.github/assets/endpoint-groups.jpg)
### Exposing Gatus on a custom path
@@ -2500,6 +2698,11 @@ Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzi
The API will return a JSON payload with the `Content-Type` response header set to `application/json`.
No such header is required to query the API.
#### Interacting with the API programmatically
See [TwiN/gatus-sdk](https://github.com/TwiN/gatus-sdk)
#### Raw Data
Gatus exposes the raw data for one of your monitored endpoints.
This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days.
@@ -2532,6 +2735,7 @@ For instance, if you want the raw response time data for the last 24 hours from
https://example.com/api/v1/endpoints/core_frontend/response-times/24h
```
### Installing as binary
You can download Gatus as a binary using the following command:
```

View File

@@ -6,6 +6,7 @@ import (
"errors"
"strconv"
"strings"
"time"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
@@ -35,6 +36,9 @@ type Alert struct {
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// MinimumReminderInterval is the interval between reminders
MinimumReminderInterval time.Duration `yaml:"minimum-reminder-interval,omitempty"`
// Description of the alert. Will be included in the alert sent.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value

View File

@@ -32,6 +32,12 @@ const (
// TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify"
// TypeHomeAssistant is the Type for the homeassistant alerting provider
TypeHomeAssistant Type = "homeassistant"
// TypeIlert is the Type for the ilert alerting provider
TypeIlert Type = "ilert"
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"

View File

@@ -15,6 +15,8 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
@@ -61,6 +63,12 @@ type Config struct {
// Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// HomeAssistant is the configuration for the homeassistant alerting provider
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
// Ilert is the configuration for the ilert alerting provider
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
// IncidentIO is the configuration for the incident-io alerting provider
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`

View File

@@ -166,7 +166,14 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + formattedConditionResults
var extraLabels string
if len(ep.ExtraLabels) > 0 {
extraLabels = "\n\nExtra labels:\n"
for key, value := range ep.ExtraLabels {
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
}
}
return subject, message + description + extraLabels + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -76,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider AlertProvider
Alert alert.Alert
Resolved bool
Endpoint *endpoint.Endpoint
ExpectedSubject string
ExpectedBody string
}{
@@ -84,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
@@ -92,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-no-extra-labels",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&endpoint.Endpoint{Name: "endpoint-name"},
scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{

View File

@@ -0,0 +1,196 @@
package homeassistant
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrURLNotSet = errors.New("url not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using HomeAssistant
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
EventType string `json:"event_type"`
EventData struct {
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Description string `json:"description,omitempty"`
Conditions []struct {
Condition string `json:"condition"`
Success bool `json:"success"`
} `json:"conditions,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
SuccessCount int `json:"success_count,omitempty"`
} `json:"event_data"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
EventType: "gatus_alert",
EventData: struct {
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Description string `json:"description,omitempty"`
Conditions []struct {
Condition string `json:"condition"`
Success bool `json:"success"`
} `json:"conditions,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
SuccessCount int `json:"success_count,omitempty"`
}{
Status: "resolved",
Endpoint: ep.DisplayName(),
},
}
if !resolved {
body.EventData.Status = "triggered"
body.EventData.FailureCount = alert.FailureThreshold
} else {
body.EventData.SuccessCount = alert.SuccessThreshold
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
body.EventData.Description = alertDescription
}
if len(result.ConditionResults) > 0 {
for _, conditionResult := range result.ConditionResults {
body.EventData.Conditions = append(body.EventData.Conditions, struct {
Condition string `json:"condition"`
Success bool `json:"success"`
}{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}

View File

@@ -0,0 +1,158 @@
package homeassistant
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{URL: "", Token: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"},
Overrides: []Override{
{
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "SUCCESSFUL_CONDITION", Success: true},
{Condition: "FAILING_CONDITION", Success: false},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
description := "test-description"
provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
body := provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "SUCCESSFUL_CONDITION", Success: true},
{Condition: "FAILING_CONDITION", Success: false},
},
},
false,
)
var decodedBody Body
if err := json.Unmarshal(body, &decodedBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if decodedBody.EventType != "gatus_alert" {
t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType)
}
if decodedBody.EventData.Status != "triggered" {
t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status)
}
if decodedBody.EventData.Description != description {
t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description)
}
if len(decodedBody.EventData.Conditions) != 2 {
t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions))
}
if !decodedBody.EventData.Conditions[0].Success {
t.Error("expected first condition to be successful")
}
if decodedBody.EventData.Conditions[1].Success {
t.Error("expected second condition to be unsuccessful")
}
}

View File

@@ -0,0 +1,168 @@
package ilert
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
restAPIUrl = "https://api.ilert.com/api/v1/events/gatus/"
)
var (
ErrIntegrationKeyNotSet = errors.New("integration key is not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) == 0 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using ilert
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", restAPIUrl, cfg.IntegrationKey), buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Alert alert.Alert `json:"alert"`
Name string `json:"name"`
Group string `json:"group"`
Status string `json:"status"`
Title string `json:"title"`
Details string `json:"details,omitempty"`
ConditionResults []*endpoint.ConditionResult `json:"condition_results"`
URL string `json:"url"`
}
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var details, status string
if resolved {
status = "resolved"
} else {
status = "firing"
}
if len(alert.GetDescription()) > 0 {
details = alert.GetDescription()
} else {
details = "No description"
}
var body []byte
body, _ = json.Marshal(Body{
Alert: *alert,
Name: ep.Name,
Group: ep.Group,
Title: ep.DisplayName(),
Status: status,
Details: details,
ConditionResults: result.ConditionResults,
URL: ep.URL,
})
return body
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}

View File

@@ -0,0 +1,322 @@
package ilert
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{
DefaultConfig: Config{
IntegrationKey: "some-random-key",
},
},
expected: true,
},
{
name: "invalid-integration-key",
provider: AlertProvider{
DefaultConfig: Config{
IntegrationKey: "",
},
},
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid, got error:", err.Error())
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
sendOnResolved := true
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_BuildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
sendOnResolved := true
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: true,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"MinimumReminderInterval":0,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
@@ -203,7 +202,6 @@ func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}

View File

@@ -10,6 +10,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
@@ -80,6 +83,10 @@ var (
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*gotify.AlertProvider)(nil)
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
@@ -94,7 +101,6 @@ var (
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
// Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil)
@@ -105,6 +111,9 @@ var (
_ Config[github.Config] = (*github.Config)(nil)
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
_ Config[gotify.Config] = (*gotify.Config)(nil)
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)

View File

@@ -23,9 +23,10 @@ var (
)
type Config struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
ApiUrl string `yaml:"api-url"`
Token string `yaml:"token"`
ID string `yaml:"id"`
TopicID string `yaml:"topic-id,omitempty"`
ApiUrl string `yaml:"api-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
@@ -53,6 +54,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.ID) > 0 {
cfg.ID = override.ID
}
if len(override.TopicID) > 0 {
cfg.TopicID = override.TopicID
}
if len(override.ApiUrl) > 0 {
cfg.ApiUrl = override.ApiUrl
}
@@ -117,6 +121,7 @@ type Body struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
TopicID string `json:"message_thread_id,omitempty"`
}
// buildRequestBody builds the request body for the provider
@@ -150,6 +155,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
ChatID: cfg.ID,
Text: text,
ParseMode: "MARKDOWN",
TopicID: cfg.TopicID,
})
return bodyAsJSON
}

View File

@@ -154,6 +154,13 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "send to topic",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {

View File

@@ -75,7 +75,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
// UNPROTECTED ROUTES //
////////////////////////
unprotectedAPIRouter := apiRouter.Group("/")
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)

View File

@@ -1,14 +1,17 @@
package api
import (
"encoding/json"
"fmt"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
type ConfigHandler struct {
securityConfig *security.Config
config *config.Config
}
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
@@ -18,8 +21,24 @@ func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
hasOIDC = handler.securityConfig.OIDC != nil
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
}
// Return the config
// Prepare response with announcements
response := map[string]interface{}{
"oidc": hasOIDC,
"authenticated": isAuthenticated,
}
// Add announcements if available, otherwise use empty slice
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
response["announcements"] = handler.config.Announcements
} else {
response["announcements"] = []interface{}{}
}
// Return the config as JSON
c.Set("Content-Type", "application/json")
return c.Status(200).
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
responseBytes, err := json.Marshal(response)
if err != nil {
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
}
return c.Status(200).Send(responseBytes)
}

View File

@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != `{"oidc":true,"authenticated":false}` {
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog"
@@ -15,6 +16,7 @@ import (
)
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
extraLabels := cfg.GetUniqueExtraMetricLabels()
return func(c *fiber.Ctx) error {
// Check if the success query parameter is present
success, exists := c.Queries()["success"]
@@ -46,6 +48,14 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
Success: c.QueryBool("success"),
Errors: []string{},
}
if len(c.Query("duration")) > 0 {
parsedDuration, err := time.ParseDuration(c.Query("duration"))
if err != nil {
logr.Errorf("[api.CreateExternalEndpointResult] Invalid duration from string=%s with error: %s", c.Query("duration"), err.Error())
return c.Status(400).SendString("invalid duration: " + err.Error())
}
result.Duration = parsedDuration
}
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
}
@@ -64,6 +74,9 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
// Return the result
return c.Status(200).SendString("")
}

View File

@@ -70,6 +70,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{
Name: "bad-duration-value",
Path: "/api/v1/endpoints/g_n/external?success=true&duration=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{
Name: "good-token-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true",
@@ -82,6 +88,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-duration-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&duration=10s",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false",
@@ -118,7 +130,7 @@ func TestCreateExternalEndpointResult(t *testing.T) {
})
}
t.Run("verify-end-results", func(t *testing.T) {
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10))
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 11))
if err != nil {
t.Errorf("failed to get endpoint status: %s", err.Error())
return
@@ -126,8 +138,8 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
}
if len(endpointStatus.Results) != 5 {
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
if len(endpointStatus.Results) != 6 {
t.Errorf("expected 6 results but got %d", len(endpointStatus.Results))
}
if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful")
@@ -138,8 +150,8 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected second result to have no errors")
}
if endpointStatus.Results[2].Success {
t.Errorf("expected third result to be unsuccessful")
if endpointStatus.Results[2].Duration == 0 || endpointStatus.Results[2].Duration.Seconds() != 10 {
t.Errorf("expected third result to have a duration of 10 seconds")
}
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
@@ -147,8 +159,11 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
t.Errorf("expected fifth result to have errors: failed")
if endpointStatus.Results[5].Success {
t.Errorf("expected sixth result to be unsuccessful")
}
if len(endpointStatus.Results[5].Errors) == 0 || endpointStatus.Results[5].Errors[0] != "failed" {
t.Errorf("expected sixth result to have errors: failed")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {

View File

@@ -34,11 +34,13 @@ func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int)
if err != nil {
pageSize = DefaultPageSize
}
if pageSize > maximumNumberOfResults {
pageSize = maximumNumberOfResults
} else if pageSize < 1 {
pageSize = DefaultPageSize
}
}
if page == 1 && pageSize > maximumNumberOfResults {
// If the page is 1 and the page size is greater than the maximum number of results, return
// no more than the maximum number of results
pageSize = maximumNumberOfResults
} else if pageSize < 1 {
pageSize = DefaultPageSize
}
return
}

View File

@@ -76,24 +76,37 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e
return domainExpiration, nil
}
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
// parseLocalAddressPlaceholder returns a string with the local address replaced
func parseLocalAddressPlaceholder(item string, localAddr net.Addr) string {
item = strings.ReplaceAll(item, "[LOCAL_ADDRESS]", localAddr.String())
return item
}
// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint
func CanCreateUDPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("udp", address, config.Timeout)
// CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint
func CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := net.DialTimeout(netType, address, config.Timeout)
if err != nil {
return false
return false, nil
}
_ = conn.Close()
return true
defer connection.Close()
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return false, nil
}
buf := make([]byte, MaximumMessageSize)
n, err := connection.Read(buf)
if err != nil {
return false, nil
}
return true, buf[:n]
}
return true, nil
}
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
@@ -152,7 +165,10 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
}
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
func CanPerformTLS(address string, body string, config *Config) (connected bool, response []byte, certificate *x509.Certificate, err error) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
InsecureSkipVerify: config.Insecure,
})
@@ -166,9 +182,27 @@ func CanPerformTLS(address string, config *Config) (connected bool, certificate
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
peerCertificates := connection.ConnectionState().PeerCertificates
return true, peerCertificates[0], nil
certificate = peerCertificates[0]
} else {
certificate = verifiedChains[0][0]
}
return true, verifiedChains[0][0], nil
connected = true
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return
}
buf := make([]byte, MaximumMessageSize)
var n int
n, err = connection.Read(buf)
if err != nil {
return
}
response = buf[:n]
}
return
}
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
@@ -234,6 +268,7 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
}
defer sshClient.Close()
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
}
@@ -286,7 +321,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
}
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) {
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const (
Origin = "http://localhost/"
MaximumMessageSize = 1024 // in bytes
@@ -295,8 +330,22 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
if err != nil {
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
}
if headers != nil {
if wsConfig.Header == nil {
wsConfig.Header = make(http.Header)
}
for name, value := range headers {
wsConfig.Header.Set(name, value)
}
}
if config != nil {
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
wsConfig.TlsConfig = &tls.Config{
InsecureSkipVerify: config.Insecure,
}
if config.HasTLSConfig() && config.TLS.isValid() == nil {
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
}
}
// Dial URL
ws, err := websocket.DialConfig(wsConfig)
@@ -304,6 +353,7 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
}
defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message
if _, err := ws.Write([]byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err)

View File

@@ -41,26 +41,26 @@ func TestGetHTTPClient(t *testing.T) {
func TestGetDomainExpiration(t *testing.T) {
t.Parallel()
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Fatalf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Make sure the refresh works when the ttl is <24 hours
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
@@ -223,7 +223,7 @@ func TestCanPerformTLS(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
@@ -235,11 +235,13 @@ func TestCanPerformTLS(t *testing.T) {
}
}
func TestCanCreateTCPConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
func TestCanCreateConnection(t *testing.T) {
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
if connected {
t.Error("should've failed, because there's no port in the address")
}
if !CanCreateTCPConnection("1.1.1.1:53", &Config{Timeout: 5 * time.Second}) {
connected, _ = CanCreateNetworkConnection("tcp", "1.1.1.1:53", "", &Config{Timeout: 5 * time.Second})
if !connected {
t.Error("should've succeeded, because that IP should always™ be up")
}
}
@@ -303,11 +305,11 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
}
func TestQueryWebSocket(t *testing.T) {
_, _, err := QueryWebSocket("", "body", &Config{Timeout: 2 * time.Second})
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the address being invalid")
}
_, _, err = QueryWebSocket("ws://example.org", "body", &Config{Timeout: 2 * time.Second})
_, _, err = QueryWebSocket("ws://example.org", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the target not being websocket-friendly")
}

View File

@@ -37,7 +37,7 @@ endpoints:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.215.14"
- "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping

View File

@@ -0,0 +1,94 @@
package announcement
import (
"errors"
"sort"
"time"
)
const (
// TypeOutage represents a service outage
TypeOutage = "outage"
// TypeWarning represents a warning or potential issue
TypeWarning = "warning"
// TypeInformation represents general information
TypeInformation = "information"
// TypeOperational represents operational status or resolved issues
TypeOperational = "operational"
// TypeNone represents no specific type (default)
TypeNone = "none"
)
var (
// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified
ErrInvalidAnnouncementType = errors.New("invalid announcement type")
// ErrEmptyMessage is returned when an announcement has an empty message
ErrEmptyMessage = errors.New("announcement message cannot be empty")
// ErrMissingTimestamp is returned when an announcement has an empty timestamp
ErrMissingTimestamp = errors.New("announcement timestamp must be set")
// validTypes contains all valid announcement types
validTypes = map[string]bool{
TypeOutage: true,
TypeWarning: true,
TypeInformation: true,
TypeOperational: true,
TypeNone: true,
}
)
// Announcement represents a system-wide announcement
type Announcement struct {
// Timestamp is the UTC timestamp when the announcement was made
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
// Type is the type of announcement (outage, warning, information, operational, none)
Type string `yaml:"type" json:"type"`
// Message is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"`
}
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
func (a *Announcement) ValidateAndSetDefaults() error {
// Validate message
if a.Message == "" {
return ErrEmptyMessage
}
// Set default type if empty
if a.Type == "" {
a.Type = TypeNone
}
// Validate type
if !validTypes[a.Type] {
return ErrInvalidAnnouncementType
}
// If timestamp is zero, return an error
if a.Timestamp.IsZero() {
return ErrMissingTimestamp
}
return nil
}
// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)
func SortByTimestamp(announcements []*Announcement) {
sort.Slice(announcements, func(i, j int) bool {
return announcements[i].Timestamp.After(announcements[j].Timestamp)
})
}
// ValidateAndSetDefaults validates a slice of announcements and sets defaults
func ValidateAndSetDefaults(announcements []*Announcement) error {
for _, announcement := range announcements {
if err := announcement.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/announcement"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance"
@@ -22,7 +24,6 @@ import (
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2/log"
"gopkg.in/yaml.v3"
)
@@ -99,14 +100,39 @@ type Config struct {
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
// Announcements is the list of system-wide announcements
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
configPath string // path to the file or directory from which config was loaded
lastFileModTime time.Time // last modification time
}
// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints
// in the configuration. It iterates through each endpoint, checks if it is enabled,
// and then collects unique labels from the endpoint's labels map.
func (config *Config) GetUniqueExtraMetricLabels() []string {
labels := make([]string, 0)
for _, ep := range config.Endpoints {
if !ep.IsEnabled() {
continue
}
for label := range ep.ExtraLabels {
if contains(labels, label) {
continue
}
labels = append(labels, label)
}
}
if len(labels) > 1 {
sort.Strings(labels)
}
return labels
}
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if ep.Key() == key {
if ep.Key() == strings.ToLower(key) {
return ep
}
}
@@ -116,7 +142,7 @@ func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {
for i := 0; i < len(config.ExternalEndpoints); i++ {
ee := config.ExternalEndpoints[i]
if ee.Key() == key {
if ee.Key() == strings.ToLower(key) {
return ee
}
}
@@ -280,6 +306,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateConnectivityConfig(config); err != nil {
return nil, err
}
if err := validateAnnouncementsConfig(config); err != nil {
return nil, err
}
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
}
@@ -293,6 +322,17 @@ func validateConnectivityConfig(config *Config) error {
return nil
}
func validateAnnouncementsConfig(config *Config) error {
if config.Announcements != nil {
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
return err
}
// Sort announcements by timestamp (newest first) for API response
announcement.SortByTimestamp(config.Announcements)
}
return nil
}
func validateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
@@ -411,6 +451,9 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeGitea,
alert.TypeGoogleChat,
alert.TypeGotify,
alert.TypeHomeAssistant,
alert.TypeIlert,
alert.TypeIncidentIO,
alert.TypeJetBrainsSpace,
alert.TypeMatrix,
alert.TypeMattermost,
@@ -425,7 +468,6 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeTelegram,
alert.TypeTwilio,
alert.TypeZulip,
alert.TypeIncidentIO,
}
var validProviders, invalidProviders []alert.Type
for _, alertType := range alertTypes {
@@ -442,7 +484,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
}
@@ -456,7 +498,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
}

View File

@@ -124,7 +124,7 @@ endpoints:
name: "dir-with-two-config-files",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": `endpoints:
"config.yaml": `endpoints:
- name: one
url: https://example.com
conditions:
@@ -135,7 +135,7 @@ endpoints:
url: https://example.org
conditions:
- "len([BODY]) > 0"`,
"config.yml": `endpoints:
"config.yml": `endpoints:
- name: three
url: https://twin.sh/health
conditions:
@@ -237,7 +237,7 @@ endpoints:
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for path, content := range scenario.pathAndFiles {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
}
}
@@ -282,7 +282,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0644)
`), 0o644)
t.Run("config-file-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(configFilePath)
@@ -298,7 +298,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0644); err != nil {
- "[STATUS] == 200"`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
@@ -315,7 +315,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
@@ -713,7 +713,7 @@ func TestParseAndValidateBadConfigBytes(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
badconfig:
- asdsa: w0w
usadasdrl: asdxzczxc
usadasdrl: asdxzczxc
asdas:
- soup
`))
@@ -1943,3 +1943,114 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
})
}
}
func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
tests := []struct {
name string
config *Config
expected []string
}{
{
name: "no-endpoints",
config: &Config{
Endpoints: []*endpoint.Endpoint{},
},
expected: []string{},
},
{
name: "single-endpoint-no-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
},
},
},
expected: []string{},
},
{
name: "single-endpoint-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
},
},
expected: []string{"env", "team"},
},
{
name: "multiple-endpoints-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
"module": "auth",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "staging",
"team": "frontend",
},
},
},
},
expected: []string{"env", "team", "module"},
},
{
name: "multiple-endpoints-with-some-disabled",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(false),
ExtraLabels: map[string]string{
"module": "auth",
},
},
},
},
expected: []string{"env", "team"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
labels := tt.config.GetUniqueExtraMetricLabels()
if len(labels) != len(tt.expected) {
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
}
for _, label := range tt.expected {
if !contains(labels, label) {
t.Errorf("expected label %s to be present", label)
}
}
})
}
}

View File

@@ -42,7 +42,8 @@ type Checker struct {
}
func (c *Checker) Check() bool {
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
connected, _ := client.CanCreateNetworkConnection("tcp", c.Target, "", &client.Config{Timeout: 5 * time.Second})
return connected
}
func (c *Checker) IsConnected() bool {

View File

@@ -7,9 +7,12 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -96,6 +99,9 @@ type Endpoint struct {
// Headers of the request
Headers map[string]string `yaml:"headers,omitempty"`
// ExtraLabels are key-value pairs that can be used to metric the endpoint
ExtraLabels map[string]string `yaml:"extra-labels,omitempty"`
// Interval is the duration to wait between every status check
Interval time.Duration `yaml:"interval,omitempty"`
@@ -125,6 +131,9 @@ type Endpoint struct {
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
LastReminderSent time.Time `yaml:"-"`
}
// IsEnabled returns whether the endpoint is enabled or not
@@ -229,7 +238,7 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
}
}
// Make sure that the request can be created
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
if err != nil {
return err
}
@@ -326,6 +335,29 @@ func (e *Endpoint) EvaluateHealth() *Result {
return result
}
func (e *Endpoint) getParsedBody() string {
body := e.Body
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", e.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", e.URL)
randRegex, err := regexp.Compile(`\[RANDOM_STRING_\d+\]`)
if err == nil {
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
n, _ := strconv.Atoi(match[15 : len(match)-1])
if n > 8192 {
n = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage
}
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
})
}
return body
}
func (e *Endpoint) getIP(result *Result) {
if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error())
@@ -356,7 +388,7 @@ func (e *Endpoint) call(result *Result) {
if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig)
result.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.getParsedBody(), e.ClientConfig)
}
if err != nil {
result.AddError(err.Error())
@@ -365,10 +397,10 @@ func (e *Endpoint) call(result *Result) {
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == TypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig)
result.Connected, result.Body = client.CanCreateNetworkConnection("tcp", strings.TrimPrefix(e.URL, "tcp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeUDP {
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig)
result.Connected, result.Body = client.CanCreateNetworkConnection("udp", strings.TrimPrefix(e.URL, "udp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
@@ -376,7 +408,16 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == TypeWS {
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig)
wsHeaders := map[string]string{}
if e.Headers != nil {
for k, v := range e.Headers {
wsHeaders[k] = v
}
}
if _, exists := wsHeaders["User-Agent"]; !exists {
wsHeaders["User-Agent"] = GatusUserAgent
}
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
@@ -385,8 +426,7 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
result.Connected, result.HTTPStatus, err =
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
@@ -401,7 +441,7 @@ func (e *Endpoint) call(result *Result) {
result.AddError(err.Error())
return
}
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig)
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
@@ -435,12 +475,12 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer
if e.GraphQL {
graphQlBody := map[string]string{
"query": e.Body,
"query": e.getParsedBody(),
}
body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body)
} else {
bodyBuffer = bytes.NewBuffer([]byte(e.Body))
bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))
}
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers {

View File

@@ -2,13 +2,19 @@ package endpoint
import (
"errors"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
"github.com/TwiN/gatus/v5/config/maintenance"
)
var (
// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.
ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint")
// ErrExternalEndpointHeartbeatIntervalTooLow is the error with which Gatus will panic if an external endpoint's heartbeat interval is less than 10 seconds.
ErrExternalEndpointHeartbeatIntervalTooLow = errors.New("heartbeat interval must be at least 10 seconds")
)
// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that
@@ -30,6 +36,12 @@ type ExternalEndpoint struct {
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
// Heartbeat is the configuration that checks if the external endpoint has received new results when it should have.
Heartbeat heartbeat.Config `yaml:"heartbeat,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int `yaml:"-"`
@@ -45,6 +57,10 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
if len(externalEndpoint.Token) == 0 {
return ErrExternalEndpointWithNoToken
}
if externalEndpoint.Heartbeat.Interval != 0 && externalEndpoint.Heartbeat.Interval < 10*time.Second {
// If the heartbeat interval is set (non-0), it must be at least 10 seconds.
return ErrExternalEndpointHeartbeatIntervalTooLow
}
return nil
}

View File

@@ -0,0 +1,11 @@
package heartbeat
import "time"
// Config used to check if the external endpoint has received new results when it should have.
// This configuration is used to trigger alerts when an external endpoint has no new results for a defined period of time
type Config struct {
// Interval is the time interval at which Gatus verifies whether the external endpoint has received new results
// If no new result is received within the interval, the endpoint is marked as failed and alerts are triggered
Interval time.Duration `yaml:"interval"`
}

View File

@@ -12,30 +12,39 @@ import (
const (
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Health Status"
defaultHeader = "Gatus"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
)
var (
defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'")
ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'none', 'failing', or 'unstable'")
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
MaximumNumberOfResults int // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
//////////////////////////////////////////////
MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
}
func (cfg *Config) IsDarkMode() bool {
@@ -69,6 +78,8 @@ func GetDefaultConfig() *Config {
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
}
}
@@ -96,6 +107,16 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}
if len(cfg.DefaultSortBy) == 0 {
cfg.DefaultSortBy = defaultSortBy
} else if cfg.DefaultSortBy != "name" && cfg.DefaultSortBy != "group" && cfg.DefaultSortBy != "health" {
return ErrInvalidDefaultSortBy
}
if len(cfg.DefaultFilterBy) == 0 {
cfg.DefaultFilterBy = defaultFilterBy
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err

View File

@@ -1,6 +1,7 @@
package ui
import (
"errors"
"strconv"
"testing"
)
@@ -25,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
}
func TestButton_Validate(t *testing.T) {
@@ -74,4 +81,114 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
if defaultConfig.DefaultSortBy != defaultSortBy {
t.Error("expected GetDefaultConfig() to return defaultSortBy, got", defaultConfig.DefaultSortBy)
}
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultSortBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultSortBy",
DefaultSortBy: "",
ExpectedError: nil,
ExpectedValue: defaultSortBy,
},
{
Name: "ValidDefaultSortBy_name",
DefaultSortBy: "name",
ExpectedError: nil,
ExpectedValue: "name",
},
{
Name: "ValidDefaultSortBy_group",
DefaultSortBy: "group",
ExpectedError: nil,
ExpectedValue: "group",
},
{
Name: "ValidDefaultSortBy_health",
DefaultSortBy: "health",
ExpectedError: nil,
ExpectedValue: "health",
},
{
Name: "InvalidDefaultSortBy",
DefaultSortBy: "invalid",
ExpectedError: ErrInvalidDefaultSortBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultSortBy: scenario.DefaultSortBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultSortBy != scenario.ExpectedValue {
t.Errorf("expected DefaultSortBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultSortBy)
}
})
}
}
func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultFilterBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultFilterBy",
DefaultFilterBy: "",
ExpectedError: nil,
ExpectedValue: defaultFilterBy,
},
{
Name: "ValidDefaultFilterBy_none",
DefaultFilterBy: "none",
ExpectedError: nil,
ExpectedValue: "none",
},
{
Name: "ValidDefaultFilterBy_failing",
DefaultFilterBy: "failing",
ExpectedError: nil,
ExpectedValue: "failing",
},
{
Name: "ValidDefaultFilterBy_unstable",
DefaultFilterBy: "unstable",
ExpectedError: nil,
ExpectedValue: "unstable",
},
{
Name: "InvalidDefaultFilterBy",
DefaultFilterBy: "invalid",
ExpectedError: ErrInvalidDefaultFilterBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultFilterBy != scenario.ExpectedValue {
t.Errorf("expected DefaultFilterBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultFilterBy)
}
})
}
}

16
config/util.go Normal file
View File

@@ -0,0 +1,16 @@
package config
// toPtr returns a pointer to the given value
func toPtr[T any](value T) *T {
return &value
}
// contains checks if a key exists in the slice
func contains[T comparable](slice []T, key T) bool {
for _, item := range slice {
if item == key {
return true
}
}
return false
}

63
go.mod
View File

@@ -3,39 +3,40 @@ module github.com/TwiN/gatus/v5
go 1.24.1
require (
code.gitea.io/sdk/gitea v0.19.0
code.gitea.io/sdk/gitea v0.21.0
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.2.2
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.1.10
github.com/aws/aws-sdk-go v1.55.6
github.com/TwiN/whois v1.1.11
github.com/aws/aws-sdk-go v1.55.8
github.com/coreos/go-oidc/v3 v3.14.1
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/fiber/v2 v2.52.8
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.65
github.com/miekg/dns v1.1.67
github.com/prometheus-community/pro-bing v0.6.1
github.com/prometheus/client_golang v1.21.1
github.com/valyala/fasthttp v1.60.0
github.com/prometheus/client_golang v1.22.0
github.com/valyala/fasthttp v1.64.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.28.0
google.golang.org/api v0.228.0
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
google.golang.org/api v0.242.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.37.0
modernc.org/sqlite v1.38.2
)
require (
cloud.google.com/go/auth v0.15.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
@@ -49,8 +50,8 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
@@ -66,22 +67,22 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
google.golang.org/grpc v1.71.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

160
go.sum
View File

@@ -1,11 +1,13 @@
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
@@ -16,12 +18,12 @@ github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
github.com/TwiN/whois v1.1.10 h1:OdnxMRPlegKr+ypwMKq5VpJ8QoD6F2e5gY+MKTs9VyA=
github.com/TwiN/whois v1.1.10/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -46,8 +48,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -68,10 +70,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -95,8 +97,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0=
github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -105,8 +107,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.6.1 h1:EQukUOma9YFZRPe4DGSscxUf9LH07rpqwisNWjSZrgU=
github.com/prometheus-community/pro-bing v0.6.1/go.mod h1:jNCOI3D7pmTCeaoF41cNS6uaxeFY/Gmc3ffwbuJVzAQ=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
@@ -125,8 +127,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw=
github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc=
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -134,20 +136,20 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
@@ -155,10 +157,10 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -166,8 +168,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -177,18 +179,18 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -202,8 +204,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -212,8 +214,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -223,26 +225,26 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs=
google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
@@ -257,26 +259,28 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -9,6 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/controller"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
@@ -49,6 +50,7 @@ func main() {
func start(cfg *config.Config) {
go controller.Handle(cfg)
metrics.InitializePrometheusMetrics(cfg, nil)
watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg)
}
@@ -56,6 +58,7 @@ func start(cfg *config.Config) {
func stop(cfg *config.Config) {
watchdog.Shutdown(cfg)
controller.Shutdown()
metrics.UnregisterPrometheusMetrics()
}
func save() {

View File

@@ -3,82 +3,146 @@ package metrics
import (
"strconv"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const namespace = "gatus" // The prefix of the metrics
var (
initializedMetrics bool // Whether the metrics have been initialized
resultTotal *prometheus.CounterVec
resultDurationSeconds *prometheus.GaugeVec
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Track if metrics have been initialized to prevent duplicate registration
metricsInitialized bool
currentRegisterer prometheus.Registerer
)
func initializePrometheusMetrics() {
resultTotal = promauto.NewCounterVec(prometheus.CounterOpts{
// UnregisterPrometheusMetrics unregisters all previously registered metrics
func UnregisterPrometheusMetrics() {
if !metricsInitialized || currentRegisterer == nil {
return
}
// Unregister all metrics if they exist
if resultTotal != nil {
currentRegisterer.Unregister(resultTotal)
}
if resultDurationSeconds != nil {
currentRegisterer.Unregister(resultDurationSeconds)
}
if resultConnectedTotal != nil {
currentRegisterer.Unregister(resultConnectedTotal)
}
if resultCodeTotal != nil {
currentRegisterer.Unregister(resultCodeTotal)
}
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
metricsInitialized = false
currentRegisterer = nil
}
func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer) {
// If metrics are already initialized, unregister them first
if metricsInitialized {
UnregisterPrometheusMetrics()
}
if reg == nil {
reg = prometheus.DefaultRegisterer
}
// Store the registerer for later unregistration
currentRegisterer = reg
extraLabels := cfg.GetUniqueExtraMetricLabels()
resultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_total",
Help: "Number of results per endpoint",
}, []string{"key", "group", "name", "type", "success"})
resultDurationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
}, append([]string{"key", "group", "name", "type", "success"}, extraLabels...))
reg.MustRegister(resultTotal)
resultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_duration_seconds",
Help: "Duration of the request in seconds",
}, []string{"key", "group", "name", "type"})
resultConnectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDurationSeconds)
resultConnectedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_connected_total",
Help: "Total number of results in which a connection was successfully established",
}, []string{"key", "group", "name", "type"})
resultCodeTotal = promauto.NewCounterVec(prometheus.CounterOpts{
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultConnectedTotal)
resultCodeTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_code_total",
Help: "Total number of results by code",
}, []string{"key", "group", "name", "type", "code"})
resultCertificateExpirationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
}, append([]string{"key", "group", "name", "type", "code"}, extraLabels...))
reg.MustRegister(resultCodeTotal)
resultCertificateExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_certificate_expiration_seconds",
Help: "Number of seconds until the certificate expires",
}, []string{"key", "group", "name", "type"})
resultEndpointSuccess = promauto.NewGaugeVec(prometheus.GaugeOpts{
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds)
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_endpoint_success",
Help: "Displays whether or not the endpoint was a success",
}, []string{"key", "group", "name", "type"})
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultEndpointSuccess)
// Mark as initialized
metricsInitialized = true
}
// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
// These metrics will be exposed at /metrics if the metrics are enabled
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result) {
if !initializedMetrics {
initializePrometheusMetrics()
initializedMetrics = true
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, extraLabels []string) {
labelValues := []string{}
for _, label := range extraLabels {
if value, ok := ep.ExtraLabels[label]; ok {
labelValues = append(labelValues, value)
} else {
labelValues = append(labelValues, "")
}
}
endpointType := ep.Type()
resultTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc()
resultDurationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.Duration.Seconds())
resultTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)}, labelValues...)...).Inc()
resultDurationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.Duration.Seconds())
if result.Connected {
resultConnectedTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Inc()
resultConnectedTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Inc()
}
if result.DNSRCode != "" {
resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode).Inc()
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode}, labelValues...)...).Inc()
}
if result.HTTPStatus != 0 {
resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc()
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)}, labelValues...)...).Inc()
}
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds())
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
}
if result.Success {
resultEndpointSuccess.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(1)
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else {
resultEndpointSuccess.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(0)
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(0)
}
}

View File

@@ -5,13 +5,112 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// TestInitializePrometheusMetrics tests metrics initialization with extraLabels.
// Note: Because of the global Prometheus registry, this test can only safely verify one label set per process.
// If the function is called with a different set of labels for the same metric, a panic will occur.
func TestInitializePrometheusMetrics(t *testing.T) {
cfgWithExtras := &config.Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "TestEP",
Group: "G",
URL: "http://x/",
ExtraLabels: map[string]string{
"foo": "foo-val",
"hello": "world-val",
},
},
},
}
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(cfgWithExtras, reg)
// Metrics variables should be non-nil
if resultTotal == nil {
t.Error("resultTotal metric not initialized")
}
if resultDurationSeconds == nil {
t.Error("resultDurationSeconds metric not initialized")
}
if resultConnectedTotal == nil {
t.Error("resultConnectedTotal metric not initialized")
}
if resultCodeTotal == nil {
t.Error("resultCodeTotal metric not initialized")
}
if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized")
}
defer func() {
if r := recover(); r != nil {
t.Errorf("resultTotal.WithLabelValues panicked: %v", r)
}
}()
_ = resultTotal.WithLabelValues("k", "g", "n", "ty", "true", "fval", "hval")
}
// TestPublishMetricsForEndpoint_withExtraLabels ensures extraLabels are included in the exported metrics.
func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
// Only test one label set per process due to Prometheus registry limits.
reg := prometheus.NewRegistry()
cfg := &config.Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "ep-extra",
URL: "https://sample.com",
ExtraLabels: map[string]string{
"foo": "my-foo",
"bar": "my-bar",
},
},
},
}
InitializePrometheusMetrics(cfg, reg)
ep := &endpoint.Endpoint{
Name: "ep-extra",
Group: "g1",
URL: "https://sample.com",
ExtraLabels: map[string]string{
"foo": "my-foo",
"bar": "my-bar",
},
}
result := &endpoint.Result{
HTTPStatus: 200,
Connected: true,
Duration: 2340 * time.Millisecond,
Success: true,
}
// Get labels in sorted order as per GetUniqueExtraMetricLabels
extraLabels := cfg.GetUniqueExtraMetricLabels()
PublishMetricsForEndpoint(ep, result, extraLabels)
expected := `
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{bar="my-bar",foo="my-foo",group="g1",key="g1_ep-extra",name="ep-extra",success="true",type="HTTP"} 1
`
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expected), "gatus_results_total")
if err != nil {
t.Error("metrics export does not include extraLabels as expected:", err)
}
}
func TestPublishMetricsForEndpoint(t *testing.T) {
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(&config.Config{}, reg)
httpEndpoint := &endpoint.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"}
PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{
HTTPStatus: 200,
@@ -23,8 +122,8 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
},
Success: true,
CertificateExpiration: 49 * time.Hour,
})
err := testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
}, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
@@ -57,8 +156,8 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
},
Success: false,
CertificateExpiration: 47 * time.Hour,
})
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
@@ -82,10 +181,12 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
dnsEndpoint := &endpoint.Endpoint{Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com.",
}}
dnsEndpoint := &endpoint.Endpoint{
Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
}
PublishMetricsForEndpoint(dnsEndpoint, &endpoint.Result{
DNSRCode: "NOERROR",
Connected: true,
@@ -94,8 +195,8 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
{Condition: "[DNS_RCODE] == NOERROR", Success: true},
},
Success: true,
})
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2

View File

@@ -211,6 +211,23 @@ func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.En
return 0
}
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
func (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {
s.RLock()
defer s.RUnlock()
endpointStatus := s.cache.GetValue(key)
if endpointStatus == nil {
// If no endpoint exists, there's no newer status, so return false instead of an error
return false, nil
}
for _, result := range endpointStatus.(*endpoint.Status).Results {
if result.Timestamp.After(timestamp) {
return true, nil
}
}
return false, nil
}
// Clear deletes everything from the store
func (s *Store) Clear() {
s.cache.Clear()

View File

@@ -84,6 +84,7 @@ var (
// This test is simply an extra sanity check
func TestStore_SanityCheck(t *testing.T) {
store, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Clear()
defer store.Close()
store.Insert(&testEndpoint, &testSuccessfulResult)
endpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
@@ -134,3 +135,30 @@ func TestStore_Save(t *testing.T) {
store.Clear()
store.Close()
}
func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
store, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Clear()
defer store.Close()
// Insert a result
err := store.Insert(&testEndpoint, &testSuccessfulResult)
if err != nil {
t.Fatalf("expected no error while inserting result, got %v", err)
}
// Check with a timestamp in the past
hasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !hasNewerStatus {
t.Fatal("expected to have a newer status, but didn't")
}
// Check with a timestamp in the future
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if hasNewerStatus {
t.Fatal("expected not to have a newer status, but did")
}
}

View File

@@ -514,6 +514,24 @@ func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.En
return int(rowsAffects)
}
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
func (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {
if timestamp.IsZero() {
return false, errors.New("timestamp is zero")
}
var count int
err := s.db.QueryRow(
"SELECT COUNT(*) FROM endpoint_results WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1) AND timestamp > $2",
key,
timestamp.UTC(),
).Scan(&count)
if err != nil {
// If the endpoint doesn't exist, we return false instead of an error
return false, nil
}
return count > 0, nil
}
// Clear deletes everything from the store
func (s *Store) Clear() {
_, _ = s.db.Exec("DELETE FROM endpoints")

View File

@@ -853,3 +853,36 @@ func TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(t *testing.T) {
t.Error("expected alert3 to exist for ep2")
}
}
func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_HasEndpointStatusNewerThan.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
// Insert an endpoint status
if err := store.Insert(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Check if it has a status newer than 1 hour ago
hasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if !hasNewerStatus {
t.Error("expected to have a newer status")
}
// Check if it has a status newer than 2 days ago
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-48*time.Hour))
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if !hasNewerStatus {
t.Error("expected to have a newer status")
}
// Check if there's a status newer than 1 hour in the future (silly test, but it should work)
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if hasNewerStatus {
t.Error("expected not to have a newer status in the future")
}
}

View File

@@ -57,6 +57,9 @@ type Store interface {
// This prevents triggered alerts that have been removed or modified from lingering in the database.
DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error)
// Clear deletes everything from the store
Clear()

View File

@@ -2,7 +2,9 @@ package watchdog
import (
"errors"
"log"
"os"
"time"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -30,14 +32,24 @@ func handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alert
if !endpointAlert.IsEnabled() || endpointAlert.FailureThreshold > ep.NumberOfFailuresInARow {
continue
}
if endpointAlert.Triggered {
logr.Debugf("[watchdog.handleAlertsToTrigger] Alert for endpoint with key=%s with description='%s' has already been TRIGGERED, skipping", ep.Key(), endpointAlert.GetDescription())
// Determine if an initial alert should be sent
sendInitialAlert := !endpointAlert.Triggered
// Determine if a reminder should be sent
sendReminder := endpointAlert.Triggered && endpointAlert.MinimumReminderInterval > 0 && time.Since(ep.LastReminderSent) >= endpointAlert.MinimumReminderInterval
// If neither initial alert nor reminder needs to be sent, skip to the next alert
if !sendInitialAlert && !sendReminder {
logr.Debugf("[watchdog.handleAlertsToTrigger] Alert for endpoint=%s with description='%s' is not due for triggering or reminding, skipping", ep.Name, endpointAlert.GetDescription())
continue
}
alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)
if alertProvider != nil {
logr.Infof("[watchdog.handleAlertsToTrigger] Sending %s alert because alert for endpoint with key=%s with description='%s' has been TRIGGERED", endpointAlert.Type, ep.Key(), endpointAlert.GetDescription())
var err error
alertType := "reminder"
if sendInitialAlert {
alertType = "initial"
}
log.Printf("[watchdog.handleAlertsToTrigger] Sending %s %s alert because alert for endpoint=%s with description='%s' has been TRIGGERED", alertType, endpointAlert.Type, ep.Name, endpointAlert.GetDescription())
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
err = errors.New("error")
@@ -48,7 +60,11 @@ func handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alert
if err != nil {
logr.Errorf("[watchdog.handleAlertsToTrigger] Failed to send an alert for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
endpointAlert.Triggered = true
// Mark initial alert as triggered and update last reminder time
if sendInitialAlert {
endpointAlert.Triggered = true
}
ep.LastReminderSent = time.Now()
if err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil {
logr.Errorf("[watchdog.handleAlertsToTrigger] Failed to persist triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error())
}

View File

@@ -3,6 +3,7 @@ package watchdog
import (
"os"
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
@@ -517,6 +518,48 @@ func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {
verify(t, ep, 0, 2, false, "")
}
func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: false,
MinimumReminderInterval: 1 * time.Second,
},
},
}
verify(t, ep, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 2, 0, true, "The alert should've triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 3, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 4, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
}
func verify(t *testing.T, ep *endpoint.Endpoint, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) {
if ep.NumberOfFailuresInARow != expectedNumberOfFailuresInARow {
t.Errorf("endpoint.NumberOfFailuresInARow should've been %d, got %d", expectedNumberOfFailuresInARow, ep.NumberOfFailuresInARow)

View File

@@ -27,19 +27,28 @@ var (
// Monitor loops over each endpoint and starts a goroutine to monitor each endpoint separately
func Monitor(cfg *config.Config) {
ctx, cancelFunc = context.WithCancel(context.Background())
extraLabels := cfg.GetUniqueExtraMetricLabels()
for _, endpoint := range cfg.Endpoints {
if endpoint.IsEnabled() {
// To prevent multiple requests from running at the same time, we'll wait for a little before each iteration
time.Sleep(777 * time.Millisecond)
go monitor(endpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, ctx)
go monitor(endpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, extraLabels, ctx)
}
}
for _, externalEndpoint := range cfg.ExternalEndpoints {
// Check if the external endpoint is enabled and is using heartbeat
// If the external endpoint does not use heartbeat, then it does not need to be monitored periodically, because
// alerting is checked every time an external endpoint is pushed to Gatus, unlike normal endpoints.
if externalEndpoint.IsEnabled() && externalEndpoint.Heartbeat.Interval > 0 {
go monitorExternalEndpointHeartbeat(externalEndpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, ctx, extraLabels)
}
}
}
// monitor a single endpoint in a loop
func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, ctx context.Context) {
func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, extraLabels []string, ctx context.Context) {
// Run it immediately on start
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics)
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, extraLabels)
// Loop for the next executions
ticker := time.NewTicker(ep.Interval)
defer ticker.Stop()
@@ -49,7 +58,7 @@ func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
logr.Warnf("[watchdog.monitor] Canceling current execution of group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key())
return
case <-ticker.C:
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics)
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, extraLabels)
}
}
// Just in case somebody wandered all the way to here and wonders, "what about ExternalEndpoints?"
@@ -57,7 +66,7 @@ func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
// periodically like they are for normal endpoints.
}
func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool) {
func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, extraLabels []string) {
if !disableMonitoringLock {
// By placing the lock here, we prevent multiple endpoints from being monitored at the exact same time, which
// could cause performance issues and return inaccurate results
@@ -72,7 +81,7 @@ func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
logr.Debugf("[watchdog.execute] Monitoring group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key())
result := ep.EvaluateHealth()
if enabledMetrics {
metrics.PublishMetricsForEndpoint(ep, result)
metrics.PublishMetricsForEndpoint(ep, result, extraLabels)
}
UpdateEndpointStatuses(ep, result)
if logr.GetThreshold() == logr.LevelDebug && !result.Success {
@@ -96,6 +105,76 @@ func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
logr.Debugf("[watchdog.execute] Waiting for interval=%s before monitoring group=%s endpoint=%s (key=%s) again", ep.Interval, ep.Group, ep.Name, ep.Key())
}
func monitorExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, ctx context.Context, extraLabels []string) {
ticker := time.NewTicker(ee.Heartbeat.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logr.Warnf("[watchdog.monitorExternalEndpointHeartbeat] Canceling current execution of group=%s; endpoint=%s; key=%s", ee.Group, ee.Name, ee.Key())
return
case <-ticker.C:
executeExternalEndpointHeartbeat(ee, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, extraLabels)
}
}
}
func executeExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, extraLabels []string) {
if !disableMonitoringLock {
// By placing the lock here, we prevent multiple endpoints from being monitored at the exact same time, which
// could cause performance issues and return inaccurate results
monitoringMutex.Lock()
defer monitoringMutex.Unlock()
}
// If there's a connectivity checker configured, check if Gatus has internet connectivity
if connectivityConfig != nil && connectivityConfig.Checker != nil && !connectivityConfig.Checker.IsConnected() {
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] No connectivity; skipping execution")
return
}
logr.Debugf("[watchdog.monitorExternalEndpointHeartbeat] Checking heartbeat for group=%s; endpoint=%s; key=%s", ee.Group, ee.Name, ee.Key())
convertedEndpoint := ee.ToEndpoint()
hasReceivedResultWithinHeartbeatInterval, err := store.Get().HasEndpointStatusNewerThan(ee.Key(), time.Now().Add(-ee.Heartbeat.Interval))
if err != nil {
logr.Errorf("[watchdog.monitorExternalEndpointHeartbeat] Failed to check if endpoint has received a result within the heartbeat interval: %s", err.Error())
return
}
if hasReceivedResultWithinHeartbeatInterval {
// If we received a result within the heartbeat interval, we don't want to create a successful result, so we
// skip the rest. We don't have to worry about alerting or metrics, because if the previous heartbeat failed
// while this one succeeds, it implies that there was a new result pushed, and that result being pushed
// should've resolved the alert.
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] Checked heartbeat for group=%s; endpoint=%s; key=%s; success=%v; errors=%d", ee.Group, ee.Name, ee.Key(), hasReceivedResultWithinHeartbeatInterval, 0)
return
}
// All code after this point assumes the heartbeat failed
result := &endpoint.Result{
Timestamp: time.Now(),
Success: false,
Errors: []string{"heartbeat: no update received within " + ee.Heartbeat.Interval.String()},
}
if enabledMetrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
UpdateEndpointStatuses(convertedEndpoint, result)
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] Checked heartbeat for group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s", ee.Group, ee.Name, ee.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond))
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range ee.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[watchdog.monitorExternalEndpointHeartbeat] Under endpoint maintenance window")
inEndpointMaintenanceWindow = true
}
}
if !maintenanceConfig.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
HandleAlerting(convertedEndpoint, result, alertingConfig)
// Sync the failure/success counters back to the external endpoint
ee.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
ee.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} else {
logr.Debug("[watchdog.monitorExternalEndpointHeartbeat] Not handling alerting because currently in the maintenance window")
}
logr.Debugf("[watchdog.monitorExternalEndpointHeartbeat] Waiting for interval=%s before checking heartbeat for group=%s endpoint=%s (key=%s) again", ee.Heartbeat.Interval, ee.Group, ee.Name, ee.Key())
}
// UpdateEndpointStatuses updates the slice of endpoint statuses
func UpdateEndpointStatuses(ep *endpoint.Endpoint, result *endpoint.Result) {
if err := store.Get().Insert(ep, result); err != nil {

2911
web/app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,23 +8,25 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@headlessui/vue": "^1.7.3",
"@heroicons/vue": "^2.0.12",
"core-js": "3.22.8",
"vue": "3.2.37",
"vue-router": "4.0.16"
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"lucide-vue-next": "^0.539.0",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "5.0.4",
"@vue/cli-plugin-eslint": "5.0.4",
"@vue/cli-plugin-router": "5.0.4",
"@vue/cli-service": "5.0.4",
"@vue/compiler-sfc": "3.2.37",
"autoprefixer": "10.4.7",
"babel-eslint": "10.1.0",
"eslint": "7.32.0",
"eslint-plugin-vue": "7.20.0",
"postcss": "8.4.14",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/compiler-sfc": "^3.5.18",
"autoprefixer": "^10.4.21",
"@babel/eslint-parser": "^7.25.1",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.28.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.1.8"
},
"eslintConfig": {
@@ -37,9 +39,20 @@
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
"parser": "@babel/eslint-parser",
"requireConfigFile": false
},
"rules": {}
"rules": {
"vue/multi-word-component-names": ["error", {
"ignores": ["Home", "Details", "Loading", "Settings", "Social", "Tooltip", "Pagination", "Button", "Badge", "Card", "Input", "Select"]
}]
},
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
}
},
"browserslist": [
"defaults",

View File

@@ -3,7 +3,17 @@
<head>
<meta charset="utf-8" />
<script type="text/javascript">
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<title>{{ .UI.Title }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
@@ -20,7 +30,7 @@
<meta name="application-name" content="{{ .UI.Title }}" />
<meta name="theme-color" content="#f7f9fb" />
</head>
<body class="dark:bg-gray-900">
<body>
<noscript><strong>Enable JavaScript to view this page.</strong></noscript>
<div id="app"></div>
</body>

View File

@@ -1,106 +1,230 @@
<template>
<Loading v-if="!retrievedConfig" class="h-64 w-64 px-4" />
<div v-else :class="[config && config.oidc && !config.authenticated ? 'hidden' : '', 'container container-xs relative mx-auto xl:rounded xl:border xl:shadow-xl xl:my-5 p-5 pb-12 xl:pb-5 text-left dark:bg-gray-800 dark:text-gray-200 dark:border-gray-500']" id="global">
<div class="mb-2">
<div class="flex flex-wrap">
<div class="w-3/4 text-left my-auto">
<div class="text-3xl xl:text-5xl lg:text-4xl font-light">{{ header }}</div>
</div>
<div class="w-1/4 flex justify-end">
<component :is="link ? 'a' : 'div'" :href="link" target="_blank" class="flex items-center justify-center" style="width:100px;min-height:100px;">
<img v-if="logo" :src="logo" alt="Gatus" class="object-scale-down" style="max-width:100px;min-width:50px;min-height:50px;" />
<img v-else src="./assets/logo.svg" alt="Gatus" class="object-scale-down" style="max-width:100px;min-width:50px;min-height:50px;" />
</component>
</div>
</div>
<div v-if="buttons" class="flex flex-wrap">
<a v-for="button in buttons" :key="button.name" :href="button.link" target="_blank" class="px-2 py-0.5 font-medium select-none text-gray-600 hover:text-gray-500 dark:text-gray-300 dark:hover:text-gray-400 hover:underline">
{{ button.name }}
</a>
</div>
<div id="global" class="bg-background text-foreground">
<!-- Loading State -->
<div v-if="!retrievedConfig" class="flex items-center justify-center min-h-screen">
<Loading size="lg" />
</div>
<router-view @showTooltip="showTooltip" />
</div>
<div v-if="config && config.oidc && !config.authenticated" class="mx-auto max-w-md pt-12">
<img src="./assets/logo.svg" alt="Gatus" class="mx-auto" style="max-width:160px; min-width:50px; min-height:50px;"/>
<h2 class="mt-4 text-center text-4xl font-extrabold text-gray-800 dark:text-gray-200">
Gatus
</h2>
<div class="py-7 px-4 rounded-sm sm:px-10">
<div v-if="$route && $route.query.error" class="text-red-500 text-center mb-5">
<div class="text-sm">
<span class="text-red-500" v-if="$route.query.error === 'access_denied'">You do not have access to this status page</span>
<span class="text-red-500" v-else>{{ $route.query.error }}</span>
<!-- Main App Container -->
<div v-else-if="!config || !config.oidc || config.authenticated" class="relative">
<!-- Header -->
<header class="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<div class="container mx-auto px-4 py-4 max-w-7xl">
<div class="flex items-center justify-between">
<!-- Logo and Title -->
<div class="flex items-center gap-4">
<component
:is="link ? 'a' : 'div'"
:href="link"
target="_blank"
class="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<div class="w-12 h-12 flex items-center justify-center">
<img
v-if="logo"
:src="logo"
alt="Gatus"
class="w-full h-full object-contain"
/>
<img
v-else
src="./assets/logo.svg"
alt="Gatus"
class="w-full h-full object-contain"
/>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">{{ header }}</h1>
<p v-if="buttons && buttons.length" class="text-sm text-muted-foreground">
System Monitoring Dashboard
</p>
</div>
</component>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-2">
<!-- Navigation Links (Desktop) -->
<nav v-if="buttons && buttons.length" class="hidden md:flex items-center gap-1">
<a
v-for="button in buttons"
:key="button.name"
:href="button.link"
target="_blank"
class="px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{{ button.name }}
</a>
</nav>
<!-- Mobile Menu Button -->
<Button
v-if="buttons && buttons.length"
variant="ghost"
size="icon"
class="md:hidden"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<Menu v-if="!mobileMenuOpen" class="h-5 w-5" />
<X v-else class="h-5 w-5" />
</Button>
</div>
</div>
<!-- Mobile Navigation -->
<nav
v-if="buttons && buttons.length && mobileMenuOpen"
class="md:hidden mt-4 pt-4 border-t space-y-1"
>
<a
v-for="button in buttons"
:key="button.name"
:href="button.link"
target="_blank"
class="block px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
@click="mobileMenuOpen = false"
>
{{ button.name }}
</a>
</nav>
</div>
</div>
<div>
<a :href="`${SERVER_URL}/oidc/login`" class="max-w-lg mx-auto w-full flex justify-center py-3 px-4 border border-green-800 rounded-md shadow-lg text-sm text-white bg-green-700 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800">
Login with OIDC
</a>
</div>
</div>
</div>
</header>
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
<Social/>
<!-- Main Content -->
<main class="relative">
<router-view @showTooltip="showTooltip" :announcements="announcements" />
</main>
<!-- Footer -->
<footer class="border-t mt-auto">
<div class="container mx-auto px-4 py-6 max-w-7xl">
<div class="flex flex-col items-center gap-4">
<div class="text-sm text-muted-foreground text-center">
Powered by <a href="https://gatus.io" target="_blank" class="font-medium text-emerald-800 hover:text-emerald-600">Gatus</a>
</div>
<Social />
</div>
</div>
</footer>
</div>
<!-- OIDC Login Screen -->
<div v-else id="login-container" class="flex items-center justify-center min-h-screen p-4">
<Card class="w-full max-w-md">
<CardHeader class="text-center">
<img
src="./assets/logo.svg"
alt="Gatus"
class="w-20 h-20 mx-auto mb-4"
/>
<CardTitle class="text-3xl">Gatus</CardTitle>
<p class="text-muted-foreground mt-2">System Monitoring Dashboard</p>
</CardHeader>
<CardContent>
<div v-if="route && route.query.error" class="mb-6">
<div class="p-3 rounded-md bg-destructive/10 border border-destructive/20">
<p class="text-sm text-destructive text-center">
<span v-if="route.query.error === 'access_denied'">
You do not have access to this status page
</span>
<span v-else>{{ route.query.error }}</span>
</p>
</div>
</div>
<a
:href="`${SERVER_URL}/oidc/login`"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full"
@click="isOidcLoading = true"
>
<Loading v-if="isOidcLoading" size="xs" />
<template v-else>
<LogIn class="mr-2 h-4 w-4" />
Login with OIDC
</template>
</a>
</CardContent>
</Card>
</div>
<!-- Tooltip -->
<Tooltip :result="tooltip.result" :event="tooltip.event" />
</div>
</template>
<script>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { Menu, X, LogIn } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue';
import {SERVER_URL} from "@/main";
import Loading from "@/components/Loading";
import Tooltip from './components/Tooltip.vue'
import Loading from './components/Loading.vue'
import { SERVER_URL } from '@/main'
export default {
name: 'App',
components: {
Loading,
Social,
Tooltip
},
methods: {
fetchConfig() {
fetch(`${SERVER_URL}/api/v1/config`, {credentials: 'include'})
.then(response => {
this.retrievedConfig = true;
if (response.status === 200) {
response.json().then(data => {
this.config = data;
})
}
});
},
showTooltip(result, event) {
this.tooltip = {result: result, event: event};
const route = useRoute()
// State
const retrievedConfig = ref(false)
const config = ref({ oidc: false, authenticated: true })
const announcements = ref([])
const tooltip = ref({})
const mobileMenuOpen = ref(false)
const isOidcLoading = ref(false)
let configInterval = null
// Computed properties
const logo = computed(() => {
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : ""
})
const header = computed(() => {
return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Gatus"
})
const link = computed(() => {
return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null
})
const buttons = computed(() => {
return window.config && window.config.buttons ? window.config.buttons : []
})
// Methods
const fetchConfig = async () => {
try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
retrievedConfig.value = true
if (response.status === 200) {
const data = await response.json()
config.value = data
announcements.value = data.announcements || []
}
},
computed: {
logo() {
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : "";
},
header() {
return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Health Status";
},
link() {
return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null;
},
buttons() {
return window.config && window.config.buttons ? window.config.buttons : [];
}
},
data() {
return {
error: '',
retrievedConfig: false,
config: { oidc: false, authenticated: true },
tooltip: {},
SERVER_URL
}
},
created() {
this.fetchConfig();
} catch (error) {
console.error('Failed to fetch config:', error)
retrievedConfig.value = true
}
}
const showTooltip = (result, event) => {
tooltip.value = { result, event }
}
// Fetch config on mount and set up interval
onMounted(() => {
fetchConfig()
// Refresh config every 10 minutes for announcements
configInterval = setInterval(fetchConfig, 600000)
})
// Clean up interval on unmount
onUnmounted(() => {
if (configInterval) {
clearInterval(configInterval)
configInterval = null
}
})
</script>

View File

@@ -0,0 +1,294 @@
<template>
<div v-if="announcements && announcements.length" class="announcement-container mb-4">
<div
:class="[
'rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-200',
containerClasses
]"
>
<!-- Header -->
<div
:class="[
'announcement-header px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors',
isCollapsed ? 'rounded-lg' : 'rounded-t-lg border-b border-gray-200 dark:border-gray-600'
]"
@click="toggleCollapsed"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<component :is="mostRecentIcon" :class="['w-5 h-5', mostRecentIconClass]" />
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Announcements</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">
({{ announcements.length }})
</span>
</div>
<ChevronDown
:class="[
'w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200',
isCollapsed ? '-rotate-90' : 'rotate-0'
]"
/>
</div>
</div>
<!-- Timeline Content -->
<div
v-if="!isCollapsed"
class="announcement-content p-4 transition-all duration-200 rounded-b-lg"
>
<div class="relative">
<!-- Announcements -->
<div class="space-y-3">
<div
v-for="(group, date) in groupedAnnouncements"
:key="date"
class="relative"
>
<!-- Vertical line from date to last icon -->
<div
v-if="group.length > 0"
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="getTimelineHeight(group)"
></div>
<!-- Date Header -->
<div class="flex items-center gap-3 mb-2 relative">
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
<time class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ formatDate(date) }}
</time>
</div>
<div class="flex-1 border-t border-gray-200 dark:border-gray-600"></div>
</div>
<!-- Announcements for this date -->
<div class="space-y-2 ml-7 relative">
<div
v-for="(announcement, index) in group"
:key="`${date}-${index}-${announcement.timestamp}`"
class="relative"
>
<!-- Timeline Icon -->
<div
:class="[
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
getTypeClasses(announcement.type).border
]"
>
<component
:is="getTypeIcon(announcement.type)"
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
/>
</div>
<!-- Announcement Card -->
<div
:class="[
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
getTypeClasses(announcement.type).background
]"
>
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
</div>
<time
:class="[
'text-xs font-mono whitespace-nowrap',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
// Props
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
// Collapse state
const isCollapsed = ref(false)
// Methods
const toggleCollapsed = () => {
isCollapsed.value = !isCollapsed.value
}
// Type configurations
const typeConfigs = {
outage: {
icon: XCircle,
background: 'bg-red-50 border-gray-200 dark:bg-red-900/50 dark:border-gray-600',
border: 'border-red-500',
iconColor: 'text-red-600 dark:text-red-400',
text: 'text-red-700 dark:text-red-300'
},
warning: {
icon: AlertTriangle,
background: 'bg-yellow-50 border-gray-200 dark:bg-yellow-900/50 dark:border-gray-600',
border: 'border-yellow-500',
iconColor: 'text-yellow-600 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-300'
},
information: {
icon: Info,
background: 'bg-blue-50 border-gray-200 dark:bg-blue-900/50 dark:border-gray-600',
border: 'border-blue-500',
iconColor: 'text-blue-600 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-300'
},
operational: {
icon: CheckCircle,
background: 'bg-green-50 border-gray-200 dark:bg-green-900/50 dark:border-gray-600',
border: 'border-green-500',
iconColor: 'text-green-600 dark:text-green-400',
text: 'text-green-700 dark:text-green-300'
},
none: {
icon: Circle,
background: 'bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-600',
border: 'border-gray-500',
iconColor: 'text-gray-600 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-300'
}
}
// Computed properties
const mostRecentAnnouncement = computed(() => {
return props.announcements && props.announcements.length > 0 ? props.announcements[0] : null
})
const mostRecentIcon = computed(() => {
const type = mostRecentAnnouncement.value?.type || 'none'
return typeConfigs[type]?.icon || Circle
})
const mostRecentIconClass = computed(() => {
const type = mostRecentAnnouncement.value?.type || 'none'
return typeConfigs[type]?.iconColor || 'text-gray-600 dark:text-gray-400'
})
const containerClasses = computed(() => {
const type = mostRecentAnnouncement.value?.type || 'none'
const config = typeConfigs[type]
// Add a subtle left border accent to indicate announcement type
return `border-l-4 ${config.border.replace('border-', 'border-l-')}`
})
const groupedAnnouncements = computed(() => {
if (!props.announcements || props.announcements.length === 0) {
return {}
}
const groups = {}
props.announcements.forEach(announcement => {
const date = new Date(announcement.timestamp).toDateString()
if (!groups[date]) {
groups[date] = []
}
groups[date].push(announcement)
})
return groups
})
// Helper functions
const getTypeIcon = (type) => {
return typeConfigs[type]?.icon || Circle
}
const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const getTimelineHeight = (group) => {
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
return {
top: '1.5rem',
height
}
}
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Today'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday'
} else {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
const formatFullTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
})
}
</script>
<style scoped>
.announcement-container {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.announcement-container .ml-7 {
margin-left: 1.5rem;
}
}
</style>

View File

@@ -1,186 +0,0 @@
<template>
<div class='endpoint px-3 py-3 border-l border-r border-t rounded-none hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-500' v-if="data">
<div class='flex flex-wrap mb-2'>
<div class='w-3/4'>
<router-link :to="generatePath()" class="font-bold hover:text-blue-800 hover:underline dark:hover:text-blue-400" title="View detailed endpoint health">
{{ data.name }}
</router-link>
<span v-if="data.results && data.results.length && data.results[data.results.length - 1].hostname" class='text-gray-500 font-light'> | {{ data.results[data.results.length - 1].hostname }}</span>
</div>
<div class='w-1/4 text-right'>
<span class='font-light overflow-x-hidden cursor-pointer select-none hover:text-gray-500' v-if="data.results && data.results.length" @click="toggleShowAverageResponseTime" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">
<slot v-if="showAverageResponseTime">
~{{ averageResponseTime }}ms
</slot>
<slot v-else>
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + '-' + maxResponseTime)) }}ms
</slot>
</span>
<!-- <span class="text-sm font-bold cursor-pointer">-->
<!-- -->
<!-- </span>-->
</div>
</div>
<div>
<div class='status-over-time flex flex-row'>
<slot v-if="data.results && data.results.length">
<slot v-if="data.results.length < maximumNumberOfResults">
<span v-for="filler in maximumNumberOfResults - data.results.length" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
<slot v-for="result in data.results" :key="result">
<span v-if="result.success" class="status status-success rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
<span v-else class="status status-failure rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
</slot>
</slot>
<slot v-else>
<span v-for="filler in maximumNumberOfResults" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
</div>
</div>
<div class='flex flex-wrap status-time-ago'>
<slot v-if="data.results && data.results.length">
<div class='w-1/2'>
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
</div>
<div class='w-1/2 text-right'>
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
</div>
</slot>
<slot v-else>
<div class='w-1/2'>
&nbsp;
</div>
</slot>
</div>
</div>
</template>
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoint',
props: {
maximumNumberOfResults: Number,
data: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
mixins: [helper],
methods: {
updateMinAndMaxResponseTimes() {
let minResponseTime = null;
let maxResponseTime = null;
let totalResponseTime = 0;
for (let i in this.data.results) {
const responseTime = parseInt((this.data.results[i].duration/1000000).toFixed(0));
totalResponseTime += responseTime;
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
if (this.minResponseTime !== minResponseTime) {
this.minResponseTime = minResponseTime;
}
if (this.maxResponseTime !== maxResponseTime) {
this.maxResponseTime = maxResponseTime;
}
if (this.data.results && this.data.results.length) {
this.averageResponseTime = (totalResponseTime/this.data.results.length).toFixed(0);
}
},
generatePath() {
if (!this.data) {
return '/';
}
return `/endpoints/${this.data.key}`;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
data: function () {
this.updateMinAndMaxResponseTimes();
}
},
created() {
this.updateMinAndMaxResponseTimes()
},
data() {
return {
minResponseTime: 0,
maxResponseTime: 0,
averageResponseTime: 0
}
}
}
</script>
<style>
.endpoint:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.endpoint:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-width: 3px;
}
.status-over-time {
overflow: auto;
}
.status-over-time > span:not(:first-child) {
margin-left: 2px;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
color: white;
width: 5%;
font-size: 75%;
font-weight: 700;
text-align: center;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status.status-success::after {
content: "✓";
}
.status.status-failure::after {
content: "X";
}
@media screen and (max-width: 600px) {
.status.status-success::after,
.status.status-failure::after {
content: " ";
white-space: pre;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<Card class="endpoint h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700">
<CardHeader class="endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0">
<div class="flex items-start justify-between gap-2 sm:gap-3">
<div class="flex-1 min-w-0 overflow-hidden">
<CardTitle class="text-base sm:text-lg truncate">
<span
class="hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate"
@click="navigateToDetails"
@keydown.enter="navigateToDetails"
:title="endpoint.name"
role="link"
tabindex="0"
:aria-label="`View details for ${endpoint.name}`">
{{ endpoint.name }}
</span>
</CardTitle>
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
<span v-if="endpoint.group && hostname"></span>
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
</div>
</div>
<div class="flex-shrink-0 ml-2">
<StatusBadge :status="currentStatus" />
</div>
</div>
</CardHeader>
<CardContent class="endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2">
<div class="space-y-2">
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex-1"></div>
<p class="text-xs text-muted-foreground" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">{{ formattedResponseTime }}</p>
</div>
<div class="flex gap-0.5">
<div
v-for="(result, index) in displayResults"
:key="index"
:class="[
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
]"
@mouseenter="result && emit('showTooltip', result, $event)"
@mouseleave="result && emit('showTooltip', null, $event)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{{ oldestResultTime }}</span>
<span>{{ newestResultTime }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup>
/* eslint-disable no-undef */
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import { helper } from '@/mixins/helper'
const router = useRouter()
const props = defineProps({
endpoint: {
type: Object,
required: true
},
maxResults: {
type: Number,
default: 50
},
showAverageResponseTime: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['showTooltip'])
const latestResult = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return null
}
return props.endpoint.results[props.endpoint.results.length - 1]
})
const currentStatus = computed(() => {
if (!latestResult.value) return 'unknown'
return latestResult.value.success ? 'healthy' : 'unhealthy'
})
const hostname = computed(() => {
return latestResult.value?.hostname || null
})
const displayResults = computed(() => {
const results = [...(props.endpoint.results || [])]
while (results.length < props.maxResults) {
results.unshift(null)
}
return results.slice(-props.maxResults)
})
const formattedResponseTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return 'N/A'
}
let total = 0
let count = 0
let min = Infinity
let max = 0
for (const result of props.endpoint.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
total += durationMs
count++
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
}
}
if (count === 0) return 'N/A'
if (props.showAverageResponseTime) {
const avgMs = Math.round(total / count)
return `~${avgMs}ms`
} else {
// Show min-max range
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
}
})
const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
})
const newestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)
})
const navigateToDetails = () => {
router.push(`/endpoints/${props.endpoint.key}`)
}
</script>

View File

@@ -1,99 +0,0 @@
<template>
<div :class="endpoints.length === 0 ? 'mt-3' : 'mt-4'">
<slot v-if="name !== 'undefined'">
<div class="endpoint-group pt-2 border dark:bg-gray-800 dark:border-gray-500" @click="toggleGroup">
<h5 class="font-mono text-gray-400 text-xl font-medium pb-2 px-3 dark:text-gray-200 dark:hover:text-gray-500 dark:border-gray-500">
<span class="endpoint-group-arrow mr-2">
{{ collapsed ? '&#9660;' : '&#9650;' }}
</span>
{{ name }}
<span v-if="unhealthyCount" class="rounded-xl bg-red-600 text-white px-2 font-bold leading-6 float-right h-6 text-center hover:scale-110 text-sm" title="Partial Outage">{{unhealthyCount}}</span>
<span v-else class="float-right text-green-600 w-7 hover:scale-110" title="Operational">
<CheckCircleIcon />
</span>
</h5>
</div>
</slot>
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'endpoint-group-content'">
<slot v-for="(endpoint, idx) in endpoints" :key="idx">
<Endpoint
:data="endpoint"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
/>
</slot>
</div>
</div>
</template>
<script>
import Endpoint from './Endpoint.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
export default {
name: 'EndpointGroup',
components: {
Endpoint,
CheckCircleIcon
},
props: {
name: String,
endpoints: Array,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
healthCheck() {
let unhealthyCount = 0
if (this.endpoints) {
for (let i in this.endpoints) {
if (this.endpoints[i].results && this.endpoints[i].results.length > 0) {
if (!this.endpoints[i].results[this.endpoints[i].results.length-1].success) {
unhealthyCount++
}
}
}
}
this.unhealthyCount = unhealthyCount;
},
toggleGroup() {
this.collapsed = !this.collapsed;
localStorage.setItem(`gatus:endpoint-group:${this.name}:collapsed`, this.collapsed);
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpoints: function () {
this.healthCheck();
}
},
created() {
this.healthCheck();
},
data() {
return {
unhealthyCount: 0,
collapsed: localStorage.getItem(`gatus:endpoint-group:${this.name}:collapsed`) === "true"
}
}
}
</script>
<style>
.endpoint-group {
cursor: pointer;
user-select: none;
}
.endpoint-group h5:hover {
color: #1b1e21;
}
</style>

View File

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

View File

@@ -1,11 +1,35 @@
<template>
<div class="flex justify-center items-center mx-auto">
<img :class="`animate-spin opacity-60 rounded-full`" src="../assets/logo.svg" alt="Gatus logo" />
<div class="flex justify-center items-center">
<img
:class="[
'animate-spin rounded-full opacity-60 grayscale',
sizeClass,
]"
src="../assets/logo.svg"
alt="Gatus logo"
/>
</div>
</template>
<script>
export default {
<script setup>
import { computed } from 'vue'
}
</script>
const props = defineProps({
size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value)
},
})
const sizeClass = computed(() => {
const sizes = {
xs: 'w-4 h-4',
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12',
xl: 'w-16 h-16'
}
return sizes[props.size] || sizes.md
})
</script>

View File

@@ -1,42 +1,73 @@
<template>
<div class="mt-3 flex">
<div class="flex-1">
<button v-if="currentPage < maxPages" @click="nextPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&lt;</button>
</div>
<div class="flex-1 text-right">
<button v-if="currentPage > 1" @click="previousPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&gt;</button>
</div>
<div class="flex items-center justify-between">
<Button
variant="outline"
size="sm"
:disabled="currentPage >= maxPages"
@click="previousPage"
class="flex items-center gap-1"
>
<ChevronLeft class="h-4 w-4" />
Previous
</Button>
<span class="text-sm text-muted-foreground">
Page {{ currentPage }} of {{ maxPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="currentPage <= 1"
@click="nextPage"
class="flex items-center gap-1"
>
Next
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
<script>
export default {
name: 'Pagination',
props: {
numberOfResultsPerPage: Number,
},
components: {},
emits: ['page'],
methods: {
nextPage() {
this.currentPage++;
this.$emit('page', this.currentPage);
},
previousPage() {
this.currentPage--;
this.$emit('page', this.currentPage);
}
},
computed: {
maxPages() {
return Math.ceil(parseInt(window.config.maximumNumberOfResults) / this.numberOfResultsPerPage)
}
},
data() {
return {
currentPage: 1,
const props = defineProps({
numberOfResultsPerPage: Number,
currentPageProp: {
type: Number,
default: 1
}
})
const emit = defineEmits(['page'])
const currentPage = ref(props.currentPageProp)
const maxPages = computed(() => {
// Use maximumNumberOfResults from config if available, otherwise default to 100
let maxResults = 100 // Default value
// Check if window.config exists and has maximumNumberOfResults
if (typeof window !== 'undefined' && window.config && window.config.maximumNumberOfResults) {
const parsed = parseInt(window.config.maximumNumberOfResults)
if (!isNaN(parsed)) {
maxResults = parsed
}
}
return Math.ceil(maxResults / props.numberOfResultsPerPage)
})
const nextPage = () => {
// "Next" should show newer data (lower page numbers)
currentPage.value--
emit('page', currentPage.value)
}
const previousPage = () => {
// "Previous" should show older data (higher page numbers)
currentPage.value++
emit('page', currentPage.value)
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div class="flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<label for="search-input" class="sr-only">Search endpoints</label>
<Input
id="search-input"
v-model="searchQuery"
type="text"
placeholder="Search endpoints..."
class="pl-10 text-sm sm:text-base"
@input="$emit('search', searchQuery)"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Filter by:</label>
<Select
v-model="filterBy"
:options="filterOptions"
placeholder="None"
class="flex-1 sm:w-[140px] md:w-[160px]"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Sort by:</label>
<Select
v-model="sortBy"
:options="sortOptions"
placeholder="Name"
class="flex-1 sm:w-[90px] md:w-[100px]"
@update:model-value="handleSortChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Search } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
const searchQuery = ref('')
const filterBy = ref(localStorage.getItem('gatus:filter-by') || (typeof window !== 'undefined' && window.config?.defaultFilterBy) || 'none')
const sortBy = ref(localStorage.getItem('gatus:sort-by') || (typeof window !== 'undefined' && window.config?.defaultSortBy) || 'name')
const filterOptions = [
{ label: 'None', value: 'none' },
{ label: 'Failing', value: 'failing' },
{ label: 'Unstable', value: 'unstable' }
]
const sortOptions = [
{ label: 'Name', value: 'name' },
{ label: 'Group', value: 'group' },
{ label: 'Health', value: 'health' }
]
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => {
filterBy.value = value
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first
emit('update:showOnlyFailing', false)
emit('update:showRecentFailures', false)
// Apply the selected filter
if (value === 'failing') {
emit('update:showOnlyFailing', true)
} else if (value === 'unstable') {
emit('update:showRecentFailures', true)
}
}
const handleSortChange = (value) => {
sortBy.value = value
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group')
// When switching to group view, initialize collapsed groups
if (value === 'group') {
emit('initializeCollapsedGroups')
}
}
onMounted(() => {
// Apply saved filter/sort state on load
handleFilterChange(filterBy.value)
handleSortChange(sortBy.value)
})
</script>

View File

@@ -1,104 +1,190 @@
<template>
<div id="settings" class="flex bg-gray-200 border-gray-300 rounded border shadow dark:text-gray-200 dark:bg-gray-800 dark:border-gray-500">
<div class="text-xs text-gray-600 rounded-xl py-1.5 px-1.5 dark:text-gray-200">
<ArrowPathIcon class="w-3"/>
<div id="settings" class="fixed bottom-4 left-4 z-50">
<div class="flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1">
<!-- Refresh Rate -->
<button
@click="showRefreshMenu = !showRefreshMenu"
:aria-label="`Refresh interval: ${formatRefreshInterval(refreshIntervalValue)}`"
:aria-expanded="showRefreshMenu"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-accent transition-colors relative"
>
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium">{{ formatRefreshInterval(refreshIntervalValue) }}</span>
<!-- Refresh Rate Dropdown -->
<div
v-if="showRefreshMenu"
@click.stop
class="absolute bottom-full left-0 mb-2 bg-popover border rounded-lg shadow-lg overflow-hidden"
>
<button
v-for="interval in REFRESH_INTERVALS"
:key="interval.value"
@click="selectRefreshInterval(interval.value)"
:class="[
'block w-full px-4 py-2 text-xs text-left hover:bg-accent transition-colors',
refreshIntervalValue === interval.value && 'bg-accent'
]"
>
{{ interval.label }}
</button>
</div>
</button>
<!-- Divider -->
<div class="h-5 w-px bg-border/50" />
<!-- Theme Toggle -->
<button
@click="toggleDarkMode"
:aria-label="darkMode ? 'Switch to light mode' : 'Switch to dark mode'"
class="p-1.5 rounded-full hover:bg-accent transition-colors group relative"
>
<Sun v-if="darkMode" class="h-3.5 w-3.5 transition-all" />
<Moon v-else class="h-3.5 w-3.5 transition-all" />
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded-md shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap">
{{ darkMode ? 'Light mode' : 'Dark mode' }}
</div>
</button>
</div>
<select class="text-center text-gray-500 text-xs dark:text-gray-200 dark:bg-gray-800 border-r border-l border-gray-300 dark:border-gray-500 pl-1" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
<option value="10" :selected="refreshInterval === 10">10s</option>
<option value="30" :selected="refreshInterval === 30">30s</option>
<option value="60" :selected="refreshInterval === 60">1m</option>
<option value="120" :selected="refreshInterval === 120">2m</option>
<option value="300" :selected="refreshInterval === 300">5m</option>
<option value="600" :selected="refreshInterval === 600">10m</option>
</select>
<button @click="toggleDarkMode" class="text-xs p-1">
<slot v-if="darkMode"><SunIcon class="w-4"/></slot>
<slot v-else><MoonIcon class="w-4 text-gray-500"/></slot>
</button>
</div>
</template>
<script>
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid'
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
<script setup>
/* eslint-disable no-undef */
import { ref, onMounted, onUnmounted } from 'vue'
import { Sun, Moon, RefreshCw } from 'lucide-vue-next'
const emit = defineEmits(['refreshData'])
// Constants
const REFRESH_INTERVALS = [
{ value: '10', label: '10s' },
{ value: '30', label: '30s' },
{ value: '60', label: '1m' },
{ value: '120', label: '2m' },
{ value: '300', label: '5m' },
{ value: '600', label: '10m' }
]
const DEFAULT_REFRESH_INTERVAL = '300'
const THEME_COOKIE_NAME = 'theme'
const THEME_COOKIE_MAX_AGE = 31536000 // 1 year
const STORAGE_KEYS = {
REFRESH_INTERVAL: 'gatus:refresh-interval'
}
// Helper functions
function wantsDarkMode() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
return themeFromCookie === 'dark' || !themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark"));
const themeFromCookie = document.cookie.match(new RegExp(`${THEME_COOKIE_NAME}=(dark|light);?`))?.[1]
return themeFromCookie === 'dark' || (!themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark")))
}
export default {
name: 'Settings',
components: {
ArrowPathIcon,
MoonIcon,
SunIcon
},
props: {},
methods: {
setRefreshInterval(seconds) {
localStorage.setItem('gatus:refresh-interval', seconds);
let that = this;
this.refreshIntervalHandler = setInterval(function () {
that.refreshData();
}, seconds * 1000);
},
refreshData() {
this.$emit('refreshData');
},
handleChangeRefreshInterval() {
this.refreshData();
clearInterval(this.refreshIntervalHandler);
this.setRefreshInterval(this.$refs.refreshInterval.value);
},
toggleDarkMode() {
if (wantsDarkMode()) {
document.cookie = `theme=light; path=/; max-age=31536000; samesite=strict`;
} else {
document.cookie = `theme=dark; path=/; max-age=31536000; samesite=strict`;
}
this.applyTheme();
},
applyTheme() {
if (wantsDarkMode()) {
this.darkMode = true;
document.documentElement.classList.add('dark');
} else {
this.darkMode = false;
document.documentElement.classList.remove('dark');
}
}
},
created() {
if (this.refreshInterval !== 10 && this.refreshInterval !== 30 && this.refreshInterval !== 60 && this.refreshInterval !== 120 && this.refreshInterval !== 300 && this.refreshInterval !== 600) {
this.refreshInterval = 300;
}
this.setRefreshInterval(this.refreshInterval);
this.applyTheme();
},
unmounted() {
clearInterval(this.refreshIntervalHandler);
},
data() {
return {
refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')),
refreshIntervalHandler: 0,
darkMode: wantsDarkMode()
}
},
function getStoredRefreshInterval() {
const stored = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL)
const parsedValue = stored && parseInt(stored)
const isValid = parsedValue && parsedValue >= 10 && REFRESH_INTERVALS.some(i => i.value === stored)
return isValid ? stored : DEFAULT_REFRESH_INTERVAL
}
// State
const refreshIntervalValue = ref(getStoredRefreshInterval())
const darkMode = ref(wantsDarkMode())
const showRefreshMenu = ref(false)
let refreshIntervalHandler = null
// Methods
const formatRefreshInterval = (value) => {
const interval = REFRESH_INTERVALS.find(i => i.value === value)
return interval ? interval.label : `${value}s`
}
const setRefreshInterval = (seconds) => {
localStorage.setItem(STORAGE_KEYS.REFRESH_INTERVAL, seconds)
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
refreshIntervalHandler = setInterval(() => {
refreshData()
}, seconds * 1000)
}
const refreshData = () => {
emit('refreshData')
}
const selectRefreshInterval = (value) => {
refreshIntervalValue.value = value
showRefreshMenu.value = false
refreshData()
setRefreshInterval(value)
}
// Close menu when clicking outside
const handleClickOutside = (event) => {
const settings = document.getElementById('settings')
if (settings && !settings.contains(event.target)) {
showRefreshMenu.value = false
}
}
const setThemeCookie = (theme) => {
document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=strict`
}
const toggleDarkMode = () => {
const newTheme = wantsDarkMode() ? 'light' : 'dark'
setThemeCookie(newTheme)
applyTheme()
}
const applyTheme = () => {
const isDark = wantsDarkMode()
darkMode.value = isDark
document.documentElement.classList.toggle('dark', isDark)
}
// Lifecycle
onMounted(() => {
setRefreshInterval(refreshIntervalValue.value)
applyTheme()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
document.removeEventListener('click', handleClickOutside)
})
</script>
<style>
#settings {
position: fixed;
left: 10px;
bottom: 10px;
<style scoped>
/* Animations for smooth transitions */
@keyframes slideIn {
from {
transform: translateX(-20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
#settings select:focus {
box-shadow: none;
#settings {
animation: slideIn 0.3s ease-out;
}
#settings > div {
transition: all 0.2s ease;
}
#settings > div:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
</style>

View File

@@ -8,14 +8,9 @@
</div>
</template>
<script>
export default {
name: 'Social'
}
<script setup>
</script>
<style scoped>
#social {
position: fixed;
@@ -33,4 +28,4 @@ export default {
#social img:hover {
opacity: 1;
}
</style>
</style>

View File

@@ -0,0 +1,58 @@
<template>
<Badge :variant="variant" class="flex items-center gap-1">
<span :class="['w-2 h-2 rounded-full', dotClass]"></span>
{{ label }}
</Badge>
</template>
<script setup>
import { computed } from 'vue'
import { Badge } from '@/components/ui/badge'
const props = defineProps({
status: {
type: String,
required: true,
validator: (value) => ['healthy', 'unhealthy', 'degraded', 'unknown'].includes(value)
}
})
const variant = computed(() => {
switch (props.status) {
case 'healthy':
return 'success'
case 'unhealthy':
return 'destructive'
case 'degraded':
return 'warning'
default:
return 'secondary'
}
})
const label = computed(() => {
switch (props.status) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'degraded':
return 'Degraded'
default:
return 'Unknown'
}
})
const dotClass = computed(() => {
switch (props.status) {
case 'healthy':
return 'bg-green-400'
case 'unhealthy':
return 'bg-red-400'
case 'degraded':
return 'bg-yellow-400'
default:
return 'bg-gray-400'
}
})
</script>

View File

@@ -1,130 +1,158 @@
<template>
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
<slot v-if="result">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
<slot v-if="result.conditionResults && result.conditionResults.length">
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
{{ conditionResult.success ? "&#10003;" : "X" }} ~ {{ conditionResult.condition }}<br/>
</slot>
</code>
</slot>
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">
<slot v-for="error in result.errors" :key="error">
- {{ error }}<br/>
</slot>
</code>
<div
id="tooltip"
ref="tooltip"
:class="[
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
'bg-popover text-popover-foreground border-border',
hidden ? 'invisible opacity-0' : 'visible opacity-100'
]"
:style="`top: ${top}px; left: ${left}px;`"
>
<div v-if="result" class="space-y-2">
<!-- Timestamp -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timestamp</div>
<div class="font-mono text-xs">{{ prettifyTimestamp(result.timestamp) }}</div>
</div>
</slot>
<!-- Response Time -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Response Time</div>
<div class="font-mono text-xs">{{ (result.duration / 1000000).toFixed(0) }}ms</div>
</div>
<!-- Conditions -->
<div v-if="result.conditionResults && result.conditionResults.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Conditions</div>
<div class="font-mono text-xs space-y-0.5">
<div
v-for="(conditionResult, index) in result.conditionResults"
:key="index"
class="flex items-start gap-1"
>
<span :class="conditionResult.success ? 'text-green-500' : 'text-red-500'">
{{ conditionResult.success ? '✓' : '✗' }}
</span>
<span class="break-all">{{ conditionResult.condition }}</span>
</div>
</div>
</div>
<!-- Errors -->
<div v-if="result.errors && result.errors.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Errors</div>
<div class="font-mono text-xs space-y-0.5">
<div v-for="(error, index) in result.errors" :key="index" class="text-red-500">
{{ error }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick } from 'vue'
import { helper } from '@/mixins/helper'
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoints',
props: {
event: Event,
result: Object
const props = defineProps({
event: {
type: [Event, Object],
default: null
},
mixins: [helper],
methods: {
htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
},
reposition() {
if (this.event && this.event.type) {
if (this.event.type === 'mouseenter') {
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
}
}
this.top = targetTopPosition;
this.left = targetLeftPosition;
} else if (this.event.type === 'mouseleave') {
this.hidden = true;
result: {
type: Object,
default: null
}
})
// State
const hidden = ref(true)
const top = ref(0)
const left = ref(0)
const tooltip = ref(null)
// Methods from helper mixin
const { prettifyTimestamp } = helper.methods
const reposition = async () => {
if (!props.event || !props.event.type) return
await nextTick()
if (props.event.type === 'mouseenter' && tooltip.value) {
const target = props.event.target
const targetRect = target.getBoundingClientRect()
// First, position tooltip to get its dimensions
hidden.value = false
await nextTick()
const tooltipRect = tooltip.value.getBoundingClientRect()
// Since tooltip uses position: fixed, we work with viewport coordinates
// getBoundingClientRect() already gives us viewport-relative positions
// Default position: below the target
let newTop = targetRect.bottom + 8
let newLeft = targetRect.left
// Check if tooltip would overflow the viewport bottom
const spaceBelow = window.innerHeight - targetRect.bottom
const spaceAbove = targetRect.top
if (spaceBelow < tooltipRect.height + 20) {
// Not enough space below, try above
if (spaceAbove > tooltipRect.height + 20) {
// Position above
newTop = targetRect.top - tooltipRect.height - 8
} else {
// Not enough space above either, position at the best spot
if (spaceAbove > spaceBelow) {
// More space above
newTop = 10
} else {
// More space below or equal, keep below but adjust
newTop = window.innerHeight - tooltipRect.height - 10
}
}
}
},
watch: {
event: function (value) {
if (value && value.type) {
if (value.type === 'mouseenter') {
this.hidden = false;
} else if (value.type === 'mouseleave') {
this.hidden = true;
}
// Adjust horizontal position if tooltip would overflow right edge
const spaceRight = window.innerWidth - targetRect.left
if (spaceRight < tooltipRect.width + 20) {
// Align right edge of tooltip with right edge of target
newLeft = targetRect.right - tooltipRect.width
// Make sure it doesn't go off the left edge
if (newLeft < 10) {
newLeft = 10
}
}
},
updated() {
this.reposition();
},
created() {
this.reposition();
},
data() {
return {
hidden: true,
top: 0,
left: 0
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
} else if (props.event.type === 'mouseleave') {
hidden.value = true
}
}
</script>
// Watchers
watch(() => props.event, (newEvent) => {
if (newEvent && newEvent.type) {
if (newEvent.type === 'mouseenter') {
hidden.value = false
nextTick(() => reposition())
} else if (newEvent.type === 'mouseleave') {
hidden.value = true
}
}
}, { immediate: true })
<style>
#tooltip {
position: fixed;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip > .tooltip-title:first-child {
margin-top: 0;
}
</style>
watch(() => props.result, () => {
if (!hidden.value) {
nextTick(() => reposition())
}
})
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div :class="combineClasses(badgeVariants({ variant }), $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
})
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white',
warning: 'border-transparent bg-yellow-500 text-white',
},
},
defaultVariants: {
variant: 'default',
},
}
)
</script>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.vue'

View File

@@ -0,0 +1,55 @@
<template>
<button
:class="combineClasses(buttonVariants({ variant, size }), $attrs.class ?? '')"
:disabled="disabled"
>
<slot />
</button>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
size: {
type: String,
default: 'default',
},
disabled: {
type: Boolean,
default: false,
},
})
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
</script>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.vue'

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('p-6 pt-0', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('flex flex-col space-y-1.5 p-6', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<h3 :class="combineClasses('text-2xl font-semibold leading-none tracking-tight', $attrs.class ?? '')">
<slot />
</h3>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,4 @@
export { default as Card } from './Card.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as CardContent } from './CardContent.vue'

View File

@@ -0,0 +1,24 @@
<template>
<input
:class="combineClasses(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
$attrs.class ?? ''
)"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
})
defineEmits(['update:modelValue'])
</script>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,127 @@
<template>
<div ref="selectRef" class="relative" :class="props.class">
<button
@click="toggleDropdown"
@keydown="handleKeyDown"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="selectedOption.label || props.placeholder"
class="flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span class="truncate">{{ selectedOption.label }}</span>
<ChevronDown class="h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1" />
</button>
<div
v-if="isOpen"
role="listbox"
class="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
>
<div class="p-1">
<div
v-for="(option, index) in options"
:key="option.value"
@click="selectOption(option)"
:class="[
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-6 sm:pl-8 pr-2 text-xs sm:text-sm outline-none hover:bg-accent hover:text-accent-foreground',
index === focusedIndex && 'bg-accent text-accent-foreground'
]"
role="option"
:aria-selected="modelValue === option.value"
>
<span class="absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check v-if="modelValue === option.value" class="h-3 w-3 sm:h-4 sm:w-4" />
</span>
{{ option.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, required: true },
placeholder: { type: String, default: 'Select...' },
class: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const isOpen = ref(false)
const selectRef = ref(null)
const focusedIndex = ref(-1)
const selectedOption = computed(() => {
return props.options.find(option => option.value === props.modelValue) || { label: props.placeholder, value: '' }
})
const selectOption = (option) => {
emit('update:modelValue', option.value)
isOpen.value = false
}
const toggleDropdown = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
// Set initial focus to selected option or first option
const selectedIdx = props.options.findIndex(opt => opt.value === props.modelValue)
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
} else {
focusedIndex.value = -1
}
}
const handleClickOutside = (event) => {
if (selectRef.value && !selectRef.value.contains(event.target)) {
isOpen.value = false
focusedIndex.value = -1
}
}
const handleKeyDown = (event) => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
toggleDropdown()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusedIndex.value = Math.min(focusedIndex.value + 1, props.options.length - 1)
break
case 'ArrowUp':
event.preventDefault()
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0 && focusedIndex.value < props.options.length) {
selectOption(props.options[focusedIndex.value])
}
break
case 'Escape':
event.preventDefault()
isOpen.value = false
focusedIndex.value = -1
break
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -0,0 +1 @@
export { default as Select } from './Select.vue'

View File

@@ -2,38 +2,78 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
:root.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.bg-success {
background-color: #28a745;
}
html:not(.dark) body {
background-color: #f7f9fb;
}
html {
height: 100%;
}
body {
padding-top: 20px;
padding-bottom: 20px;
min-height: 100vh;
}
#global {
margin-top: 0 !important;
}
#global, #results {
max-width: 1280px;
}
@media screen and (max-width: 1279px) {
body {
padding-top: 0;
padding-bottom: 0;
}
#global {
min-height: 100vh;
}
}

6
web/app/src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function combineClasses(...inputs) {
return twMerge(clsx(inputs))
}

View File

@@ -1,231 +1,399 @@
<template>
<router-link to="../"
class="absolute top-2 left-5 inline-block px-2 pb-0.5 text-sm text-black bg-gray-100 rounded hover:bg-gray-200 focus:outline-none border border-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">
&larr;
</router-link>
<div>
<slot v-if="endpointStatus">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RECENT CHECKS</h1>
<hr class="mb-4"/>
<Endpoint
:data="endpointStatus"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
:showAverageResponseTime="showAverageResponseTime"
/>
<Pagination @page="changePage" :numberOfResultsPerPage="20" />
</slot>
<div v-if="endpointStatus && endpointStatus.key" class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
<hr/>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
<img :src="generateUptimeBadgeImageURL('30d')" alt="30d uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateUptimeBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateUptimeBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key && showResponseTimeChartAndBadges" class="mt-12">
<div class="flex items-center justify-between">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RESPONSE TIME</h1>
<select v-model="selectedChartDuration" class="text-sm bg-gray-400 text-white border border-gray-600 rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="response time chart" class="mt-6"/>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
<img :src="generateResponseTimeBadgeImageURL('30d')" alt="7d response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
<img :src="generateResponseTimeBadgeImageURL('7d')" alt="7d response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateResponseTimeBadgeImageURL('24h')" alt="24h response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateResponseTimeBadgeImageURL('1h')" alt="1h response time badge" class="mx-auto mt-2"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">CURRENT HEALTH</h1>
<hr />
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">EVENTS</h1>
<hr />
<ul role="list" class="px-0 xl:px-24 divide-y divide-gray-200 dark:divide-gray-600">
<li v-for="event in events" :key="event" class="p-3 my-4">
<h2 class="text-sm sm:text-lg">
<ArrowUpCircleIcon v-if="event.type === 'HEALTHY'" class="w-8 inline mr-2 text-green-600" />
<ArrowDownCircleIcon v-else-if="event.type === 'UNHEALTHY'" class="w-8 inline mr-2 text-red-500" />
<PlayCircleIcon v-else-if="event.type === 'START'" class="w-8 inline mr-2 text-gray-400 dark:text-gray-100" />
{{ event.fancyText }}
</h2>
<div class="flex mt-1 text-xs sm:text-sm text-gray-400">
<div class="flex-2 text-left pl-12">
{{ prettifyTimestamp(event.timestamp) }}
<div class="dashboard-container bg-background">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<div class="mb-8">
<Button variant="ghost" class="mb-4" @click="goBack">
<ArrowLeft class="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
<div v-if="endpointStatus && endpointStatus.name" class="space-y-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-4xl font-bold tracking-tight">{{ endpointStatus.name }}</h1>
<div class="flex items-center gap-3 text-muted-foreground mt-2">
<span v-if="endpointStatus.group">Group: {{ endpointStatus.group }}</span>
<span v-if="endpointStatus.group && hostname"></span>
<span v-if="hostname">{{ hostname }}</span>
</div>
</div>
<div class="flex-1 text-right">
{{ event.fancyTimeAgo }}
<StatusBadge :status="currentHealthStatus" />
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Current Status</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ currentHealthStatus === 'healthy' ? 'Operational' : 'Issues Detected' }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Response Time Range</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageResponseTimeRange }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Last Check</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ lastCheckTime }}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Recent Checks</CardTitle>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
@click="showAverageResponseTime = !showAverageResponseTime"
:title="showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'"
>
<Activity v-if="showAverageResponseTime" class="h-5 w-5" />
<Timer v-else class="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
@click="fetchData"
title="Refresh data"
:disabled="isRefreshing"
>
<RefreshCw :class="['h-4 w-4', isRefreshing && 'animate-spin']" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div class="space-y-4">
<EndpointCard
v-if="endpointStatus"
:endpoint="endpointStatus"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
class="border-0 shadow-none bg-transparent p-0"
/>
<div v-if="endpointStatus && endpointStatus.key" class="pt-4 border-t">
<Pagination @page="changePage" :numberOfResultsPerPage="50" :currentPageProp="currentPage" />
</div>
</div>
</CardContent>
</Card>
<div v-if="showResponseTimeChartAndBadges" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Response Time Trend</CardTitle>
<select
v-model="selectedChartDuration"
class="text-sm bg-background border rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
</CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card v-for="period in ['30d', '7d', '24h', '1h']" :key="period">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground text-center">
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
</CardTitle>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeBadgeImageURL(period)" :alt="`${period} response time`" class="mx-auto mt-2" />
</CardContent>
</Card>
</div>
</div>
</li>
</ul>
<Card>
<CardHeader>
<CardTitle>Uptime Statistics</CardTitle>
</CardHeader>
<CardContent>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div v-for="period in ['30d', '7d', '24h', '1h']" :key="period" class="text-center">
<p class="text-sm text-muted-foreground mb-2">
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
</p>
<img :src="generateUptimeBadgeImageURL(period)" :alt="`${period} uptime`" class="mx-auto" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current Health</CardTitle>
</CardHeader>
<CardContent>
<div class="text-center">
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto" />
</div>
</CardContent>
</Card>
<Card v-if="events && events.length > 0">
<CardHeader>
<CardTitle>Events</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div v-for="event in events" :key="event.timestamp" class="flex items-start gap-4 pb-4 border-b last:border-0">
<div class="mt-1">
<ArrowUpCircle v-if="event.type === 'HEALTHY'" class="h-5 w-5 text-green-500" />
<ArrowDownCircle v-else-if="event.type === 'UNHEALTHY'" class="h-5 w-5 text-red-500" />
<PlayCircle v-else class="h-5 w-5 text-muted-foreground" />
</div>
<div class="flex-1">
<p class="font-medium">{{ event.fancyText }}</p>
<p class="text-sm text-muted-foreground">{{ prettifyTimestamp(event.timestamp) }} {{ event.fancyTimeAgo }}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div v-else class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
</div>
</div>
<Settings @refreshData="fetchData" />
</div>
<Settings @refreshData="fetchData"/>
</template>
<script>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, ArrowUpCircle, ArrowDownCircle, PlayCircle, Activity, Timer } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import Settings from '@/components/Settings.vue'
import Endpoint from '@/components/Endpoint.vue';
import {SERVER_URL} from "@/main.js";
import {helper} from "@/mixins/helper.js";
import Pagination from "@/components/Pagination";
import { ArrowDownCircleIcon, ArrowUpCircleIcon, PlayCircleIcon } from '@heroicons/vue/20/solid'
import Pagination from '@/components/Pagination.vue'
import Loading from '@/components/Loading.vue'
import { SERVER_URL } from '@/main.js'
import { helper } from '@/mixins/helper'
export default {
name: 'Details',
components: {
Pagination,
Endpoint,
Settings,
ArrowDownCircleIcon,
ArrowUpCircleIcon,
PlayCircleIcon
},
emits: ['showTooltip'],
mixins: [helper],
methods: {
fetchData() {
//console.log("[Details][fetchData] Fetching data");
fetch(`${this.serverUrl}/api/v1/endpoints/${this.$route.params.key}/statuses?page=${this.currentPage}`, {credentials: 'include'})
.then(response => {
if (response.status === 200) {
response.json().then(data => {
if (JSON.stringify(this.endpointStatus) !== JSON.stringify(data)) {
this.endpointStatus = data;
let events = [];
for (let i = data.events.length - 1; i >= 0; i--) {
let event = data.events[i];
if (i === data.events.length - 1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Endpoint is unhealthy';
} else if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint is healthy';
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
} else {
let nextEvent = data.events[i + 1];
if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint became healthy';
} else if (event.type === 'UNHEALTHY') {
if (nextEvent) {
event.fancyText = 'Endpoint was unhealthy for ' + this.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp);
} else {
event.fancyText = 'Endpoint became unhealthy';
}
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
}
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
events.push(event);
}
this.events = events;
// Check if there's any non-0 response time data
// If there isn't, it's likely an external endpoint, which means we should
// hide the response time chart and badges
for (let i = 0; i < data.results.length; i++) {
if (data.results[i].duration > 0) {
this.showResponseTimeChartAndBadges = true;
break;
}
}
}
});
} else {
response.text().then(text => {
console.log(`[Details][fetchData] Error: ${text}`);
});
}
});
},
generateHealthBadgeImageURL() {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/health/badge.svg`;
},
generateUptimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
},
generateResponseTimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/badge.svg`;
},
generateResponseTimeChartImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/chart.svg`;
},
changePage(page) {
this.currentPage = page;
this.fetchData();
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.showAverageResponseTime = !this.showAverageResponseTime;
},
},
data() {
return {
endpointStatus: {},
events: [],
hourlyAverageResponseTime: {},
selectedChartDuration: '24h',
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
currentPage: 1,
showAverageResponseTime: true,
showResponseTimeChartAndBadges: false,
chartLabels: [],
chartValues: [],
const router = useRouter()
const route = useRoute()
const emit = defineEmits(['showTooltip'])
const endpointStatus = ref(null) // For paginated historical data
const currentStatus = ref(null) // For current/latest status (always page 1)
const events = ref([])
const currentPage = ref(1)
const showResponseTimeChartAndBadges = ref(false)
const showAverageResponseTime = ref(false)
const selectedChartDuration = ref('24h')
const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
const isRefreshing = ref(false)
const latestResult = computed(() => {
// Use currentStatus for the actual latest result
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
return null
}
return currentStatus.value.results[currentStatus.value.results.length - 1]
})
const currentHealthStatus = computed(() => {
if (!latestResult.value) return 'unknown'
return latestResult.value.success ? 'healthy' : 'unhealthy'
})
const hostname = computed(() => {
return latestResult.value?.hostname || null
})
const pageAverageResponseTime = computed(() => {
// Use endpointStatus for current page's average response time
if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
return 'N/A'
}
let total = 0
let count = 0
for (const result of endpointStatus.value.results) {
if (result.duration) {
total += result.duration
count++
}
},
created() {
this.fetchData();
}
if (count === 0) return 'N/A'
return Math.round(total / count / 1000000)
})
const pageResponseTimeRange = computed(() => {
// Use endpointStatus for current page's response time range
if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
return 'N/A'
}
let min = Infinity
let max = 0
let hasData = false
for (const result of endpointStatus.value.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
hasData = true
}
}
if (!hasData) return 'N/A'
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
})
const lastCheckTime = computed(() => {
// Use currentStatus for real-time last check time
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
return 'Never'
}
return helper.methods.generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
})
const fetchData = async () => {
isRefreshing.value = true
try {
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
endpointStatus.value = data
// Always update currentStatus when on page 1 (including when returning to it)
if (currentPage.value === 1) {
currentStatus.value = data
}
let processedEvents = []
if (data.events && data.events.length > 0) {
for (let i = data.events.length - 1; i >= 0; i--) {
let event = data.events[i]
if (i === data.events.length - 1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Endpoint is unhealthy'
} else if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint is healthy'
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started'
}
} else {
let nextEvent = data.events[i + 1]
if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint became healthy'
} else if (event.type === 'UNHEALTHY') {
if (nextEvent) {
event.fancyText = 'Endpoint was unhealthy for ' + helper.methods.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
} else {
event.fancyText = 'Endpoint became unhealthy'
}
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started'
}
}
event.fancyTimeAgo = helper.methods.generatePrettyTimeAgo(event.timestamp)
processedEvents.push(event)
}
}
events.value = processedEvents
if (data.results && data.results.length > 0) {
for (let i = 0; i < data.results.length; i++) {
if (data.results[i].duration > 0) {
showResponseTimeChartAndBadges.value = true
break
}
}
}
} else {
console.error('[Details][fetchData] Error:', await response.text())
}
} catch (error) {
console.error('[Details][fetchData] Error:', error)
} finally {
isRefreshing.value = false
}
}
</script>
<style scoped>
.endpoint {
border-radius: 3px;
border-bottom-width: 3px;
const goBack = () => {
router.push('/')
}
</style>
const changePage = (page) => {
currentPage.value = page
fetchData()
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
}
const prettifyTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString()
}
const generateHealthBadgeImageURL = () => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
}
const generateUptimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
}
const generateResponseTimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
const generateResponseTimeChartImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
}
onMounted(() => {
fetchData()
})
</script>

View File

@@ -1,76 +1,388 @@
<template>
<Loading v-if="!retrievedData" class="h-64 w-64 px-4 my-24"/>
<slot>
<Endpoints
v-show="retrievedData"
:endpointStatuses="endpointStatuses"
:showStatusOnHover="true"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
:showAverageResponseTime="showAverageResponseTime"
/>
<Pagination v-show="retrievedData" @page="changePage" :numberOfResultsPerPage="20" />
</slot>
<Settings @refreshData="fetchData"/>
<div class="dashboard-container bg-background">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
<p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
</div>
<div class="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
@click="toggleShowAverageResponseTime"
:title="showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'"
>
<Activity v-if="showAverageResponseTime" class="h-5 w-5" />
<Timer v-else class="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" @click="refreshData" title="Refresh data">
<RefreshCw class="h-5 w-5" />
</Button>
</div>
</div>
<SearchBar
@search="handleSearch"
@update:showOnlyFailing="showOnlyFailing = $event"
@update:showRecentFailures="showRecentFailures = $event"
@update:groupByGroup="groupByGroup = $event"
@update:sortBy="sortBy = $event"
@initializeCollapsedGroups="initializeCollapsedGroups"
/>
</div>
<!-- Announcements Banner -->
<AnnouncementBanner :announcements="props.announcements" />
<div>
</div>
<div v-if="loading" class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
<div v-else-if="filteredEndpoints.length === 0" class="text-center py-20">
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-semibold mb-2">No endpoints found</h3>
<p class="text-muted-foreground">
{{ searchQuery || showOnlyFailing || showRecentFailures
? 'Try adjusting your filters'
: 'No endpoints are configured' }}
</p>
</div>
<div v-else>
<!-- Grouped view -->
<div v-if="groupByGroup" class="space-y-6">
<div v-for="(endpoints, group) in paginatedEndpoints" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
<!-- Group Header -->
<div
@click="toggleGroupCollapse(group)"
class="endpoint-group-header flex items-center justify-between p-4 bg-card border-b cursor-pointer hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-3">
<ChevronDown v-if="!collapsedGroups.has(group)" class="h-5 w-5 text-muted-foreground" />
<ChevronUp v-else class="h-5 w-5 text-muted-foreground" />
<h2 class="text-xl font-semibold text-foreground">{{ group }}</h2>
</div>
<div class="flex items-center gap-2">
<span v-if="calculateUnhealthyCount(endpoints) > 0"
class="bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium">
{{ calculateUnhealthyCount(endpoints) }}
</span>
<CheckCircle v-else class="h-6 w-6 text-green-600" />
</div>
</div>
<!-- Group Content -->
<div v-if="!collapsedGroups.has(group)" class="endpoint-group-content p-4">
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<EndpointCard
v-for="endpoint in endpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
</div>
</div>
</div>
</div>
<!-- Regular view -->
<div v-else class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<EndpointCard
v-for="endpoint in paginatedEndpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
</div>
<div v-if="!groupByGroup && totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="icon"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="flex gap-1">
<Button
v-for="page in visiblePages"
:key="page"
:variant="page === currentPage ? 'default' : 'outline'"
size="sm"
@click="goToPage(page)"
>
{{ page }}
</Button>
</div>
<Button
variant="outline"
size="icon"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Settings @refreshData="fetchData" />
</div>
</template>
<script>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import EndpointCard from '@/components/EndpointCard.vue'
import SearchBar from '@/components/SearchBar.vue'
import Settings from '@/components/Settings.vue'
import Endpoints from '@/components/Endpoints.vue';
import Pagination from "@/components/Pagination";
import Loading from "@/components/Loading";
import {SERVER_URL} from "@/main.js";
import Loading from '@/components/Loading.vue'
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
import { SERVER_URL } from '@/main.js'
export default {
name: 'Home',
components: {
Loading,
Pagination,
Endpoints,
Settings,
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
fetchData() {
fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=${this.currentPage}`, {credentials: 'include'})
.then(response => {
this.retrievedData = true;
if (response.status === 200) {
response.json().then(data => {
if (JSON.stringify(this.endpointStatuses) !== JSON.stringify(data)) {
this.endpointStatuses = data;
}
});
} else {
response.text().then(text => {
console.log(`[Home][fetchData] Error: ${text}`);
});
}
});
},
changePage(page) {
this.retrievedData = false; // Show loading only on page change or on initial load
this.currentPage = page;
this.fetchData();
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.showAverageResponseTime = !this.showAverageResponseTime;
},
},
data() {
return {
endpointStatuses: [],
currentPage: 1,
showAverageResponseTime: true,
retrievedData: false,
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['showTooltip'])
const endpointStatuses = ref([])
const loading = ref(false)
const currentPage = ref(1)
const itemsPerPage = 96
const searchQuery = ref('')
const showOnlyFailing = ref(false)
const showRecentFailures = ref(false)
const showAverageResponseTime = ref(true)
const groupByGroup = ref(false)
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
const collapsedGroups = ref(new Set())
const filteredEndpoints = computed(() => {
let filtered = [...endpointStatuses.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(endpoint =>
endpoint.name.toLowerCase().includes(query) ||
(endpoint.group && endpoint.group.toLowerCase().includes(query))
)
}
if (showOnlyFailing.value) {
filtered = filtered.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
const latestResult = endpoint.results[endpoint.results.length - 1]
return !latestResult.success
})
}
if (showRecentFailures.value) {
filtered = filtered.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
return endpoint.results.some(result => !result.success)
})
}
// Sort by health if selected
if (sortBy.value === 'health') {
filtered.sort((a, b) => {
const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success
const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success
// Unhealthy first
if (!aHealthy && bHealthy) return -1
if (aHealthy && !bHealthy) return 1
// Then sort by name
return a.name.localeCompare(b.name)
})
}
return filtered
})
const totalPages = computed(() => {
return Math.ceil(filteredEndpoints.value.length / itemsPerPage)
})
const groupedEndpoints = computed(() => {
if (!groupByGroup.value) {
return null
}
const grouped = {}
filteredEndpoints.value.forEach(endpoint => {
const group = endpoint.group || 'No Group'
if (!grouped[group]) {
grouped[group] = []
}
grouped[group].push(endpoint)
})
// Sort groups alphabetically, with 'No Group' at the end
const sortedGroups = Object.keys(grouped).sort((a, b) => {
if (a === 'No Group') return 1
if (b === 'No Group') return -1
return a.localeCompare(b)
})
const result = {}
sortedGroups.forEach(group => {
result[group] = grouped[group]
})
return result
})
const paginatedEndpoints = computed(() => {
if (groupByGroup.value) {
// When grouping, we don't paginate
return groupedEndpoints.value
}
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredEndpoints.value.slice(start, end)
})
const visiblePages = computed(() => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const fetchData = async () => {
// Don't show loading state on refresh to prevent UI flicker
const isInitialLoad = endpointStatuses.value.length === 0
if (isInitialLoad) {
loading.value = true
}
try {
const response = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
// If this is the initial load, just set the data
if (isInitialLoad) {
endpointStatuses.value = data
} else {
// Check if endpoints have been added or removed
const currentKeys = new Set(endpointStatuses.value.map(ep => ep.key))
const newKeys = new Set(data.map(ep => ep.key))
const hasAdditions = data.some(ep => !currentKeys.has(ep.key))
const hasRemovals = endpointStatuses.value.some(ep => !newKeys.has(ep.key))
if (hasAdditions || hasRemovals) {
// Endpoints have changed, reset the array to maintain proper order
endpointStatuses.value = data
} else {
// Only statuses/results have changed, update in place to preserve scroll
const endpointMap = new Map(data.map(ep => [ep.key, ep]))
endpointStatuses.value.forEach((endpoint, index) => {
const updated = endpointMap.get(endpoint.key)
if (updated) {
// Update in place to preserve Vue's reactivity and scroll position
Object.assign(endpointStatuses.value[index], updated)
}
})
}
}
} else {
console.error('[Home][fetchData] Error:', await response.text())
}
} catch (error) {
console.error('[Home][fetchData] Error:', error)
} finally {
if (isInitialLoad) {
loading.value = false
}
},
created() {
this.retrievedData = false; // Show loading only on page change or on initial load
this.fetchData();
}
}
const refreshData = () => {
endpointStatuses.value = [];
fetchData()
}
const handleSearch = (query) => {
searchQuery.value = query
currentPage.value = 1
}
const goToPage = (page) => {
currentPage.value = page
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const toggleShowAverageResponseTime = () => {
showAverageResponseTime.value = !showAverageResponseTime.value
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
}
const calculateUnhealthyCount = (endpoints) => {
return endpoints.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
const latestResult = endpoint.results[endpoint.results.length - 1]
return !latestResult.success
}).length
}
const toggleGroupCollapse = (groupName) => {
if (collapsedGroups.value.has(groupName)) {
collapsedGroups.value.delete(groupName)
} else {
collapsedGroups.value.add(groupName)
}
// Save to localStorage
const collapsed = Array.from(collapsedGroups.value)
localStorage.setItem('gatus:collapsed-groups', JSON.stringify(collapsed))
}
const initializeCollapsedGroups = () => {
// Get saved collapsed groups from localStorage
try {
const saved = localStorage.getItem('gatus:collapsed-groups')
if (saved) {
collapsedGroups.value = new Set(JSON.parse(saved))
}
} catch (e) {
console.warn('Failed to parse saved collapsed groups:', e)
localStorage.removeItem('gatus:collapsed-groups')
}
}
onMounted(() => {
fetchData()
})
</script>

View File

@@ -6,9 +6,65 @@ module.exports = {
darkMode: 'class', // or 'media' or 'class'
theme: {
fontFamily: {
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace']
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace'],
'sans': ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif']
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
"accordion-down": {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
"accordion-up": {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
extend: {},
},
variants: {
extend: {},

Some files were not shown because too many files have changed in this diff Show More