Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andTwiN c0c9f81c8e Improve error message with list of changed files
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2025-12-11 01:01:17 +00:00
copilot-swe-agent[bot]andTwiN a7f0aaf87a Add newline at end of test-ui.yml workflow file
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2025-12-11 00:57:52 +00:00
copilot-swe-agent[bot]andTwiN 1087bf2fb2 Add git status check after frontend-build in test-ui workflow
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2025-12-11 00:50:55 +00:00
copilot-swe-agent[bot]andTwiN fef2bb9bf6 Initial plan for git status check after frontend build
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2025-12-11 00:49:24 +00:00
copilot-swe-agent[bot] e7510c856c Initial plan 2025-12-11 00:45:32 +00:00
5f4c26e5fe fix(ui): Do not store config locally on load (#1432)
* fix(ui): Do not store config locally on load

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-10 19:12:54 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2beaca5700 chore(deps): bump codecov/codecov-action from 5.5.1 to 5.5.2 (#1428)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.1 to 5.5.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.1...v5.5.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.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>
2025-12-10 16:54:33 -05:00
PythonGermanyandGitHub a55eb7da40 refactor(ui): Improve dev environment (#1429) 2025-12-10 16:29:46 -05:00
PythonGermanyandGitHub b0629773e5 build(docker): Non tidy go mods fail image build (#1418)
fix(docker): Non tidy go mods fail image build
2025-12-06 17:48:39 -05:00
PythonGermanyandGitHub c5f7e5b82b refactor(docker): Update compose files (#1409)
* refactor(docker): Rename compose files

* refactor(docker): Remove obsolete version attribute
2025-12-01 20:15:46 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a2a7e1f14a chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.31.12 to 1.31.15 (#1366)
chore(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.31.12 to 1.31.15.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.31.12...config/v1.31.15)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.31.15
  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-11-28 20:28:12 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
1e4c440f01 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ses from 1.34.5 to 1.34.7 (#1365)
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ses

Bumps [github.com/aws/aws-sdk-go-v2/service/ses](https://github.com/aws/aws-sdk-go-v2) from 1.34.5 to 1.34.7.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/mq/v1.34.5...service/ses/v1.34.7)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/ses
  dependency-version: 1.34.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>
Co-authored-by: TwiN <twin@linux.com>
2025-11-28 20:16:53 -05:00
TwiN 844847bb05 chore(ui): Regenerate static assets 2025-11-28 19:57:06 -05:00
0c3231713f fix(ui): Show correct avg response time for N/A value (#1407)
* fix(ui): Show correct avg response time not applicable value

* refactor(ui): Convert to milliseconds after loop

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-28 19:56:02 -05:00
ee01adb603 fix(ui): Show correct oldest result timestamp (#1405)
* fix(ui): Show correct oldest result timestamp

* fix(ui): Request correct result page size in home view

* refactor(ui): Use constant for result page size

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-28 19:05:39 -05:00
TwiN 9121d87965 fix(ui): Typo in conditional check if dashboard subheading is not set and running in dev mode 2025-11-27 20:55:30 -05:00
Bo-Yi WuandGitHub 86cd1a9eb2 chore(deps): update Go module dependencies for CVE security (#1402)
chore(mod): update Go module dependencies for CVE security

- Update golang.org/x/crypto, golang.org/x/net, and golang.org/x/sync dependencies to newer versions
- Bump indirect dependencies golang.org/x/mod, golang.org/x/sys, golang.org/x/text, and golang.org/x/tools to latest releases

* CVE-2025-47914 and CVE-2025-58181

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-11-27 17:16:03 -05:00
Giorgio GalloandGitHub 9f960fdd27 docs: complete unfinished sentence in README.md (#1400)
chore: complete unfinished sentence in README.md

Fixes #1398
2025-11-25 22:58:19 -05:00
6f9db4107c feat(client): Add ssh private-key support (#1390)
* feat(endpoint): Add ssh key support

Fixes #1257

* test(config): Add tests for private key config

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-19 16:36:36 -05:00
TwiN 5d626f2934 test(ui): Improve validation tests for UI config 2025-11-16 15:41:25 -05:00
75c1b290f6 feat(ui): customizable dashboard heading and subheading (#1235)
* Made the Dashboard Text customizable

* Aligned with spaces, changed feature name to DashboardHeading and DashboardSubheading

* rebuild frontend

---------

Co-authored-by: macmoritz <tratarmoritz@gmail.com>
2025-11-16 15:33:26 -05:00
GiampaoloandGitHub fe7b74f555 docs: Remove ECS Fargate section from README (#1389)
Remove ECS Fargate section from README

Removed ECS Fargate deployment section from README.
2025-11-12 07:26:47 -05:00
Zee AslamandGitHub ed4c270a25 docs: Add note to README.md regarding CAP_NET_RAW (#1384)
* docs: Add note to README.md regarding CAP_NET_RAW

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* docs: fix inconsistent markdown

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

---------

Signed-off-by: Zee Aslam <zeet6613@gmail.com>
2025-11-09 15:50:24 -05:00
TwiN 379ec2983d refactor(announcements): Move duplicate markdown code into utils/markdown.js 2025-11-08 13:54:36 -05:00
SworyzandGitHub 907716289c feat(announcements): add markdown support (#1371)
* feat(announcements): add markdown support

* feat(announcements): add information about announcement formatting in readme

* feat(announcements): bump packages versions for marked and dompurify

* feat(announcements): bump versions for marked and dompurify in package-lock.json

* fix(announcements): md to link was not working since the conflict merge

* fix(announcements): fix time before message and not after

* feat(announcements): past announcements add markdown support

* feat(announcements): static files
2025-11-08 13:28:57 -05:00
TwiN 7c6b5539c1 docs: Add past-announcements.jpg 2025-11-07 19:42:48 -05:00
TwiNandGitHub 607f3c5549 feat(announcements): Add support for archived announcements and add past announcement section in UI (#1382)
* feat(announcements): Add support for archived announcements and add past announcement section in UI

* Add missing field
2025-11-07 19:35:39 -05:00
Arden RasmussenandGitHub 9e97efaba1 fix(api): Escape endpoint key in URL for raw APIs (#1381) 2025-11-07 18:20:10 -05:00
diamanatandGitHub 8912b4b3e3 feat(client): Add support for monitoring gRPC endpoints (#1376)
* add grpc

* add gRPC to readme
2025-11-07 15:19:13 -05:00
5fdc489113 fix(client): update icmp/ping logic to determine pinger privileged mode (#1346)
* fix(pinger): update logic to determine pinger privileged mode

* add some unit tests for pinger

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* undo accidental removal

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* check for cap_net_raw by trying to open a raw socket and checking for permission error

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* revert syscall after testing. It is unable to build a binary on windows

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* remove extra import

* review icmp section of readme. No changes required

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* Update client/client.go

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

* Update client/client.go

Match function name

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

* Update client/client.go

Remove extra line

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

---------

Signed-off-by: Zee Aslam <zeet6613@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-11-04 20:42:20 -05:00
GiampaoloandGitHub 2ebb74ae1e docs: Add ECS fargate module in README (#1377) 2025-11-02 00:04:28 -04:00
TwiNandGitHub e2f06e9ede fix(ui): Modernize response time chart (#1373) 2025-10-29 14:15:59 -04:00
TwiNandGitHub beb9a2f3d9 feat(condition): Format certificate and domain expiration durations in human-readable format (#1370) 2025-10-27 16:30:12 -04:00
MaxandGitHub e469b6adf4 fix(external-endpoint): check per-endpoint maintenance windows (#1369)
* fix(external-endpoint): check per-endpoint maintenance windows

* refactor(external-endpoint): use tabs for indentation
2025-10-27 12:21:20 -04:00
2f8a3d2a02 feat(metrics): Add metrics for domain expiration (#1244)
* Add metrics for domain expiration

* Update grafana and prometheus versions and extend grafana dashboard with Domain expiration

* feat(deps) update whois version

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-25 12:45:15 -04:00
TwiNandGitHub 9495b7389e feat(ui): Add support for endpoints[].ui.hide-errors to hide errors (#1361)
Supersedes #1292
2025-10-22 13:13:53 -04:00
TwiN c8bdecbde8 docs: Fix ordering in Table of Contents 2025-10-21 14:11:00 -04:00
TwiNandGitHub 394602bc47 fix(ui): Ensure retrievedConfig is set to true after fetching configuration (#1359) 2025-10-21 08:06:02 -04:00
TwiNandGitHub 15813d4297 fix(client): Add nil check for SSHConfig before validating username/password (#1358)
Fixes #1357
2025-10-21 07:37:24 -04:00
TwiNandGitHub d24c66cf96 fix(key): Revert support for ( and ) as name/group, as they already worked before (#1356)
Relevant: #1340
2025-10-20 13:50:02 -04:00
TwiNandGitHub 70d7d0c54c fix(suites): Load persisted triggered alerts for suite endpoints on start (#1347) 2025-10-20 13:31:58 -04:00
TwiN 91931e48b4 fix(ui): Clear selected result before toggling new one
relevant: #1236
2025-10-17 22:06:54 -04:00
TwiN 386a4d2cb7 fix(ui): Implement toggleable tooltip for suites too
relevant: #1236
2025-10-17 21:41:40 -04:00
TwiN 4d9eb0572c docs(alerting): Link n8n-nodes-gatus-trigger for n8n alerting provider 2025-10-17 21:12:53 -04:00
1586b3cc0b feat(alerting): Add message-content parameter for Discord pings (#1335)
* feat(discord-alerts): add option for prefix-messages outside of embeds

* chore(docs): add discord prefix-message to README

* chore(discord-alerts): rename prefix-message to message-content

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-17 20:22:26 -04:00
981e082d0c feat(ui): Make tooltips toggleable (#1236)
* feat(results): allow for data points in checks to be "clicked"

asdf

* feat(ui): resolve merge conflicts

feat(dev): put back package.lock

* fix(ui): make sure the datapoint stays "fixed"

* fix(ui): watch for url changes to make tooltip go away

* feat(ui): add compiled app.css and app.js

* fix(ui): lengthen the tooltipElement name

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-17 16:09:47 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
91daaf92aa chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.8 to 2.52.9 (#1338)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.8 to 2.52.9.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.8...v2.52.9)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-version: 2.52.9
  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-10-17 10:09:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a1bb07c556 chore(deps): bump golang.org/x/net from 0.45.0 to 0.46.0 (#1333)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/net/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.46.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-10-17 10:08:48 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
258175dec3 chore(deps): bump code.gitea.io/sdk/gitea from 0.21.0 to 0.22.0 (#1341)
Bumps code.gitea.io/sdk/gitea from 0.21.0 to 0.22.0.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.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-10-17 10:08:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
ef6159e420 chore(deps): bump github.com/prometheus/client_golang from 1.23.0 to 1.23.2 (#1337)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.0 to 1.23.2.
- [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.23.0...v1.23.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.23.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-10-16 20:45:36 -04:00
TwiNandGitHub ebd4068aac fix(key): Support (, ), + and & as name/group (#1340)
fix(key): Support (, ), + and & as name/group

Relevant: #1339
2025-10-16 16:47:11 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
39981de54b chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 (#1332)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.43.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-10-15 08:49:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7dce07e47f chore(deps): bump modernc.org/sqlite from 1.38.2 to 1.39.1 (#1331)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.38.2 to 1.39.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.38.2...v1.39.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.39.1
  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-10-15 08:23:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6a83857db4 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.14.1 to 3.16.0 (#1313)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.14.1 to 3.16.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.14.1...v3.16.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.16.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-10-14 16:42:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
50702bd1d4 chore(deps): bump google.golang.org/api from 0.242.0 to 0.252.0 (#1315)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.242.0 to 0.252.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.242.0...v0.252.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.252.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-10-14 10:19:44 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5bf95fe4f7 chore(deps): bump github.com/valyala/fasthttp from 1.64.0 to 1.67.0 (#1330)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.64.0 to 1.67.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.64.0...v1.67.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.67.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-10-14 09:56:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20d8ef966b chore(deps): bump golang.org/x/oauth2 from 0.30.0 to 0.32.0 (#1317)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.30.0 to 0.32.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.30.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.32.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-10-13 22:11:52 -04:00
michael-barabooandGitHub 8f15346fb7 fix(alerting)!: remove discontinued jetbrains space alerting provider (#1329)
remove alerting provider for discontinued jetbrains space
2025-10-13 20:47:05 -04:00
TwiNandGitHub 8920bdd301 fix(ui): Handle refresh properly on SuiteDetails.vue (#1324) 2025-10-12 14:27:43 -04:00
TwiN e37024dfc6 docs: Use working websocket example 2025-10-09 13:28:34 -04:00
TwiN ac4374b1e3 docs: Replace Docker Hub with GHCR as primary container registry 2025-10-06 14:15:14 -04:00
129fb82f71 feat(alerting): Add RESULT_CONDITIONS in custom alert to have more information (#1086)
feat(alerting): Add RESULT_CONDITIONS in custom alert to have more information on an alert while using custom alerting module

Add testing of new feature

Co-authored-by: TwiN <twin@linux.com>
2025-10-06 12:22:38 -04:00
Andrii OriekhovandGitHub 374be99b35 fix(alerting): Format link from Telegram alert description when sending message (#1200)
* allow passing Markdown link in telegram message

* update tests
2025-10-05 15:00:45 -04:00
5c78bd92fb feat(client): Support body placeholder for SSH endpoints (#1286)
* feat(ssh): Add BODY placeholder support for SSH endpoints

- Modify ExecuteSSHCommand to capture stdout output
- Update SSH endpoint handling to use needsToReadBody() mechanism
- Add comprehensive test cases for SSH BODY functionality
- Support basic body content, pattern matching, JSONPath, and functions
- Maintain backward compatibility with existing SSH endpoints

* docs: Add SSH BODY placeholder examples to README

- Add [BODY] placeholder to supported SSH placeholders list
- Add comprehensive examples showing various SSH BODY conditions
- Include pattern matching, length checks, JSONPath expressions
- Demonstrate function wrappers (len, has, any) usage

* Revert "docs: Add SSH BODY placeholder examples to README"

This reverts commit ae93e38683.

* docs: Add [BODY] placeholder to SSH supported placeholders list

* test: remove SSH BODY placeholder test cases

* Update client/client.go

* Update client/client.go

* docs: Add minimal SSH BODY example

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-03 22:52:34 -04:00
TwiNandGitHub 8853140cb2 feat(alerting): Add support for n8n alerts (#1309) 2025-10-03 16:51:26 -04:00
TwiNandGitHub 03ec18a703 fix(ui): Swap oldest/newest result time for SuiteCard.vue (#1308) 2025-10-03 13:36:09 -04:00
Kevin KuglerGitHubNerdySoftPawTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
65eaed4621 fix(incidentio): Implement deduplication key generation for alerts (#1296)
* fix(incidentio): Implement deduplication key generation for alerts

* fix(incidentio): Merge metadata from config and endpoint extra labels in request body

* fix(incidentio): Update comments for clarity and consistency in deduplication key generation and metadata merging

* fix(incidentio): Update comments for clarity and consistency in metadata merging and deduplication key generation

* fix(incidentio): Remove duplicate Metadata assignment in request body construction

* refactor(incidentio): Reformat code for consistency and readability in request body construction

* fix(incidentio): Remove unnecessary newline in buildRequestBody function

* Initial plan

* Fix incidentio tests to handle dynamic deduplication_key field

Co-authored-by: NerdySoftPaw <7468547+NerdySoftPaw@users.noreply.github.com>

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-10-03 13:33:51 -04:00
TwiNandGitHub 10c6e71eef chore(deps): Update aws-sdk-go to aws-sdk-go-v2 (#1305) 2025-10-03 13:33:37 -04:00
TwiN c7f0a32135 fix(tunneling): Adjust exponential backoff duration 2025-09-30 14:27:38 -04:00
TwiNandGitHub 405c15f756 fix(tunneling): Add exponential backoff retry (#1303) 2025-09-30 14:08:56 -04:00
TwiNandGitHub 6f1312dfcf chore: Tweak configuration validation and yaml output (#1302) 2025-09-30 13:38:17 -04:00
97 changed files with 3496 additions and 1574 deletions
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus
@@ -13,7 +12,7 @@ services:
prometheus:
container_name: prometheus
image: prom/prometheus:v2.14.0
image: prom/prometheus:v3.5.0
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
@@ -25,7 +24,7 @@ services:
grafana:
container_name: grafana
image: grafana/grafana:6.4.4
image: grafana/grafana:12.1.0
restart: always
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
@@ -16,4 +16,10 @@ endpoints:
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- "[STATUS] == 200"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
@@ -19,7 +19,7 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 566,
"id": 41,
"links": [],
"panels": [
{
@@ -39,7 +39,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "yellow",
@@ -79,7 +80,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -122,7 +123,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "yellow",
@@ -162,7 +164,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -193,7 +195,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
}
]
},
@@ -225,7 +228,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -292,7 +295,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -321,7 +324,7 @@
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"description": "Domain expiration times for all domains",
"fieldConfig": {
"defaults": {
"color": {
@@ -339,7 +342,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "#EAB839",
@@ -395,7 +399,137 @@
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"editorMode": "code",
"expr": "gatus_results_domain_expiration_seconds",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
}
],
"title": "Domain Expiration",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": false,
"__name__": true,
"app_kubernetes_io_instance": true,
"app_kubernetes_io_managed_by": true,
"app_kubernetes_io_name": true,
"app_kubernetes_io_service": true,
"helm_sh_chart": true,
"instance": true,
"job": true,
"key": true,
"type": true
},
"includeByName": {},
"indexByName": {
"Value": 2,
"group": 0,
"name": 1
},
"renameByName": {
"Value": "Time Until Expiry",
"group": "Group",
"name": "Service"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "#EAB839",
"value": 172800
},
{
"color": "green",
"value": 604800
}
]
},
"unit": "dtdurations"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Time Until Expiry"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"applyToRow": false,
"type": "color-background"
}
},
{
"id": "unit",
"value": "s"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 11,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -444,6 +578,113 @@
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
@@ -483,7 +724,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "green",
@@ -525,7 +767,7 @@
"h": 8,
"w": 12,
"x": 12,
"y": 8
"y": 16
},
"id": 2,
"options": {
@@ -546,7 +788,7 @@
}
]
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -689,7 +931,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -703,9 +946,9 @@
},
"gridPos": {
"h": 8,
"w": 12,
"w": 24,
"x": 0,
"y": 16
"y": 24
},
"id": 4,
"options": {
@@ -721,7 +964,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -735,112 +978,6 @@
],
"title": "Response Times",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
}
],
"preload": false,
@@ -855,8 +992,8 @@
"list": [
{
"current": {
"text": "myprom",
"value": "PA04845DA3A4B088E"
"text": "prometheus",
"value": "cedv077q7bbwgd"
},
"description": "Select your Prometheus datasource",
"includeAll": false,
@@ -877,6 +1014,6 @@
"timepicker": {},
"timezone": "",
"title": "Gatus - Service Monitoring Dashboard",
"uid": "gatus-monitoring2",
"version": 10
}
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
"version": 1
}
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus
@@ -1,4 +1,3 @@
version: "3.9"
services:
nginx:
image: nginx:stable
@@ -1,4 +1,3 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
@@ -1,4 +1,3 @@
version: "3.9"
services:
postgres:
image: postgres
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
image: twinproduction/gatus:latest
@@ -1,4 +1,3 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+14 -1
View File
@@ -15,4 +15,17 @@ jobs:
steps:
- uses: actions/checkout@v5
- run: make frontend-install-dependencies
- run: make frontend-build
- run: make frontend-build
- name: Check for uncommitted changes
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "Error: There are uncommitted changes after running 'make frontend-build'"
echo "Please run 'make frontend-build' locally and commit the changes"
echo ""
echo "Changed files:"
git diff --name-only
echo ""
git status
git diff
exit 1
fi
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v5.5.1
uses: codecov/codecov-action@v5.5.2
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
+1 -1
View File
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN go mod tidy
RUN go mod tidy -diff
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment
+236 -145
View File
@@ -20,13 +20,14 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
<details>
<summary><b>Quick start</b></summary>
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
You can also use GitHub Container Registry if you prefer:
```console
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
You can also use Docker Hub if you prefer:
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
For more details, see [Usage](#usage)
</details>
@@ -49,6 +50,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Conditions](#conditions)
- [Placeholders](#placeholders)
- [Functions](#functions)
- [Announcements](#announcements)
- [Storage](#storage)
- [Client configuration](#client-configuration)
- [Tunneling](#tunneling)
@@ -66,11 +68,11 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring IFTTT alerts](#configuring-ifttt-alerts)
- [Configuring Ilert alerts](#configuring-ilert-alerts)
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
- [Configuring Line alerts](#configuring-line-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring n8n alerts](#configuring-n8n-alerts)
- [Configuring New Relic alerts](#configuring-new-relic-alerts)
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
@@ -94,7 +96,6 @@ 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)
@@ -108,6 +109,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Docker](#docker)
- [Helm Chart](#helm-chart)
- [Terraform](#terraform)
- [Kubernetes](#kubernetes)
- [Running the tests](#running-the-tests)
- [Using in Production](#using-in-production)
- [FAQ](#faq)
@@ -118,6 +120,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)
- [Monitoring an endpoint using gRPC](#monitoring-an-endpoint-using-grpc)
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
@@ -185,18 +188,15 @@ The main features of Gatus are:
## Usage
<details>
<summary><b>Quick start</b></summary>
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
You can also use GitHub Container Registry if you prefer:
You can also use Docker Hub if you prefer:
```console
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
If you want to create your own configuration, see [Docker](#docker) for information on how to mount a configuration file.
</details>
Here's a simple example:
```yaml
@@ -263,6 +263,8 @@ If you want to test it locally, see [Docker](#docker).
| `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.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` |
| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` |
| `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. | `""` |
@@ -311,6 +313,7 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.hide-errors` | Whether to hide errors from the results. | `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. | `{}` |
@@ -368,17 +371,17 @@ or send an HTTP request:
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 `-`.
- `{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}` (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).
- `{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, this will be ignored.
- `{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.
### Suites (ALPHA)
Suites are collections of endpoints that are executed sequentially with a shared context.
Suites are collections of endpoints that are executed sequentially with a shared context.
This allows you to create complex monitoring scenarios where the result from one endpoint can be used in subsequent endpoints, enabling workflow-style monitoring.
Here are a few cases in which suites could be useful:
@@ -420,7 +423,7 @@ suites:
context:
price: "19.99" # Initial static value in context
endpoints:
# Step 1: Create an item and store the item ID
# Step 1: Create an item and store the item ID
- name: create-item
url: https://api.example.com/items
method: POST
@@ -434,7 +437,7 @@ suites:
alerts:
- type: slack
description: "Failed to create item"
# Step 2: Update the item using the stored item ID
- name: update-item
url: https://api.example.com/items/[CONTEXT].itemId
@@ -445,7 +448,7 @@ suites:
alerts:
- type: slack
description: "Failed to update item"
# Step 3: Fetch the item and validate the price
- name: get-item
url: https://api.example.com/items/[CONTEXT].itemId
@@ -456,7 +459,7 @@ suites:
alerts:
- type: slack
description: "Item price did not update correctly"
# Step 4: Delete the item (always-run: true to ensure cleanup even if step 2 or 3 fails)
- name: delete-item
url: https://api.example.com/items/[CONTEXT].itemId
@@ -524,18 +527,21 @@ Here are some examples of conditions you can use:
### 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.
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. You can use markdown to format your announcements.
| 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 |
This is essentially what some status page calls "incident communications".
| 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 |
| `announcements[].archived` | Whether to archive the announcement. Archived announcements show at the bottom of the status page instead of at the top. | `false` |
Types:
- **outage**: Indicates service disruptions or critical issues (red theme)
- **warning**: Indicates potential issues or important notices (yellow 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)
@@ -543,17 +549,24 @@ Types:
Example Configuration:
```yaml
announcements:
- timestamp: 2025-08-15T14:00:00Z
- timestamp: 2025-11-07T14: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
- timestamp: 2025-11-07T16:15:00Z
type: operational
message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-08-15T12:00:00Z
- timestamp: 2025-11-07T12:00:00Z
type: information
message: "New monitoring dashboard features will be deployed next week"
- timestamp: 2025-11-06T09:00:00Z
type: warning
message: "Elevated API response times observed for US customers"
archived: true
```
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:
![Gatus past announcements section](.github/assets/past-announcements.jpg)
### Storage
| Parameter | Description | Default |
@@ -708,7 +721,7 @@ endpoints:
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
### Tunneling
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
This is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.
SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
@@ -745,6 +758,9 @@ endpoints:
- "[STATUS] == 200"
```
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
> This may lead to inaccurate response time measurements.
### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
@@ -809,11 +825,11 @@ endpoints:
| `alerting.ifttt` | Configuration for alerts of type `ifttt`. <br />See [Configuring IFTTT alerts](#configuring-ifttt-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.line` | Configuration for alerts of type `line`. <br />See [Configuring Line alerts](#configuring-line-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.n8n` | Configuration for alerts of type `n8n`. <br />See [Configuring n8n alerts](#configuring-n8n-alerts). | `{}` |
| `alerting.newrelic` | Configuration for alerts of type `newrelic`. <br />See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
@@ -919,6 +935,7 @@ endpoints:
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.discord.message-content` | Message content to send before the embed (useful for pinging users/roles, e.g. `<@123>`) | `""` |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
@@ -1298,7 +1315,7 @@ endpoints:
```
#### Configuring ilert alerts
#### Configuring Ilert alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
@@ -1380,42 +1397,6 @@ In order to get the required alert source config id and authentication token, yo
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
#### Configuring JetBrains Space alerts
| Parameter | Description | Default |
|:--------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.jetbrainsspace.overrides[].*` | See `alerting.jetbrainsspace.*` parameters | `{}` |
```yaml
alerting:
jetbrainsspace:
project: myproject
channel-id: ABCDE12345
token: "**************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: jetbrainsspace
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:
![JetBrains Space notifications](.github/assets/jetbrains-space-alerts.png)
#### Configuring Line alerts
| Parameter | Description | Default |
@@ -1576,8 +1557,8 @@ alerting:
region: "US" # or "EU" for European region
endpoints:
- name: website
url: "https://twin.sh/health"
- name: example
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
@@ -1587,6 +1568,52 @@ endpoints:
```
#### Configuring n8n alerts
| Parameter | Description | Default |
|:---------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.n8n` | Configuration for alerts of type `n8n` | `{}` |
| `alerting.n8n.webhook-url` | n8n webhook URL | Required `""` |
| `alerting.n8n.title` | Title of the alert sent to n8n | `""` |
| `alerting.n8n.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.n8n.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.n8n.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.n8n.overrides[].*` | See `alerting.n8n.*` parameters | `{}` |
[n8n](https://n8n.io/) is a workflow automation platform that allows you to automate tasks across different applications and services using webhooks.
See [n8n-nodes-gatus-trigger](https://github.com/TwiN/n8n-nodes-gatus-trigger) for a n8n community node that can be used as trigger.
Example:
```yaml
alerting:
n8n:
webhook-url: "https://your-n8n-instance.com/webhook/your-webhook-id"
title: "Gatus Monitoring"
default-alert:
send-on-resolved: true
endpoints:
- name: example
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: n8n
description: "Health check alert"
```
The JSON payload sent to the n8n webhook will include:
- `title`: The configured title
- `endpoint_name`: Name of the endpoint
- `endpoint_group`: Group of the endpoint (if any)
- `endpoint_url`: URL being monitored
- `alert_description`: Custom alert description
- `resolved`: Boolean indicating if the alert is resolved
- `message`: Human-readable alert message
- `condition_results`: Array of condition results with their success status
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
@@ -1840,6 +1867,40 @@ endpoints:
```
#### Configuring SendGrid alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
```yaml
alerting:
sendgrid:
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
from: "alerts@example.com"
to: "admin@example.com,ops@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: sendgrid
send-on-resolved: true
```
#### Configuring Signal alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
@@ -1906,40 +1967,6 @@ endpoints:
```
#### Configuring SendGrid alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
```yaml
alerting:
sendgrid:
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
from: "alerts@example.com"
to: "admin@example.com,ops@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: sendgrid
send-on-resolved: true
```
#### Configuring Slack alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
@@ -2167,7 +2194,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.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 |
@@ -2286,14 +2313,14 @@ endpoints:
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
| `alerting.webex.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.webex.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
| `alerting.webex.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.webex.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
```yaml
alerting:
@@ -2345,7 +2372,7 @@ endpoints:
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
| `alerting.zulip` | Configuration for alerts of type `zulip` | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
@@ -2401,7 +2428,8 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)
- `[RESULT_CONDITIONS]` (condition results from the health evaluation of a given health check)
-
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
@@ -2662,6 +2690,7 @@ endpoint on the same port your application is configured to run on (`web.port`).
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_domain_expiration_seconds | gauge | Number of seconds until the domains expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
@@ -2739,24 +2768,24 @@ Many examples can be found in the [.examples](.examples) folder, but this sectio
### Docker
To run Gatus locally with Docker:
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
Other than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
command:
```console
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable
```
If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.:
```console
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable
```
To build the image locally:
```console
docker build . -t twinproduction/gatus
docker build . -t ghcr.io/twin/gatus:stable
```
@@ -2776,8 +2805,10 @@ To get more details, please check [chart's configuration](https://github.com/Twi
### Terraform
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
#### Kubernetes
Gatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
## Running the tests
```console
@@ -2821,7 +2852,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
### Recommended interval
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
endpoints/suites that can be evaluated at the same time.
In other words, even if you have multiple endpoints with the same interval, they are not guaranteed to run at the same time.
@@ -2917,20 +2948,59 @@ This works for SCTP based application.
### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:
```yaml
endpoints:
- name: example
url: "wss://example.com/"
url: "wss://echo.websocket.org/"
body: "status"
conditions:
- "[CONNECTED] == true"
- "[BODY].result >= 0"
- "[BODY] == pat(*served by*)"
```
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
shows whether the connection was successfully established. You can use Go template
syntax. The functions LocalAddr and RandomString with a length can be used.
shows whether the connection was successfully established. You can use Go template
syntax.
### Monitoring an endpoint using gRPC
You can monitor gRPC services by prefixing `endpoints[].url` with `grpc://` or `grpcs://`.
Gatus executes the standard `grpc.health.v1.Health/Check` RPC against the target.
```yaml
endpoints:
- name: my-grpc
url: grpc://localhost:50051
interval: 30s
conditions:
- "[CONNECTED] == true"
- "[BODY].status == SERVING" # BODY is read only when referenced
client:
timeout: 5s
```
For TLS-enabled servers, use `grpcs://` and configure client TLS if necessary:
```yaml
endpoints:
- name: my-grpcs
url: grpcs://example.com:443
conditions:
- "[CONNECTED] == true"
- "[BODY].status == SERVING"
client:
timeout: 5s
insecure: false # set true to skip cert verification (not recommended)
tls:
certificate-file: /path/to/cert.pem # optional mTLS client cert
private-key-file: /path/to/key.pem # optional mTLS client key
```
Notes:
- The health check targets the default service (`service: ""`). Support for a custom service name can be added later if needed.
- The response body is exposed as a minimal JSON object like `{"status":"SERVING"}` only when required by conditions or suite store mappings.
- Timeouts, custom DNS resolvers and SSH tunnels are honored via the existing [`client` configuration](#client-configuration).
### Monitoring an endpoint using ICMP
@@ -2947,9 +3017,12 @@ endpoints:
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.
If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux
If you run Gatus on Linux, please read the Linux section on [https://github.com/prometheus-community/pro-bing#linux]
if you encounter any problems.
Prior to `v5.31.0`, some environment setups required adding `CAP_NET_RAW` capabilities to allow pings to work.
As of `v5.31.0`, this is no longer necessary, and ICMP checks will work with unprivileged pings unless running as root. See #1346 for details.
### Monitoring an endpoint using DNS queries
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
@@ -2975,23 +3048,39 @@ There are two placeholders that can be used in the conditions for endpoints of t
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
```yaml
endpoints:
- name: ssh-example
# Password-based SSH example
- name: ssh-example-password
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: "username"
password: "password"
body: |
{
"command": "uptime"
"command": "echo '{\"memory\": {\"used\": 512}}'"
}
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY].memory.used > 500"
# Key-based SSH example
- name: ssh-example-key
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: "username"
private-key: |
-----BEGIN RSA PRIVATE KEY-----
TESTRSAKEY...
-----END RSA PRIVATE KEY-----
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
```
you can also use no authentication to monitor the endpoint by not specifying the username
and password fields.
you can also use no authentication to monitor the endpoint by not specifying the username,
password and private key fields.
```yaml
endpoints:
@@ -3000,6 +3089,7 @@ endpoints:
ssh:
username: ""
password: ""
private-key: ""
interval: 1m
conditions:
@@ -3010,6 +3100,7 @@ endpoints:
The following placeholders are supported for endpoints of type SSH:
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
- `[BODY]` resolves to the stdout output of the command executed on the remote server
- `[IP]` resolves to the IP address of the server
- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command
@@ -3063,7 +3154,7 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 240h"
```
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
> [through a library](https://github.com/TwiN/whois) and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
> To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
> using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
@@ -3092,7 +3183,7 @@ concurrency: 0
**Use cases for higher concurrency:**
- You have a large number of endpoints to monitor
- You want to monitor endpoints at very short intervals (< 5s)
- You want to monitor endpoints at very short intervals (< 5s)
- You're using Gatus for load testing scenarios
**Legacy configuration:**
@@ -3176,7 +3267,7 @@ ui:
default-sort-by: group
```
Note that if a user has already sorted the dashboard by a different field, the default sort will not be applied unless the user
clears their browser's localstorage.
clears their browser's localstorage.
### Exposing Gatus on a custom path
@@ -3282,7 +3373,7 @@ The path to generate a badge is the following:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
the URL would look like this:
@@ -3308,7 +3399,7 @@ The path to generate a badge is the following:
/api/v1/endpoints/{key}/health/badge.svg
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
@@ -3325,7 +3416,7 @@ The path to generate a badge is the following:
/api/v1/endpoints/{key}/health/badge.shields
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
@@ -3348,7 +3439,7 @@ The endpoint to generate a badge is the following:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
#### Response time (chart)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/chart.svg)
@@ -3361,7 +3452,7 @@ The endpoint to generate a response time chart is the following:
```
Where:
- `{duration}` is `30d`, `7d`, or `24h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
##### How to change the color thresholds of the response time badge
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
@@ -3419,7 +3510,7 @@ The path to get raw uptime data for an endpoint is:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
```
@@ -3433,7 +3524,7 @@ The path to get raw response time data for an endpoint is:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the raw response time data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
```
+3 -3
View File
@@ -47,9 +47,6 @@ const (
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace"
// TypeLine is the Type for the line alerting provider
TypeLine Type = "line"
@@ -65,6 +62,9 @@ const (
// TypeNewRelic is the Type for the newrelic alerting provider
TypeNewRelic Type = "newrelic"
// TypeN8N is the Type for the n8n alerting provider
TypeN8N Type = "n8n"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
+6 -7
View File
@@ -20,11 +20,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"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/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
@@ -66,7 +66,6 @@ type Config struct {
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
@@ -81,22 +80,19 @@ 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"`
// IFTTT is the configuration for the ifttt alerting provider
IFTTT *ifttt.AlertProvider `yaml:"ifttt,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"`
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
// Line is the configuration for the line alerting provider
Line *line.AlertProvider `yaml:"line,omitempty"`
@@ -112,6 +108,9 @@ type Config struct {
// NewRelic is the configuration for the newrelic alerting provider
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
// N8N is the configuration for the n8n alerting provider
N8N *n8n.AlertProvider `yaml:"n8n,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
+25 -38
View File
@@ -1,18 +1,18 @@
package awsses
import (
"context"
"errors"
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ses"
"github.com/aws/aws-sdk-go-v2/service/ses/types"
"gopkg.in/yaml.v3"
)
@@ -102,63 +102,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return err
}
awsSession, err := provider.createSession(cfg)
ctx := context.Background()
svc, err := provider.createClient(ctx, cfg)
if err != nil {
return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: aws.StringSlice(emails),
Destination: &types.Destination{
ToAddresses: emails,
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Message: &types.Message{
Body: &types.Body{
Text: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(body),
},
},
Subject: &ses.Content{
Subject: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(subject),
},
},
Source: aws.String(cfg.From),
}
if _, err = svc.SendEmail(input); err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default:
logr.Error(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
logr.Error(err.Error())
}
if _, err = svc.SendEmail(ctx, input); err != nil {
return err
}
return nil
}
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
awsConfig := &aws.Config{
Region: aws.String(cfg.Region),
func (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {
var opts []func(*config.LoadOptions) error
if len(cfg.Region) > 0 {
opts = append(opts, config.WithRegion(cfg.Region))
}
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")))
}
return session.NewSession(awsConfig)
awsConfig, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, err
}
return ses.NewFromConfig(awsConfig), nil
}
// buildMessageSubjectAndBody builds the message subject and body
+19
View File
@@ -111,6 +111,25 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
if len(result.ConditionResults) > 0 && strings.Contains(body, "[RESULT_CONDITIONS]") {
var formattedConditionResults string
for index, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`", prefix, conditionResult.Condition)
if index < len(result.ConditionResults)-1 {
formattedConditionResults += ", "
}
}
body = strings.ReplaceAll(body, "[RESULT_CONDITIONS]", formattedConditionResults)
url = strings.ReplaceAll(url, "[RESULT_CONDITIONS]", formattedConditionResults)
}
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
+63
View File
@@ -261,6 +261,69 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholderAndResultConditions(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_CONDITIONS]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "fixed",
"TRIGGERED": "boom",
},
},
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
NoConditions bool
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed,✅ - `[CONNECTED] == true`, ✅ - `[STATUS] == 200`",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom,❌ - `[CONNECTED] == true`, ❌ - `[STATUS] == 200`",
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
+7 -3
View File
@@ -20,8 +20,9 @@ var (
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
MessageContent string `yaml:"message-content,omitempty"` // Message content for pinging users or groups (e.g. "<@123456789>" or "<@&987654321>")
}
func (cfg *Config) Validate() error {
@@ -38,6 +39,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if len(override.MessageContent) > 0 {
cfg.MessageContent = override.MessageContent
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
@@ -142,7 +146,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
title = cfg.Title
}
body := Body{
Content: "",
Content: cfg.MessageContent,
Embeds: []Embed{
{
Title: title,
+67
View File
@@ -134,6 +134,16 @@ func TestAlertProvider_Send(t *testing.T) {
}),
ExpectedError: false,
},
{
Name: "triggered-with-message-content",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"}},
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -200,6 +210,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
},
{
Name: "triggered-with-message-content-user-mention",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "triggered-with-message-content-role-mention",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@&987654321>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\\u003c@\\u0026987654321\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "resolved-with-message-content",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -313,6 +344,39 @@ func TestAlertProvider_GetConfig(t *testing.T) {
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
{
Name: "provider-with-message-content-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
{
Name: "provider-with-message-content-group-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
},
{
Name: "provider-with-message-content-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"message-content": "<@999999999>"}},
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@999999999>"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -323,6 +387,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.MessageContent != scenario.ExpectedOutput.MessageContent {
t.Errorf("expected message content to be %s, got %s", scenario.ExpectedOutput.MessageContent, got.MessageContent)
}
// 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)
+18
View File
@@ -0,0 +1,18 @@
package incidentio
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// generateDeduplicationKey generates a unique deduplication_key for incident.io
func generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string {
data := fmt.Sprintf("%s|%s|%s|%d", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano())
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
+22 -5
View File
@@ -153,27 +153,44 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
} else {
prefix = "🔴"
}
// No need for \n since incident.io trims it anyways.
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
var body []byte
// Generate deduplication key if empty (first firing)
if alert.ResolveKey == "" {
// Generate unique key (endpoint key, alert type, timestamp)
alert.ResolveKey = generateDeduplicationKey(ep, alert)
}
// Extract alert_source_config_id from URL
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
body, _ = json.Marshal(Body{
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
mergedMetadata := map[string]interface{}{}
// Copy cfg.Metadata
for k, v := range cfg.Metadata {
mergedMetadata[k] = v
}
// Add extra labels from endpoint (if present)
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
for k, v := range ep.ExtraLabels {
mergedMetadata[k] = v
}
}
body, _ := json.Marshal(Body{
AlertSourceConfigID: alertSourceID,
Title: "Gatus: " + ep.DisplayName(),
Status: status,
DeduplicationKey: alert.ResolveKey,
Description: message,
SourceURL: cfg.SourceURL,
Metadata: cfg.Metadata,
Metadata: mergedMetadata,
})
fmt.Printf("%v", string(body))
return body
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
+83 -30
View File
@@ -183,39 +183,63 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedAlertSourceID string
ExpectedStatus string
ExpectedTitle string
ExpectedDescription string
ExpectedSourceURL string
ExpectedMetadata map[string]interface{}
ShouldHaveDeduplicationKey bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "firing",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "resolved",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
{
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "resolved",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
ExpectedSourceURL: "some-source-url",
ExpectedMetadata: map[string]interface{}{"service": "some-service", "team": "very-core"},
ShouldHaveDeduplicationKey: true,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedAlertSourceID: "different-id",
ExpectedStatus: "firing",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
}
@@ -237,13 +261,42 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
},
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 {
// Parse the JSON body
var parsedBody Body
if err := json.Unmarshal(body, &parsedBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
// Validate individual fields
if parsedBody.AlertSourceConfigID != scenario.ExpectedAlertSourceID {
t.Errorf("expected alert_source_config_id to be %s, got %s", scenario.ExpectedAlertSourceID, parsedBody.AlertSourceConfigID)
}
if parsedBody.Status != scenario.ExpectedStatus {
t.Errorf("expected status to be %s, got %s", scenario.ExpectedStatus, parsedBody.Status)
}
if parsedBody.Title != scenario.ExpectedTitle {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedTitle, parsedBody.Title)
}
if parsedBody.Description != scenario.ExpectedDescription {
t.Errorf("expected description to be %s, got %s", scenario.ExpectedDescription, parsedBody.Description)
}
if scenario.ExpectedSourceURL != "" && parsedBody.SourceURL != scenario.ExpectedSourceURL {
t.Errorf("expected source_url to be %s, got %s", scenario.ExpectedSourceURL, parsedBody.SourceURL)
}
if scenario.ExpectedMetadata != nil {
metadataJSON, _ := json.Marshal(parsedBody.Metadata)
expectedMetadataJSON, _ := json.Marshal(scenario.ExpectedMetadata)
if string(metadataJSON) != string(expectedMetadataJSON) {
t.Errorf("expected metadata to be %s, got %s", string(expectedMetadataJSON), string(metadataJSON))
}
}
// Validate that deduplication_key exists and is not empty
if scenario.ShouldHaveDeduplicationKey {
if parsedBody.DeduplicationKey == "" {
t.Error("expected deduplication_key to be present and non-empty")
}
}
})
}
}
@@ -1,4 +1,4 @@
package jetbrainsspace
package n8n
import (
"bytes"
@@ -15,50 +15,36 @@ import (
)
var (
ErrProjectNotSet = errors.New("project not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Project string `yaml:"project"` // Project name
ChannelID string `yaml:"channel-id"` // Chat Channel ID
Token string `yaml:"token"` // Bearer Token
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if len(cfg.Project) == 0 {
return ErrProjectNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Project) > 0 {
cfg.Project = override.Project
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
// AlertProvider is the configuration necessary for sending an alert using n8n
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"`
}
@@ -90,13 +76,11 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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
@@ -110,78 +94,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
type Body struct {
Channel string `json:"channel"`
Content Content `json:"content"`
Title string `json:"title"`
EndpointName string `json:"endpoint_name"`
EndpointGroup string `json:"endpoint_group,omitempty"`
EndpointURL string `json:"endpoint_url"`
AlertDescription string `json:"alert_description,omitempty"`
Resolved bool `json:"resolved"`
Message string `json:"message"`
ConditionResults []ConditionResult `json:"condition_results,omitempty"`
}
type Content struct {
ClassName string `json:"className"`
Style string `json:"style"`
Sections []Section `json:"sections,omitempty"`
}
type Section struct {
ClassName string `json:"className"`
Elements []Element `json:"elements"`
Header string `json:"header"`
}
type Element struct {
ClassName string `json:"className"`
Accessory Accessory `json:"accessory"`
Style string `json:"style"`
Size string `json:"size"`
Content string `json:"content"`
}
type Accessory struct {
ClassName string `json:"className"`
Icon Icon `json:"icon"`
Style string `json:"style"`
}
type Icon struct {
Icon string `json:"icon"`
type ConditionResult struct {
Condition string `json:"condition"`
Success bool `json:"success"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
Channel: "id:" + cfg.ChannelID,
Content: Content{
ClassName: "ChatMessage.Block",
Sections: []Section{{
ClassName: "MessageSection",
Elements: []Element{},
}},
},
}
var message string
if resolved {
body.Content.Style = "SUCCESS"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
body.Content.Style = "WARNING"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
title := "Gatus"
if cfg.Title != "" {
title = cfg.Title
}
var conditionResults []ConditionResult
for _, conditionResult := range result.ConditionResults {
icon := "warning"
style := "WARNING"
if conditionResult.Success {
icon = "success"
style = "SUCCESS"
}
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
ClassName: "MessageText",
Accessory: Accessory{
ClassName: "MessageIcon",
Icon: Icon{Icon: icon},
Style: style,
},
Style: style,
Size: "REGULAR",
Content: conditionResult.Condition,
conditionResults = append(conditionResults, ConditionResult{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
body := Body{
Title: title,
EndpointName: ep.Name,
EndpointGroup: ep.Group,
EndpointURL: ep.URL,
AlertDescription: alert.GetDescription(),
Resolved: resolved,
Message: message,
ConditionResults: conditionResults,
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
@@ -1,4 +1,4 @@
package jetbrainsspace
package n8n
import (
"encoding/json"
@@ -12,11 +12,11 @@ import (
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
@@ -24,10 +24,9 @@ func TestAlertProvider_Validate(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
Config: Config{ChannelID: "http://example.com"},
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
@@ -36,26 +35,21 @@ func TestAlertProvider_ValidateWithOverride(t *testing.T) {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
Config: Config{ChannelID: ""},
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
t.Error("provider webhook URL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
Project: "foo",
ChannelID: "bar",
Token: "baz",
},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{ChannelID: "foobar"},
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
@@ -79,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -89,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -99,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -109,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -151,45 +145,94 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
ExpectedBody string
ExpectedBody Body
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-1",
Resolved: false,
Message: "An alert for name has been triggered due to having failed 3 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: false},
{Condition: "[STATUS] == 200", Success: false},
},
},
},
{
Name: "triggered-with-group",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
Name: "triggered-with-group",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group", URL: "https://example.org"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointGroup: "group",
EndpointURL: "https://example.org",
AlertDescription: "description-1",
Resolved: false,
Message: "An alert for group/name has been triggered due to having failed 3 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: false},
{Condition: "[STATUS] == 200", Success: false},
},
},
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-2",
Resolved: true,
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: true},
},
},
},
{
Name: "resolved-with-group",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
Name: "resolved-with-custom-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "Custom Title"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: Body{
Title: "Custom Title",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-2",
Resolved: true,
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: true},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
if err != nil {
t.Fatal("couldn't get config:", err.Error())
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -200,13 +243,22 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
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 {
var actualBody Body
if err := json.Unmarshal(body, &actualBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if actualBody.Title != scenario.ExpectedBody.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedBody.Title, actualBody.Title)
}
if actualBody.EndpointName != scenario.ExpectedBody.EndpointName {
t.Errorf("expected endpoint name to be %s, got %s", scenario.ExpectedBody.EndpointName, actualBody.EndpointName)
}
if actualBody.Resolved != scenario.ExpectedBody.Resolved {
t.Errorf("expected resolved to be %v, got %v", scenario.ExpectedBody.Resolved, actualBody.Resolved)
}
if actualBody.Message != scenario.ExpectedBody.Message {
t.Errorf("expected message to be %s, got %s", scenario.ExpectedBody.Message, actualBody.Message)
}
})
}
}
@@ -231,67 +283,67 @@ func TestAlertProvider_GetConfig(t *testing.T) {
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
@@ -300,14 +352,8 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Project != scenario.ExpectedOutput.Project {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+3 -3
View File
@@ -16,11 +16,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"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/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
@@ -105,11 +105,11 @@ var (
_ AlertProvider = (*ifttt.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*line.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*n8n.AlertProvider)(nil)
_ AlertProvider = (*newrelic.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
@@ -146,11 +146,11 @@ var (
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[line.Config] = (*line.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[n8n.Config] = (*n8n.Config)(nil)
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
+1 -1
View File
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
}
var text string
if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n%s \n%s", message, alert.GetDescription(), formattedConditionResults)
} else {
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
}
+12 -4
View File
@@ -124,6 +124,7 @@ func TestAlertProvider_Send(t *testing.T) {
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
descriptionWithLink := "[link](https://example.org/)"
scenarios := []struct {
Name string
Provider AlertProvider
@@ -137,14 +138,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
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\"}",
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* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
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* \\ndescription-2 \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved-with-no-conditions",
@@ -152,14 +153,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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\"}",
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* \\ndescription-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\"}",
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* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
},
{
Name: "triggered with link in description",
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &descriptionWithLink, 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[link](https://example.org/) \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
}
for _, scenario := range scenarios {
+1
View File
@@ -84,6 +84,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/history", ResponseTimeHistory)
// This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA
+60
View File
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
}
return nil
}
func ResponseTimeHistory(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
}
endpointKey, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
if len(hourlyAverageResponseTime) == 0 {
return c.Status(200).JSON(map[string]interface{}{
"timestamps": []int64{},
"values": []int{},
})
}
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
}
sort.Ints(hourlyTimestamps)
timestamps := make([]int64, 0, len(hourlyTimestamps))
values := make([]int, 0, len(hourlyTimestamps))
for _, hourlyTimestamp := range hourlyTimestamps {
timestamp := int64(hourlyTimestamp)
averageResponseTime := hourlyAverageResponseTime[timestamp]
timestamps = append(timestamps, timestamp*1000)
values = append(values, averageResponseTime)
}
return c.Status(http.StatusOK).JSON(map[string]interface{}{
"timestamps": timestamps,
"values": values,
})
}
+66
View File
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
})
}
}
func TestResponseTimeHistory(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "history-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "history-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
response, err := router.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}
}
+10 -1
View File
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return c.Status(500).SendString(err.Error())
}
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[api.CreateExternalEndpointResult] Under endpoint maintenance window")
inEndpointMaintenanceWindow = true
}
}
// Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() {
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} else {
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
+9 -2
View File
@@ -3,6 +3,7 @@ package api
import (
"errors"
"fmt"
"net/url"
"time"
"github.com/TwiN/gatus/v5/storage/store"
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
+51 -19
View File
@@ -1,6 +1,7 @@
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
@@ -12,6 +13,7 @@ import (
"net"
"net/http"
"net/smtp"
"os"
"runtime"
"strings"
"time"
@@ -246,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
// using the SSH protocol.
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
@@ -258,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
} else {
port = "22"
}
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
// Build auth methods: prefer parsed private key if provided, fall back to password.
var authMethods []ssh.AuthMethod
if len(privateKey) > 0 {
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
} else {
return false, nil, fmt.Errorf("invalid private key: %w", err)
}
}
if len(password) > 0 {
authMethods = append(authMethods, ssh.Password(password))
}
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
Timeout: config.Timeout,
Auth: authMethods,
Timeout: config.Timeout,
})
if err != nil {
return false, nil, err
@@ -301,7 +315,7 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
}
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {
type Body struct {
Command string `json:"command"`
}
@@ -309,26 +323,30 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
return false, 0, nil, err
}
sess, err := sshClient.NewSession()
if err != nil {
return false, 0, err
return false, 0, nil, err
}
// Capture stdout
var stdout bytes.Buffer
sess.Stdout = &stdout
err = sess.Start(b.Command)
if err != nil {
return false, 0, err
return false, 0, nil, err
}
defer sess.Close()
err = sess.Wait()
output := stdout.Bytes()
if err == nil {
return true, 0, nil
return true, 0, output, nil
}
var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok {
return false, 0, err
return false, 0, nil, err
}
return true, exitErr.ExitStatus(), nil
return true, exitErr.ExitStatus(), output, nil
}
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
@@ -338,12 +356,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
pinger := ping.New(address)
pinger.Count = 1
pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every GOOS except darwin
// See https://github.com/TwiN/gatus/issues/132
//
// Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/prometheus-community/pro-bing#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
pinger.SetNetwork(config.Network)
err := pinger.Run()
if err != nil {
@@ -359,6 +372,25 @@ func Ping(address string, config *Config) (bool, time.Duration) {
return true, 0
}
// ShouldRunPingerAsPrivileged will determine whether or not to run pinger in privileged mode.
// It should be set to privileged when running as root, and always on windows. See https://pkg.go.dev/github.com/macrat/go-parallel-pinger#Pinger.SetPrivileged
func ShouldRunPingerAsPrivileged() bool {
// Set the pinger's privileged mode to false for darwin
// See https://github.com/TwiN/gatus/issues/132
// linux should also be set to false, but there are potential complications
// See https://github.com/TwiN/gatus/pull/748 and https://github.com/TwiN/gatus/issues/697#issuecomment-2081700989
//
// Note that for this to work on Linux, Gatus must run with sudo privileges. (in certain cases)
// See https://github.com/prometheus-community/pro-bing#linux
if runtime.GOOS == "windows" {
return true
}
// To actually check for cap_net_raw capabilities, we would need to add "kernel.org/pub/linux/libs/security/libcap/cap" to gatus.
// Or use a syscall and check for permission errors, but this requires platform specific compilation
// As a backstop we can simply check the effective user id and run as privileged when running as root
return os.Geteuid() == 0
}
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const (
+38 -16
View File
@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/netip"
"os"
"runtime"
"testing"
"time"
@@ -129,10 +131,37 @@ func TestPing(t *testing.T) {
}
}
func TestShouldRunPingerAsPrivileged(t *testing.T) {
// Don't run in parallel since we're testing system-dependent behavior
if runtime.GOOS == "windows" {
result := ShouldRunPingerAsPrivileged()
if !result {
t.Error("On Windows, ShouldRunPingerAsPrivileged() should return true")
}
return
}
// Non-Windows tests
result := ShouldRunPingerAsPrivileged()
isRoot := os.Geteuid() == 0
// Test cases based on current environment
if isRoot {
if !result {
t.Error("When running as root, ShouldRunPingerAsPrivileged() should return true")
}
} else {
// When not root, the result depends on raw socket creation
// We can at least verify the function runs without panic
t.Logf("Non-root privileged result: %v", result)
}
}
func TestCanPerformStartTLS(t *testing.T) {
type args struct {
address string
insecure bool
address string
insecure bool
dnsresolver string
}
tests := []struct {
@@ -168,7 +197,7 @@ func TestCanPerformStartTLS(t *testing.T) {
{
name: "dns resolver",
args: args{
address: "smtp.gmail.com:587",
address: "smtp.gmail.com:587",
dnsresolver: "tcp://1.1.1.1:53",
},
wantConnected: true,
@@ -340,7 +369,7 @@ func TestQueryWebSocket(t *testing.T) {
}
func TestTlsRenegotiation(t *testing.T) {
tests := []struct {
scenarios := []struct {
name string
cfg TLSConfig
expectedConfig tls.RenegotiationSupport
@@ -371,12 +400,12 @@ func TestTlsRenegotiation(t *testing.T) {
expectedConfig: tls.RenegotiateNever,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
tls := &tls.Config{}
tlsConfig := configureTLS(tls, test.cfg)
if tlsConfig.Renegotiation != test.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
tlsConfig := configureTLS(tls, scenario.cfg)
if tlsConfig.Renegotiation != scenario.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", scenario.expectedConfig, tls.Renegotiation)
}
})
}
@@ -513,14 +542,11 @@ func TestQueryDNS(t *testing.T) {
func TestCheckSSHBanner(t *testing.T) {
cfg := &Config{Timeout: 3}
t.Run("no-auth-ssh", func(t *testing.T) {
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
if err != nil {
t.Errorf("Expected: error != nil, got: %v ", err)
}
if connected == false {
t.Errorf("Expected: connected == true, got: %v", connected)
}
@@ -528,14 +554,11 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 0, got: %v", status)
}
})
t.Run("invalid-address", func(t *testing.T) {
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
if err == nil {
t.Errorf("Expected: error, got: %v ", err)
}
if connected != false {
t.Errorf("Expected: connected == false, got: %v", connected)
}
@@ -543,5 +566,4 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 1, got: %v", status)
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package client
import (
"context"
"crypto/tls"
"net"
"time"
"github.com/TwiN/logr"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
health "google.golang.org/grpc/health/grpc_health_v1"
)
// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.
// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.
func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {
if cfg == nil {
cfg = GetDefaultConfig()
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
var opts []grpc.DialOption
// Transport credentials
if useTLS {
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}
if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {
tlsCfg = configureTLS(tlsCfg, *cfg.TLS)
}
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
// Custom dialer for DNS resolver or SSH tunnel
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
if cfg.ResolvedTunnel != nil {
return cfg.ResolvedTunnel.Dial("tcp", addr)
}
if cfg.HasCustomDNSResolver() {
resolverCfg, err := cfg.parseDNSResolver()
if err != nil {
// Shouldn't happen because already validated; log and fall back
logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err)
} else {
d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port)
}}}
return d.DialContext(ctx, "tcp", addr)
}
}
var d net.Dialer
return d.DialContext(ctx, "tcp", addr)
}))
start := time.Now()
conn, err := grpc.DialContext(ctx, address, opts...)
if err != nil {
return false, "", err, time.Since(start)
}
defer conn.Close()
client := health.NewHealthClient(conn)
resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""})
if err != nil {
return false, "", err, time.Since(start)
}
return true, resp.GetStatus().String(), nil, time.Since(start)
}
+4
View File
@@ -53,6 +53,10 @@ type Announcement struct {
// Message is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"`
// Archived indicates whether the announcement should be displayed in the historical section
// instead of at the top of the status page
Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"`
}
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
+241
View File
@@ -0,0 +1,241 @@
package announcement
import (
"errors"
"testing"
"time"
)
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcement *Announcement
expectedError error
expectedType string
}{
{
name: "valid-announcement-with-all-fields",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "This is a test announcement",
Archived: false,
},
expectedError: nil,
expectedType: TypeWarning,
},
{
name: "valid-announcement-with-archived-true",
announcement: &Announcement{
Timestamp: now,
Type: TypeOperational,
Message: "This is an archived announcement",
Archived: true,
},
expectedError: nil,
expectedType: TypeOperational,
},
{
name: "valid-announcement-with-empty-type-should-default-to-none",
announcement: &Announcement{
Timestamp: now,
Message: "This announcement has no type",
},
expectedError: nil,
expectedType: TypeNone,
},
{
name: "invalid-announcement-with-empty-message",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "",
},
expectedError: ErrEmptyMessage,
},
{
name: "invalid-announcement-with-zero-timestamp",
announcement: &Announcement{
Timestamp: time.Time{},
Type: TypeWarning,
Message: "Test message",
},
expectedError: ErrMissingTimestamp,
},
{
name: "invalid-announcement-with-invalid-type",
announcement: &Announcement{
Timestamp: now,
Type: "invalid-type",
Message: "Test message",
},
expectedError: ErrInvalidAnnouncementType,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.announcement.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
}
})
}
}
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
now := time.Now()
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
for _, validType := range validTypes {
t.Run("type-"+validType, func(t *testing.T) {
announcement := &Announcement{
Timestamp: now,
Type: validType,
Message: "Test message",
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error for type %s, got %v", validType, err)
}
if announcement.Type != validType {
t.Errorf("expected type %s, got %s", validType, announcement.Type)
}
})
}
}
func TestSortByTimestamp(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now"},
{Timestamp: later, Message: "later"},
{Timestamp: earlier, Message: "earlier"},
}
SortByTimestamp(announcements)
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now", Archived: false},
{Timestamp: later, Message: "later", Archived: true},
{Timestamp: earlier, Message: "earlier", Archived: false},
}
SortByTimestamp(announcements)
// Sorting should be by timestamp only, not affected by archived status
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest, regardless of archived status")
}
if !announcements[0].Archived {
t.Error("expected first announcement to be archived")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestValidateAndSetDefaults_Slice(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcements []*Announcement
expectedError error
shouldValidate bool
}{
{
name: "all-valid-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "mixed-archived-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "one-invalid-announcement-in-slice",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: TypeOperational, Message: ""},
},
expectedError: ErrEmptyMessage,
shouldValidate: false,
},
{
name: "announcement-with-missing-timestamp",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
},
expectedError: ErrMissingTimestamp,
shouldValidate: false,
},
{
name: "announcement-with-invalid-type",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
},
expectedError: ErrInvalidAnnouncementType,
shouldValidate: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := ValidateAndSetDefaults(scenario.announcements)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
now := time.Now()
announcement := &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "Test announcement",
// Archived not set, should default to false
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error, got %v", err)
}
// Zero value for bool is false
if announcement.Archived {
t.Error("expected Archived to default to false")
}
}
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
announcements := []*Announcement{}
if err := ValidateAndSetDefaults(announcements); err != nil {
t.Errorf("expected no error for empty slice, got %v", err)
}
}
+2 -5
View File
@@ -575,11 +575,8 @@ func ValidateUniqueKeys(config *Config) error {
func ValidateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.ValidateAndSetDefaults() {
if !config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
return ErrInvalidSecurityConfig
}
}
@@ -610,11 +607,11 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeIFTTT,
alert.TypeIlert,
alert.TypeIncidentIO,
alert.TypeJetBrainsSpace,
alert.TypeLine,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeN8N,
alert.TypeNewRelic,
alert.TypeNtfy,
alert.TypeOpsgenie,
+12 -64
View File
@@ -25,7 +25,6 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"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/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -775,10 +774,6 @@ alerting:
to: "+1-234-567-8901"
teams:
webhook-url: "http://example.com"
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
endpoints:
- name: website
@@ -801,7 +796,6 @@ endpoints:
success-threshold: 15
- type: teams
- type: pushover
- type: jetbrainsspace
conditions:
- "[STATUS] == 200"
`))
@@ -828,8 +822,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 10 {
t.Fatal("There should've been 10 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -936,12 +930,6 @@ endpoints:
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
}
if !config.Endpoints[0].Alerts[9].IsEnabled() {
t.Error("The alert should've been enabled")
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -1001,14 +989,6 @@ alerting:
webhook-url: "http://example.com"
default-alert:
enabled: true
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
default-alert:
enabled: true
failure-threshold: 5
success-threshold: 3
email:
from: "from@example.com"
username: "from@example.com"
@@ -1047,7 +1027,6 @@ endpoints:
- type: twilio
- type: teams
- type: pushover
- type: jetbrainsspace
- type: email
- type: gotify
conditions:
@@ -1169,22 +1148,6 @@ endpoints:
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
t.Fatal("JetBrainsSpace alerting config should've been valid")
}
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
}
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
}
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
}
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
t.Fatal("Email alerting config should've been valid")
}
@@ -1256,8 +1219,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 12 {
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
if len(config.Endpoints[0].Alerts) != 11 {
t.Fatalf("There should've been 11 alerts configured, got %d", len(config.Endpoints[0].Alerts))
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -1374,21 +1337,21 @@ endpoints:
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
}
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
if config.Endpoints[0].Alerts[9].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[9].Type)
}
if !config.Endpoints[0].Alerts[9].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
if config.Endpoints[0].Alerts[9].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold)
}
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
if config.Endpoints[0].Alerts[9].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
}
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
if config.Endpoints[0].Alerts[10].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[10].Type)
}
if !config.Endpoints[0].Alerts[10].IsEnabled() {
t.Error("The alert should've been enabled")
@@ -1399,19 +1362,6 @@ endpoints:
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
}
if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type)
}
if !config.Endpoints[0].Alerts[11].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[11].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold)
}
if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -1917,7 +1867,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
IFTTT: &ifttt.AlertProvider{},
Ilert: &ilert.AlertProvider{},
IncidentIO: &incidentio.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Line: &line.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
@@ -1962,7 +1911,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
{alertType: alert.TypeLine, expected: alertingConfig.Line},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
+38 -1
View File
@@ -209,7 +209,44 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
}
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
resolvedStrings := make([]string, 2)
for i := 0; i < 2; i++ {
// Check if the parameter is a certificate or domain expiration placeholder
if parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {
// Format as duration string (convert milliseconds back to duration)
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else if _, err := time.ParseDuration(parameters[i]); err == nil {
// If the original parameter was a duration string (like "48h"), format the resolved value
// as a duration string too so it matches and doesn't show in parentheses
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else {
// Format as integer
resolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))
}
}
return prettify(parameters, resolvedStrings, operator)
}
// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.
// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.
// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.
func formatDuration(d time.Duration) string {
// Truncate to whole seconds to avoid decimal seconds
d = d.Truncate(time.Second)
s := d.String()
// Special case: if duration is zero, return "0s"
if s == "0s" {
return "0s"
}
// Remove trailing "0s" if present
if strings.HasSuffix(s, "0s") {
s = strings.TrimSuffix(s, "0s")
// Remove trailing "0m" if present after removing "0s"
s = strings.TrimSuffix(s, "0m")
}
return s
}
// prettify returns a string representation of a condition with its parameters resolved between parentheses
+2 -2
View File
@@ -476,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
},
{
Name: "certificate-expiration-greater-than-duration",
@@ -490,7 +490,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
Result: &Result{CertificateExpiration: 24 * time.Hour},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
},
{
Name: "no-placeholders",
+28 -4
View File
@@ -50,6 +50,7 @@ const (
TypeSTARTTLS Type = "STARTTLS"
TypeTLS Type = "TLS"
TypeHTTP Type = "HTTP"
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
TypeUNKNOWN Type = "UNKNOWN"
@@ -177,6 +178,8 @@ func (e *Endpoint) Type() Type {
return TypeTLS
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return TypeHTTP
case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"):
return TypeGRPC
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return TypeWS
case strings.HasPrefix(e.URL, "ssh://"):
@@ -353,6 +356,9 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
}
result.port = ""
}
if processedEndpoint.UIConfig.HideErrors {
result.Errors = nil
}
if processedEndpoint.UIConfig.HideConditions {
result.ConditionResults = nil
}
@@ -497,8 +503,8 @@ func (e *Endpoint) call(result *Result) {
}
result.Duration = time.Since(startTime)
} 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 {
// If there's no username, password or private key specified, attempt to validate just the SSH banner
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
@@ -509,17 +515,35 @@ func (e *Endpoint) call(result *Result) {
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
var output []byte
result.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
if e.needsToReadBody() {
result.Body = output
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeGRPC {
useTLS := strings.HasPrefix(e.URL, "grpcs://")
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Connected = connected
result.Duration = duration
if e.needsToReadBody() {
result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status))
}
} else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
+188 -8
View File
@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
name string
username string
password string
privateKey string
expectedErr error
}{
{
name: "fail when has no user",
name: "fail when has no user but has password",
username: "",
password: "password",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "fail when has no password",
username: "username",
password: "",
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
name: "fail when has no user but has private key",
username: "",
privateKey: "-----BEGIN",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "success when all fields are set",
name: "fail when has no password or private key",
username: "username",
password: "",
privateKey: "",
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
},
{
name: "success when username and password are set",
username: "username",
password: "password",
expectedErr: nil,
},
{
name: "success when username and private key are set",
username: "username",
privateKey: "-----BEGIN",
expectedErr: nil,
},
}
for _, scenario := range scenarios {
@@ -539,8 +553,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
Name: "ssh-test",
URL: "https://example.com",
SSHConfig: &ssh.Config{
Username: scenario.username,
Password: scenario.password,
Username: scenario.username,
Password: scenario.password,
PrivateKey: scenario.privateKey,
},
Conditions: []Condition{Condition("[STATUS] == 0")},
}
@@ -1448,3 +1463,168 @@ func TestEndpoint_preprocessWithContext(t *testing.T) {
})
}
}
func TestEndpoint_HideUIFeatures(t *testing.T) {
defer client.InjectHTTPClient(nil)
tests := []struct {
name string
endpoint Endpoint
mockResponse test.MockRoundTripper
checkHostname bool
expectHostname string
checkErrors bool
expectErrors bool
checkConditions bool
expectConditions bool
checkErrorContent string
}{
{
name: "hide-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
UIConfig: &ui.Config{HideConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`))}
}),
checkConditions: true,
expectConditions: false,
},
{
name: "hide-hostname",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideHostname: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkHostname: true,
expectHostname: "",
},
{
name: "hide-url-in-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: true,
checkErrorContent: "<redacted>",
},
{
name: "hide-port-in-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com:9999/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HidePort: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: true,
checkErrorContent: "<redacted>",
},
{
name: "hide-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideErrors: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: false,
},
{
name: "dont-resolve-failed-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: true,
},
{
name: "multiple-hide-features",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: false,
checkHostname: true,
expectHostname: "",
checkErrors: true,
expectErrors: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mockResponse != nil {
mockClient := &http.Client{Transport: tt.mockResponse}
if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = tt.endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
err := tt.endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := tt.endpoint.EvaluateHealth()
if tt.checkHostname {
if result.Hostname != tt.expectHostname {
t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname)
}
}
if tt.checkErrors {
hasErrors := len(result.Errors) > 0
if hasErrors != tt.expectErrors {
t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors)
}
if tt.checkErrorContent != "" && len(result.Errors) > 0 {
found := false
for _, err := range result.Errors {
if strings.Contains(err, tt.checkErrorContent) {
found = true
break
}
}
if !found {
t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors)
}
}
}
if tt.checkConditions {
hasConditions := len(result.ConditionResults) > 0
if hasConditions != tt.expectConditions {
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
}
}
})
}
}
+11 -8
View File
@@ -8,26 +8,29 @@ var (
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
)
type Config struct {
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
PrivateKey string `yaml:"private-key,omitempty"`
}
// Validate the SSH configuration
func (cfg *Config) Validate() error {
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return nil
}
// If any authentication method is provided (password or private key), a username is required
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}
if len(cfg.Password) == 0 {
return ErrEndpointWithoutSSHPassword
// If a username is provided, require at least a password or a private key
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return ErrEndpointWithoutSSHAuth
}
return nil
}
+18 -3
View File
@@ -5,7 +5,7 @@ import (
"testing"
)
func TestSSH_validate(t *testing.T) {
func TestSSH_validatePasswordCfg(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error")
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
cfg.Username = "username"
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
}
cfg.Password = "password"
if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err)
}
}
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
t.Run("fail when username missing but private key provided", func(t *testing.T) {
cfg := &Config{PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
}
})
t.Run("success when username with private key", func(t *testing.T) {
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
+4
View File
@@ -16,6 +16,9 @@ type Config struct {
// HidePort whether to hide the port in the Result
HidePort bool `yaml:"hide-port"`
// HideErrors whether to hide the errors in the Result
HideErrors bool `yaml:"hide-errors"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
@@ -58,6 +61,7 @@ func GetDefaultConfig() *Config {
HideHostname: false,
HideURL: false,
HidePort: false,
HideErrors: false,
DontResolveFailedConditions: false,
HideConditions: false,
Badge: &Badge{
+3 -1
View File
@@ -15,5 +15,7 @@ func sanitize(s string) string {
s = strings.ReplaceAll(s, ",", "-")
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "#", "-")
s = strings.ReplaceAll(s, "+", "-")
s = strings.ReplaceAll(s, "&", "-")
return s
}
}
+15
View File
@@ -29,6 +29,21 @@ func TestConvertGroupAndNameToKey(t *testing.T) {
Name: "name",
ExpectedOutput: "_name",
},
{
GroupName: "API (v1)",
Name: "endpoint",
ExpectedOutput: "api-(v1)_endpoint",
},
{
GroupName: "website (admin)",
Name: "test",
ExpectedOutput: "website-(admin)_test",
},
{
GroupName: "search",
Name: "query&filter",
ExpectedOutput: "search_query-filter",
},
}
for _, scenario := range scenarios {
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
+10 -10
View File
@@ -31,17 +31,17 @@ var (
//
// Uses UTC by default.
type Config struct {
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
Timezone string `yaml:"timezone,omitempty"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
// Every is a list of days of the week during which maintenance period applies.
// See longDayNames for list of valid values.
// Every day if empty.
Every []string `yaml:"every"`
Every []string `yaml:"every,omitempty"`
TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured
timezoneLocation *time.Location
durationToStartFromMidnight time.Duration
}
@@ -90,13 +90,13 @@ func (c *Config) ValidateAndSetDefaults() error {
return errInvalidMaintenanceDuration
}
if c.Timezone != "" {
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
}
} else {
c.Timezone = "UTC"
c.TimezoneLocation = time.UTC
c.timezoneLocation = time.UTC
}
return nil
}
@@ -107,8 +107,8 @@ func (c *Config) IsUnderMaintenance() bool {
return false
}
now := time.Now()
if c.TimezoneLocation != nil {
now = now.In(c.TimezoneLocation)
if c.timezoneLocation != nil {
now = now.In(c.timezoneLocation)
}
adjustedDate := now.Day()
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
+27 -22
View File
@@ -54,7 +54,6 @@ func New(config *Config) *SSHTunnel {
tunnel := &SSHTunnel{
config: config,
}
// Parse authentication methods once during initialization to avoid
// expensive cryptographic operations on every connection attempt
if config.PrivateKey != "" {
@@ -66,7 +65,6 @@ func New(config *Config) *SSHTunnel {
} else if config.Password != "" {
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
}
return tunnel
}
@@ -131,27 +129,34 @@ func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
client = t.client
t.mu.Unlock()
}
// Create connection through SSH tunnel
conn, err := client.Dial(network, addr)
if err != nil {
// Close stale connection before retry to prevent leak
t.mu.Lock()
if t.client != nil {
t.client.Close()
t.client = nil
// Attempt dial with exponential backoff retry
const maxRetries = 3
const baseDelay = 500 * time.Millisecond
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 500ms, 1s, 2s
delay := baseDelay << (attempt - 1)
time.Sleep(delay)
// Close stale connection and reconnect
t.mu.Lock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
if err := t.connectUnsafe(); err != nil {
t.mu.Unlock()
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
continue
}
client = t.client
t.mu.Unlock()
}
t.mu.Unlock()
// Retry once - connection might be stale
if connErr := t.Connect(); connErr != nil {
return nil, fmt.Errorf("SSH tunnel dial failed: %w (retry failed: %v)", err, connErr)
}
t.mu.RLock()
client = t.client
t.mu.RUnlock()
conn, err = client.Dial(network, addr)
if err != nil {
return nil, fmt.Errorf("SSH tunnel dial failed after retry: %w", err)
conn, err := client.Dial(network, addr)
if err == nil {
return conn, nil
}
lastErr = err
}
return conn, nil
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
}
+1 -1
View File
@@ -13,7 +13,7 @@ type Config struct {
// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel
Tunnels map[string]*sshtunnel.Config `yaml:",inline"`
mu sync.RWMutex `yaml:"-"`
mu sync.RWMutex `yaml:"-"`
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
}
+1 -1
View File
@@ -188,4 +188,4 @@ func TestConfig_Close(t *testing.T) {
if len(config.connections) != 0 {
t.Errorf("Close() did not clear connections map, got %d connections", len(config.connections))
}
}
}
+30 -19
View File
@@ -10,14 +10,16 @@ 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 = "Gatus"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
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 = "Gatus"
defaultDashboardHeading = "Health Dashboard"
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
)
var (
@@ -30,17 +32,18 @@ var (
// 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
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')
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
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')
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
//////////////////////////////////////////////
@@ -73,6 +76,8 @@ func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Description: defaultDescription,
DashboardHeading: defaultDashboardHeading,
DashboardSubheading: defaultDashboardSubheading,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
@@ -92,6 +97,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Description) == 0 {
cfg.Description = defaultDescription
}
if len(cfg.DashboardHeading) == 0 {
cfg.DashboardHeading = defaultDashboardHeading
}
if len(cfg.DashboardSubheading) == 0 {
cfg.DashboardSubheading = defaultDashboardSubheading
}
if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
+110 -25
View File
@@ -7,31 +7,110 @@ import (
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
cfg := &Config{
Title: "",
Description: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
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)
}
t.Run("empty-config", func(t *testing.T) {
cfg := &Config{
Title: "",
Description: "",
DashboardHeading: "",
DashboardSubheading: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
if cfg.DashboardHeading != defaultDashboardHeading {
t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
}
if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected DashboardSubheading to be %s, got %s", defaultDashboardSubheading, cfg.DashboardSubheading)
}
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)
}
})
t.Run("custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
Description: "Custom Description",
DashboardHeading: "Production Status",
DashboardSubheading: "Monitor all production endpoints",
Header: "My Company",
Logo: "https://example.com/logo.png",
Link: "https://example.com",
DefaultSortBy: "health",
DefaultFilterBy: "failing",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != "Custom Title" {
t.Errorf("expected title to be preserved, got %s", cfg.Title)
}
if cfg.Description != "Custom Description" {
t.Errorf("expected description to be preserved, got %s", cfg.Description)
}
if cfg.DashboardHeading != "Production Status" {
t.Errorf("expected DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
}
if cfg.DashboardSubheading != "Monitor all production endpoints" {
t.Errorf("expected DashboardSubheading to be preserved, got %s", cfg.DashboardSubheading)
}
if cfg.Header != "My Company" {
t.Errorf("expected header to be preserved, got %s", cfg.Header)
}
if cfg.Logo != "https://example.com/logo.png" {
t.Errorf("expected logo to be preserved, got %s", cfg.Logo)
}
if cfg.Link != "https://example.com" {
t.Errorf("expected link to be preserved, got %s", cfg.Link)
}
if cfg.DefaultSortBy != "health" {
t.Errorf("expected defaultSortBy to be preserved, got %s", cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != "failing" {
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
}
})
t.Run("partial-custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
DashboardHeading: "My Dashboard",
Header: "",
DashboardSubheading: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != "Custom Title" {
t.Errorf("expected custom title to be preserved, got %s", cfg.Title)
}
if cfg.DashboardHeading != "My Dashboard" {
t.Errorf("expected custom DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
}
if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected DashboardSubheading to use default, got %s", cfg.DashboardSubheading)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to use default, got %s", cfg.Header)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to use default, got %s", cfg.Description)
}
})
}
func TestButton_Validate(t *testing.T) {
@@ -78,6 +157,12 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Title != defaultTitle {
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
}
if defaultConfig.DashboardHeading != defaultDashboardHeading {
t.Error("expected GetDefaultConfig() to return defaultDashboardHeading, got", defaultConfig.DashboardHeading)
}
if defaultConfig.DashboardSubheading != defaultDashboardSubheading {
t.Error("expected GetDefaultConfig() to return defaultDashboardSubheading, got", defaultConfig.DashboardSubheading)
}
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
+46 -32
View File
@@ -5,58 +5,72 @@ go 1.24.4
toolchain go1.24.7
require (
code.gitea.io/sdk/gitea v0.21.0
code.gitea.io/sdk/gitea v0.22.0
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.4.0
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
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.8
github.com/TwiN/whois v1.2.0
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7
github.com/coreos/go-oidc/v3 v3.16.0
github.com/gofiber/fiber/v2 v2.52.9
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.68
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/client_golang v1.23.2
github.com/registrobr/rdap v1.1.8
github.com/valyala/fasthttp v1.64.0
github.com/valyala/fasthttp v1.67.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.17.0
google.golang.org/api v0.242.0
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.18.0
google.golang.org/api v0.252.0
google.golang.org/grpc v1.75.1
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.38.2
modernc.org/sqlite v1.39.1
)
require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
github.com/aws/smithy-go v1.23.2 // 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
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
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.2 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // 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
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -65,27 +79,27 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
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.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
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.25.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
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+110 -87
View File
@@ -1,13 +1,13 @@
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 v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
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.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=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
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=
@@ -18,21 +18,48 @@ 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.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/TwiN/whois v1.2.0 h1:/Z22SrS3Z0FQgMl1+4bKSu9UmEQTfGx9i9J4Hn18eQk=
github.com/TwiN/whois v1.2.0/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/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
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/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7 h1:As+tSgow5R48+hFwkgtEBOFYFtvSEqMal2QSMRccWgw=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7/go.mod h1:DgSdE3UxodNau1977aGbt4YixHYESTNAEStFo9qJ2TQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
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=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
@@ -43,15 +70,15 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/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.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/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=
@@ -72,16 +99,12 @@ 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.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
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=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -109,12 +132,12 @@ 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.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
@@ -126,13 +149,12 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
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=
@@ -144,18 +166,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
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=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
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=
@@ -163,8 +187,8 @@ 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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=
@@ -174,8 +198,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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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=
@@ -185,18 +209,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.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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -210,8 +234,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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -220,8 +244,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.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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=
@@ -231,28 +255,30 @@ 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.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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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.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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -260,23 +286,20 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.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/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/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/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/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -285,8 +308,8 @@ 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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
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=
+27
View File
@@ -183,6 +183,33 @@ func initializeStorage(cfg *config.Config) {
}
}
}
// Load persisted triggered alerts for suite endpoints
for _, suite := range cfg.Suites {
for _, ep := range suite.Endpoints {
var checksums []string
for _, alert := range ep.Alerts {
if alert.IsEnabled() {
checksums = append(checksums, alert.Checksum())
}
}
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)
if numberOfTriggeredAlertsDeleted > 0 {
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for suite endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
}
for _, alert := range ep.Alerts {
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)
if err != nil {
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for suite endpoint with key=%s: %s", ep.Key(), err.Error())
continue
}
if exists {
alert.Triggered, alert.ResolveKey = true, resolveKey
ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold
numberOfPersistedTriggeredAlertsLoaded++
}
}
}
}
if numberOfPersistedTriggeredAlertsLoaded > 0 {
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
}
+14
View File
@@ -17,6 +17,7 @@ var (
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultDomainExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Suite metrics
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultDomainExpirationSeconds != nil {
currentRegisterer.Unregister(resultDomainExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds)
resultDomainExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_domain_expiration_seconds",
Help: "Number of seconds until the domain expires",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDomainExpirationSeconds)
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_endpoint_success",
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
}
if result.DomainExpiration != 0 {
resultDomainExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.DomainExpiration.Seconds())
}
if result.Success {
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else {
+13
View File
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized")
}
if resultDomainExpirationSeconds == nil {
t.Error("resultDomainExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized")
}
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: true,
CertificateExpiration: 49 * time.Hour,
DomainExpiration: 25 * time.Hour,
}, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -140,6 +145,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 90000
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
@@ -154,9 +162,11 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: false,
CertificateExpiration: 47 * time.Hour,
DomainExpiration: 24 * time.Hour,
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -175,6 +185,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 86400
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
-14
View File
@@ -12,7 +12,6 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -325,19 +324,6 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
{
Name: "jetbrainsspace",
AlertType: alert.TypeJetBrainsSpace,
AlertingConfig: &alerting.Config{
JetBrainsSpace: &jetbrainsspace.AlertProvider{
DefaultConfig: jetbrainsspace.Config{
Project: "foo",
ChannelID: "bar",
Token: "baz",
},
},
},
},
{
Name: "line",
AlertType: alert.TypeLine,
-1
View File
@@ -64,7 +64,6 @@ func executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []st
}
}
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
// TODO: Consider moving this after the monitoring lock is unlocked? I mean, how much noise can a single alerting provider cause...
HandleAlerting(ep, result, cfg.Alerting)
} else {
logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window")
+2 -2
View File
@@ -50,6 +50,8 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
if cfg.Metrics {
metrics.PublishMetricsForSuite(s, result, extraLabels)
}
// Store result
UpdateSuiteStatus(s, result)
// Handle alerting for suite endpoints
for i, ep := range s.Endpoints {
if i < len(result.EndpointResults) {
@@ -72,8 +74,6 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
}
}
logr.Infof("[watchdog.executeSuite] Completed suite=%s; success=%v; errors=%d; duration=%v; endpoints_executed=%d/%d", s.Name, result.Success, len(result.Errors), result.Duration, len(result.EndpointResults), len(s.Endpoints))
// Store result in database
UpdateSuiteStatus(s, result)
}
// UpdateSuiteStatus persists the suite result in the database
+146 -464
View File
@@ -8,16 +8,19 @@
"name": "gatus",
"version": "4.0.0",
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^13.6.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0",
"radix-vue": "^1.9.17",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
@@ -1861,42 +1864,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.3",
"@floating-ui/utils": "^0.2.10",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -1912,30 +1879,6 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@headlessui/vue": {
"version": "1.7.23",
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
"license": "MIT",
"dependencies": {
"@tanstack/vue-virtual": "^3.0.0-beta.60"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
"license": "MIT",
"peerDependencies": {
"vue": ">= 3"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1974,24 +1917,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@internationalized/date": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@@ -2063,6 +1988,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -2244,41 +2175,6 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -2472,11 +2368,12 @@
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
@@ -3221,44 +3118,6 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true
},
"node_modules/@vueuse/core": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.6.0",
"@vueuse/shared": "13.6.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -3670,18 +3529,6 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/array-flatten": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
@@ -4170,6 +4017,37 @@
"node": ">=4"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=4.0.0"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -5002,6 +4880,16 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -5146,12 +5034,6 @@
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
"dev": true
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5298,6 +5180,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -6237,7 +6128,8 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-glob": {
"version": "3.2.11",
@@ -7798,6 +7690,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -9535,88 +9439,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/radix-vue": {
"version": "1.9.17",
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.7",
"@floating-ui/vue": "^1.1.0",
"@internationalized/date": "^3.5.4",
"@internationalized/number": "^3.5.3",
"@tanstack/vue-virtual": "^3.8.1",
"@vueuse/core": "^10.11.0",
"@vueuse/shared": "^10.11.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.4",
"fast-deep-equal": "^3.1.3",
"nanoid": "^5.0.7"
},
"peerDependencies": {
"vue": ">= 3.2.0"
}
},
"node_modules/radix-vue/node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/radix-vue/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -10821,6 +10643,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
@@ -11026,30 +10849,14 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"node_modules/vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-eslint-parser": {
@@ -13323,38 +13130,6 @@
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true
},
"@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"requires": {
"@floating-ui/utils": "^0.2.10"
}
},
"@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"requires": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"@floating-ui/vue": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
"requires": {
"@floating-ui/dom": "^1.7.3",
"@floating-ui/utils": "^0.2.10",
"vue-demi": ">=0.13.0"
}
},
"@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -13370,20 +13145,6 @@
"@hapi/hoek": "^9.0.0"
}
},
"@headlessui/vue": {
"version": "1.7.23",
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
"requires": {
"@tanstack/vue-virtual": "^3.0.0-beta.60"
}
},
"@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
"requires": {}
},
"@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -13407,22 +13168,6 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
"@internationalized/date": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"requires": {
"@swc/helpers": "^0.5.0"
}
},
"@internationalized/number": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
"requires": {
"@swc/helpers": "^0.5.0"
}
},
"@jridgewell/gen-mapping": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@@ -13483,6 +13228,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"@leichtgewicht/ip-codec": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -13629,27 +13379,6 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true
},
"@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"requires": {
"tslib": "^2.8.0"
}
},
"@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="
},
"@tanstack/vue-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
"requires": {
"@tanstack/virtual-core": "3.13.12"
}
},
"@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -13840,10 +13569,11 @@
"@types/node": "*"
}
},
"@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"@types/ws": {
"version": "8.5.3",
@@ -14421,27 +14151,6 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true
},
"@vueuse/core": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
"requires": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.6.0",
"@vueuse/shared": "13.6.0"
}
},
"@vueuse/metadata": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ=="
},
"@vueuse/shared": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
"requires": {}
},
"@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -14776,14 +14485,6 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"requires": {
"tslib": "^2.0.0"
}
},
"array-flatten": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
@@ -15123,6 +14824,26 @@
"supports-color": "^5.3.0"
}
},
"chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"requires": {}
},
"chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"requires": {}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -15721,6 +15442,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -15822,11 +15548,6 @@
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
"dev": true
},
"defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
},
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -15936,6 +15657,14 @@
"domelementtype": "^2.2.0"
}
},
"dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"requires": {
"@types/trusted-types": "^2.0.7"
}
},
"domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -16615,7 +16344,8 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"fast-glob": {
"version": "3.2.11",
@@ -17784,6 +17514,11 @@
"semver": "^6.0.0"
}
},
"marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="
},
"mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -18970,60 +18705,6 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true
},
"radix-vue": {
"version": "1.9.17",
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
"requires": {
"@floating-ui/dom": "^1.6.7",
"@floating-ui/vue": "^1.1.0",
"@internationalized/date": "^3.5.4",
"@internationalized/number": "^3.5.3",
"@tanstack/vue-virtual": "^3.8.1",
"@vueuse/core": "^10.11.0",
"@vueuse/shared": "^10.11.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.4",
"fast-deep-equal": "^3.1.3",
"nanoid": "^5.0.7"
},
"dependencies": {
"@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
},
"@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"requires": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
}
},
"@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="
},
"@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"requires": {
"vue-demi": ">=0.14.8"
}
},
"nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="
}
}
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -19954,7 +19635,8 @@
"tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"type-check": {
"version": "0.4.0",
@@ -20092,10 +19774,10 @@
"@vue/shared": "3.5.18"
}
},
"vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"requires": {}
},
"vue-eslint-parser": {
+8 -1
View File
@@ -8,22 +8,29 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@babel/eslint-parser": "^7.25.1",
"@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",
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<script type="text/javascript">
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}}
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", 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];
+42 -8
View File
@@ -133,7 +133,7 @@
</div>
<a
:href="`${SERVER_URL}/oidc/login`"
:href="`/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"
>
@@ -148,7 +148,7 @@
</div>
<!-- Tooltip -->
<Tooltip :result="tooltip.result" :event="tooltip.event" />
<Tooltip :result="tooltip.result" :event="tooltip.event" :isPersistent="tooltipIsPersistent" />
</div>
</template>
@@ -162,7 +162,6 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue'
import Loading from './components/Loading.vue'
import { SERVER_URL } from '@/main'
const route = useRoute()
@@ -173,6 +172,7 @@ const announcements = ref([])
const tooltip = ref({})
const mobileMenuOpen = ref(false)
const isOidcLoading = ref(false)
const tooltipIsPersistent = ref(false)
let configInterval = null
// Computed properties
@@ -195,22 +195,52 @@ const buttons = computed(() => {
// Methods
const fetchConfig = async () => {
try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
retrievedConfig.value = true
const response = await fetch(`/api/v1/config`, { credentials: 'include' })
if (response.status === 200) {
const data = await response.json()
config.value = data
announcements.value = data.announcements || []
}
retrievedConfig.value = true
} catch (error) {
console.error('Failed to fetch config:', error)
retrievedConfig.value = true
}
}
const showTooltip = (result, event) => {
tooltip.value = { result, event }
const showTooltip = (result, event, action = 'hover') => {
if (action === 'click') {
if (!result) {
// Deselecting
tooltip.value = {}
tooltipIsPersistent.value = false
} else {
// Selecting new data point
tooltip.value = { result, event }
tooltipIsPersistent.value = true
}
} else if (action === 'hover') {
// Only update tooltip on hover if not in persistent mode
if (!tooltipIsPersistent.value) {
tooltip.value = { result, event }
}
}
}
const handleDocumentClick = (event) => {
// Close persistent tooltip when clicking outside
if (tooltipIsPersistent.value) {
const tooltipElement = document.getElementById('tooltip')
// Check if click is on a data point bar or inside tooltip
const clickedDataPoint = event.target.closest('.flex-1.h-6, .flex-1.h-8')
if (tooltipElement && !tooltipElement.contains(event.target) && !clickedDataPoint) {
tooltip.value = {}
tooltipIsPersistent.value = false
// Emit event to clear selections in child components
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
}
}
}
// Fetch config on mount and set up interval
@@ -218,6 +248,8 @@ onMounted(() => {
fetchConfig()
// Refresh config every 10 minutes for announcements
configInterval = setInterval(fetchConfig, 600000)
// Add click listener for closing persistent tooltips
document.addEventListener('click', handleDocumentClick)
})
// Clean up interval on unmount
@@ -226,5 +258,7 @@ onUnmounted(() => {
clearInterval(configInterval)
configInterval = null
}
// Remove click listener
document.removeEventListener('click', handleDocumentClick)
})
</script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+36 -28
View File
@@ -44,17 +44,10 @@
: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">
<time class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ formatDate(date) }}
</time>
</div>
@@ -69,38 +62,60 @@
class="relative"
>
<!-- Timeline Icon -->
<div
<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',
'absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
index === group.length - 1 ? 'top-3' : 'top-1/2 -translate-y-1/2',
getTypeClasses(announcement.type).border
]"
>
<component
:is="getTypeIcon(announcement.type)"
<component
:is="getTypeIcon(announcement.type)"
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
/>
</div>
<!-- Vertical line segment connecting upward from first icon to date -->
<div
v-if="index === 0"
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
style="left: -16px; top: -2.5rem; height: calc(50% + 2.5rem);"
></div>
<!-- Vertical line segment connecting downward to next icon -->
<div
v-if="index < group.length - 1"
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="{
left: '-16px',
top: '50%',
height: index === group.length - 2 ? 'calc(50% + 1.25rem)' : 'calc(50% + 2rem)'
}"
></div>
<!-- Announcement Card -->
<div
<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
<div class="flex items-center gap-3">
<time
:class="[
'text-xs font-mono whitespace-nowrap',
'text-sm font-mono whitespace-nowrap flex-shrink-0',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
v-html="formatAnnouncementMessage(announcement.message)"
></p>
</div>
</div>
</div>
</div>
@@ -116,6 +131,7 @@
<script setup>
import { computed, ref } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
import { formatAnnouncementMessage } from '@/utils/markdown'
// Props
const props = defineProps({
@@ -220,14 +236,6 @@ 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()
@@ -291,4 +299,4 @@ const formatFullTimestamp = (timestamp) => {
margin-left: 1.5rem;
}
}
</style>
</style>
+49 -5
View File
@@ -39,10 +39,16 @@
: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'
result ? 'cursor-pointer' : '',
result ? (
result.success
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
: (selectedResultIndex === index ? 'bg-red-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)"
@mouseenter="result && handleMouseEnter(result, $event)"
@mouseleave="result && handleMouseLeave(result, $event)"
@click.stop="result && handleClick(result, $event, index)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
@@ -57,7 +63,7 @@
<script setup>
/* eslint-disable no-undef */
import { computed } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -82,6 +88,9 @@ const props = defineProps({
const emit = defineEmits(['showTooltip'])
// Track selected data point
const selectedResultIndex = ref(null)
const latestResult = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return null
@@ -145,7 +154,8 @@ const formattedResponseTime = computed(() => {
const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
const oldestResultIndex = Math.max(0, props.endpoint.results.length - props.maxResults)
return generatePrettyTimeAgo(props.endpoint.results[oldestResultIndex].timestamp)
})
const newestResultTime = computed(() => {
@@ -156,4 +166,38 @@ const newestResultTime = computed(() => {
const navigateToDetails = () => {
router.push(`/endpoints/${props.endpoint.key}`)
}
const handleMouseEnter = (result, event) => {
emit('showTooltip', result, event, 'hover')
}
const handleMouseLeave = (result, event) => {
emit('showTooltip', null, event, 'hover')
}
const handleClick = (result, event, index) => {
// Clear selections in other cards first
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
// Then toggle this card's selection
if (selectedResultIndex.value === index) {
selectedResultIndex.value = null
emit('showTooltip', null, event, 'click')
} else {
selectedResultIndex.value = index
emit('showTooltip', result, event, 'click')
}
}
// Listen for clear selection event
const handleClearSelection = () => {
selectedResultIndex.value = null
}
onMounted(() => {
window.addEventListener('clear-data-point-selection', handleClearSelection)
})
onUnmounted(() => {
window.removeEventListener('clear-data-point-selection', handleClearSelection)
})
</script>
@@ -0,0 +1,212 @@
<template>
<div v-if="announcements && announcements.length" class="past-announcements">
<h2 class="text-2xl font-semibold text-foreground mb-6">Past Announcements</h2>
<div class="space-y-8">
<div
v-for="(group, date) in displayedAnnouncements"
:key="date"
>
<!-- Date Header -->
<div class="mb-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{{ formatDate(date) }}
</h3>
</div>
<!-- Announcements for this date or empty state -->
<div v-if="group.length > 0" class="space-y-3">
<div
v-for="(announcement, index) in group"
:key="`${date}-${index}-${announcement.timestamp}`"
:class="[
'border-l-4 p-4 transition-all duration-200',
getTypeClasses(announcement.type).background,
getTypeClasses(announcement.type).borderColor
]"
>
<div class="flex items-start gap-3">
<component
:is="getTypeIcon(announcement.type)"
:class="['w-5 h-5 flex-shrink-0 mt-0.5', getTypeClasses(announcement.type).iconColor]"
/>
<time
:class="[
'text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
v-html="formatAnnouncementMessage(announcement.message)"
></p>
</div>
</div>
</div>
</div>
<!-- Empty state for dates without announcements -->
<div v-else class="py-2">
<p class="text-sm italic text-muted-foreground/60">
No incidents reported on this day
</p>
</div>
</div>
<!-- View Older Announcements Link -->
<div v-if="hasOlderAnnouncements && !showAllAnnouncements">
<button @click="showAllAnnouncements = true" class="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer group">
<ChevronDown class="w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200" />
<span class="group-hover:underline">View older announcements</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
import { formatAnnouncementMessage } from '@/utils/markdown'
// Props
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
// State
const showAllAnnouncements = ref(false)
// Type configurations (consistent with AnnouncementBanner)
const typeConfigs = {
outage: {
icon: XCircle,
background: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-500 dark:border-red-400',
iconColor: 'text-red-600 dark:text-red-400',
text: 'text-red-700 dark:text-red-300'
},
warning: {
icon: AlertTriangle,
background: 'bg-yellow-50 dark:bg-yellow-900/20',
borderColor: 'border-yellow-500 dark:border-yellow-400',
iconColor: 'text-yellow-600 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-300'
},
information: {
icon: Info,
background: 'bg-blue-50 dark:bg-blue-900/20',
borderColor: 'border-blue-500 dark:border-blue-400',
iconColor: 'text-blue-600 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-300'
},
operational: {
icon: CheckCircle,
background: 'bg-green-50 dark:bg-green-900/20',
borderColor: 'border-green-500 dark:border-green-400',
iconColor: 'text-green-600 dark:text-green-400',
text: 'text-green-700 dark:text-green-300'
},
none: {
icon: Circle,
background: 'bg-gray-50 dark:bg-gray-800/20',
borderColor: 'border-gray-500 dark:border-gray-400',
iconColor: 'text-gray-600 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-300'
}
}
// Helper to normalize date to start of day
const normalizeDate = (date) => {
const normalized = new Date(date)
normalized.setHours(0, 0, 0, 0)
return normalized
}
// Computed properties
const displayedAnnouncements = computed(() => {
if (!props.announcements?.length) return {}
// Group announcements by date and find oldest
const grouped = {}
let oldest = new Date()
props.announcements.forEach(announcement => {
const date = new Date(announcement.timestamp)
const key = date.toDateString()
grouped[key] = grouped[key] || []
grouped[key].push(announcement)
if (date < oldest) oldest = date
})
// Calculate date range
const today = normalizeDate(new Date())
const endDate = showAllAnnouncements.value
? normalizeDate(oldest)
: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000)
// Build result: today (if has announcements) + yesterday backwards
const result = {}
const todayKey = today.toDateString()
if (grouped[todayKey]) result[todayKey] = grouped[todayKey]
for (let date = new Date(today.getTime() - 24 * 60 * 60 * 1000); date >= endDate; date.setDate(date.getDate() - 1)) {
result[date.toDateString()] = grouped[date.toDateString()] || []
}
return result
})
// Check if there are announcements older than 14 days
const hasOlderAnnouncements = computed(() => {
if (!props.announcements?.length) return false
const fourteenDaysAgo = new Date(normalizeDate(new Date()).getTime() - 14 * 24 * 60 * 60 * 1000)
return props.announcements.some(a => new Date(a.timestamp) < fourteenDaysAgo)
})
// Helper functions
const getTypeIcon = (type) => {
return typeConfigs[type]?.icon || Circle
}
const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const formatDate = (dateString) => {
const date = new Date(dateString)
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>
@@ -0,0 +1,294 @@
<template>
<div class="relative w-full" style="height: 300px;">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50">
<Loading />
</div>
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center text-muted-foreground">
{{ error }}
</div>
<Line v-else :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale } from 'chart.js'
import annotationPlugin from 'chartjs-plugin-annotation'
import 'chartjs-adapter-date-fns'
import { generatePrettyTimeDifference } from '@/utils/time'
import Loading from './Loading.vue'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale, annotationPlugin)
const props = defineProps({
endpointKey: {
type: String,
required: true
},
duration: {
type: String,
required: true,
validator: (value) => ['24h', '7d', '30d'].includes(value)
},
serverUrl: {
type: String,
default: '..'
},
events: {
type: Array,
default: () => []
}
})
const loading = ref(true)
const error = ref(null)
const timestamps = ref([])
const values = ref([])
const isDark = ref(document.documentElement.classList.contains('dark'))
const hoveredEventIndex = ref(null)
// Helper function to get color for unhealthy events
const getEventColor = () => {
// Only UNHEALTHY events are displayed on the chart
return 'rgba(239, 68, 68, 0.8)' // Red
}
// Filter events based on selected duration and calculate durations
const filteredEvents = computed(() => {
if (!props.events || props.events.length === 0) {
return []
}
const now = new Date()
let fromTime
switch (props.duration) {
case '24h':
fromTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
fromTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
fromTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
default:
return []
}
// Only include UNHEALTHY events and calculate their duration
const unhealthyEvents = []
for (let i = 0; i < props.events.length; i++) {
const event = props.events[i]
if (event.type !== 'UNHEALTHY') continue
const eventTime = new Date(event.timestamp)
if (eventTime < fromTime || eventTime > now) continue
// Find the next event to calculate duration
let duration = null
let isOngoing = false
if (i + 1 < props.events.length) {
const nextEvent = props.events[i + 1]
duration = generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
} else {
// Still ongoing - calculate duration from event time to now
duration = generatePrettyTimeDifference(now, event.timestamp)
isOngoing = true
}
unhealthyEvents.push({
...event,
duration,
isOngoing
})
}
return unhealthyEvents
})
const chartData = computed(() => {
if (timestamps.value.length === 0) {
return {
labels: [],
datasets: []
}
}
const labels = timestamps.value.map(ts => new Date(ts))
return {
labels,
datasets: [{
label: 'Response Time (ms)',
data: values.value,
borderColor: isDark.value ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)',
backgroundColor: isDark.value ? 'rgba(96, 165, 250, 0.1)' : 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.1,
fill: true
}]
}
})
const chartOptions = computed(() => {
// Include hoveredEventIndex in dependency tracking
// eslint-disable-next-line no-unused-vars
const _ = hoveredEventIndex.value
// Calculate max Y value for positioning annotations
const maxY = values.value.length > 0 ? Math.max(...values.value) : 0
const midY = maxY / 2
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark.value ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
titleColor: isDark.value ? '#f9fafb' : '#111827',
bodyColor: isDark.value ? '#d1d5db' : '#374151',
borderColor: isDark.value ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
title: (tooltipItems) => {
if (tooltipItems.length > 0) {
const date = new Date(tooltipItems[0].parsed.x)
return date.toLocaleString()
}
return ''
},
label: (context) => {
const value = context.parsed.y
return `${value}ms`
}
}
},
annotation: {
annotations: filteredEvents.value.reduce((acc, event, index) => {
// Find closest data point to determine annotation position
const eventTimestamp = new Date(event.timestamp).getTime()
let closestValue = 0
if (timestamps.value.length > 0 && values.value.length > 0) {
const closestIndex = timestamps.value.reduce((closest, ts, idx) => {
const tsTime = new Date(ts).getTime()
const currentDistance = Math.abs(tsTime - eventTimestamp)
const closestDistance = Math.abs(new Date(timestamps.value[closest]).getTime() - eventTimestamp)
return currentDistance < closestDistance ? idx : closest
}, 0)
closestValue = values.value[closestIndex]
}
// Position annotation at bottom if data point is in lower half, at top if in upper half
const position = closestValue <= midY ? 'end' : 'start'
acc[`event-${index}`] = {
type: 'line',
xMin: new Date(event.timestamp),
xMax: new Date(event.timestamp),
borderColor: getEventColor(),
borderWidth: 1,
borderDash: [5, 5],
enter() {
hoveredEventIndex.value = index
},
leave() {
hoveredEventIndex.value = null
},
label: {
display: () => hoveredEventIndex.value === index,
content: [event.isOngoing ? `Status: ONGOING` : `Status: RESOLVED`, `Unhealthy for ${event.duration}`, `Started at ${new Date(event.timestamp).toLocaleString()}`],
backgroundColor: getEventColor(),
color: '#ffffff',
font: {
size: 11
},
padding: 6,
position
}
}
return acc
}, {})
}
},
scales: {
x: {
type: 'time',
time: {
unit: props.duration === '24h' ? 'hour' : props.duration === '7d' ? 'day' : 'day',
displayFormats: {
hour: 'MMM d, ha',
day: 'MMM d'
}
},
grid: {
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
drawBorder: false
},
ticks: {
color: isDark.value ? '#9ca3af' : '#6b7280',
maxRotation: 0,
autoSkipPadding: 20
}
},
y: {
beginAtZero: true,
grid: {
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
drawBorder: false
},
ticks: {
color: isDark.value ? '#9ca3af' : '#6b7280',
callback: (value) => `${value}ms`
}
}
}
}
})
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
timestamps.value = data.timestamps || []
values.value = data.values || []
} else {
error.value = 'Failed to load chart data'
console.error('[ResponseTimeChart] Error:', await response.text())
}
} catch (err) {
error.value = 'Failed to load chart data'
console.error('[ResponseTimeChart] Error:', err)
} finally {
loading.value = false
}
}
watch(() => props.duration, () => {
fetchData()
})
onMounted(() => {
fetchData()
const observer = new MutationObserver(() => {
isDark.value = document.documentElement.classList.contains('dark')
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
onUnmounted(() => observer.disconnect())
})
</script>
+10 -7
View File
@@ -64,9 +64,10 @@ const sortOptions = [
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => {
const handleFilterChange = (value, store = true) => {
filterBy.value = value
localStorage.setItem('gatus:filter-by', value)
if (store)
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first
emit('update:showOnlyFailing', false)
@@ -80,9 +81,11 @@ const handleFilterChange = (value) => {
}
}
const handleSortChange = (value) => {
const handleSortChange = (value, store = true) => {
sortBy.value = value
localStorage.setItem('gatus:sort-by', value)
if (store)
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group')
@@ -93,8 +96,8 @@ const handleSortChange = (value) => {
}
onMounted(() => {
// Apply saved filter/sort state on load
handleFilterChange(filterBy.value)
handleSortChange(sortBy.value)
// Apply saved or application wide filter/sort state on load but do not store it in localstorage
handleFilterChange(filterBy.value, false)
handleSortChange(sortBy.value, false)
})
</script>
+44 -9
View File
@@ -39,15 +39,21 @@
: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'
result ? 'cursor-pointer' : '',
result ? (
result.success
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
: (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')
) : 'bg-gray-200 dark:bg-gray-700'
]"
@mouseenter="result && showTooltip(result, $event)"
@mouseleave="hideTooltip($event)"
@mouseenter="result && handleMouseEnter(result, $event)"
@mouseleave="result && handleMouseLeave(result, $event)"
@click.stop="result && handleClick(result, $event, index)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{{ newestResultTime }}</span>
<span>{{ oldestResultTime }}</span>
<span>{{ newestResultTime }}</span>
</div>
</div>
</div>
@@ -56,7 +62,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -77,6 +83,9 @@ const props = defineProps({
const emit = defineEmits(['showTooltip'])
// Track selected data point
const selectedResultIndex = ref(null)
// Computed properties
const displayResults = computed(() => {
const results = [...(props.suite.results || [])]
@@ -143,13 +152,39 @@ const navigateToDetails = () => {
router.push(`/suites/${props.suite.key}`)
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
const handleMouseEnter = (result, event) => {
emit('showTooltip', result, event, 'hover')
}
const hideTooltip = (event) => {
emit('showTooltip', null, event)
const handleMouseLeave = (result, event) => {
emit('showTooltip', null, event, 'hover')
}
const handleClick = (result, event, index) => {
// Clear selections in other cards first
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
// Then toggle this card's selection
if (selectedResultIndex.value === index) {
selectedResultIndex.value = null
emit('showTooltip', null, event, 'click')
} else {
selectedResultIndex.value = index
emit('showTooltip', result, event, 'click')
}
}
// Listen for clear selection event
const handleClearSelection = () => {
selectedResultIndex.value = null
}
onMounted(() => {
window.addEventListener('clear-data-point-selection', handleClearSelection)
})
onUnmounted(() => {
window.removeEventListener('clear-data-point-selection', handleClearSelection)
})
</script>
<style scoped>
+119 -59
View File
@@ -1,12 +1,12 @@
<template>
<div
id="tooltip"
ref="tooltip"
<div
id="tooltip"
ref="tooltip"
:class="[
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
'absolute 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">
@@ -96,9 +96,12 @@
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick, computed } from 'vue'
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { prettifyTimestamp } from '@/utils/time'
const route = useRoute()
const props = defineProps({
event: {
type: [Event, Object],
@@ -107,6 +110,10 @@ const props = defineProps({
result: {
type: Object,
default: null
},
isPersistent: {
type: Boolean,
default: false
}
})
@@ -115,6 +122,7 @@ const hidden = ref(true)
const top = ref(0)
const left = ref(0)
const tooltip = ref(null)
const targetElement = ref(null)
// Computed properties
const isSuiteResult = computed(() => {
@@ -133,75 +141,109 @@ const successCount = computed(() => {
// Methods are imported from utils/time
// Update tooltip position based on target element's current position
const updatePosition = async () => {
if (!targetElement.value || !tooltip.value || hidden.value) return
await nextTick()
const targetRect = targetElement.value.getBoundingClientRect()
const tooltipRect = tooltip.value.getBoundingClientRect()
// For absolute positioning, we need to add scroll offsets
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
// Default position: below the target (viewport coords + scroll offset)
let newTop = targetRect.bottom + scrollTop + 8
let newLeft = targetRect.left + scrollLeft
// 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 + scrollTop - tooltipRect.height - 8
} else {
// Not enough space above either, position at the best spot
if (spaceAbove > spaceBelow) {
// More space above
newTop = scrollTop + 10
} else {
// More space below or equal, keep below but adjust
newTop = scrollTop + window.innerHeight - tooltipRect.height - 10
}
}
}
// 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 + scrollLeft - tooltipRect.width
// Make sure it doesn't go off the left edge
if (newLeft < scrollLeft + 10) {
newLeft = scrollLeft + 10
}
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
}
const reposition = async () => {
if (!props.event || !props.event.type) return
await nextTick()
if (props.event.type === 'mouseenter' && tooltip.value) {
if ((props.event.type === 'mouseenter' || props.event.type === 'click') && tooltip.value) {
const target = props.event.target
const targetRect = target.getBoundingClientRect()
// First, position tooltip to get its dimensions
// Store the target element for scroll updates
targetElement.value = target
// First, make tooltip visible 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
}
}
}
// 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
}
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
// Update position
await updatePosition()
} else if (props.event.type === 'mouseleave') {
hidden.value = true
// Only hide on mouseleave if not in persistent mode
if (!props.isPersistent) {
hidden.value = true
targetElement.value = null
}
}
}
// Handle resize events (still needed for viewport size changes)
const handleResize = () => {
updatePosition()
}
// Lifecycle hooks
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// Watchers
watch(() => props.event, (newEvent) => {
if (newEvent && newEvent.type) {
if (newEvent.type === 'mouseenter') {
if (newEvent.type === 'mouseenter' || newEvent.type === 'click') {
hidden.value = false
nextTick(() => reposition())
} else if (newEvent.type === 'mouseleave') {
hidden.value = true
// Only hide on mouseleave if not in persistent mode
if (!props.isPersistent) {
hidden.value = true
}
}
}
}, { immediate: true })
@@ -211,4 +253,22 @@ watch(() => props.result, () => {
nextTick(() => reposition())
}
})
// Watch for persistent state changes and result changes
watch(() => [props.isPersistent, props.result], ([isPersistent, result]) => {
if (!isPersistent && !result) {
// Hide tooltip when both persistent mode is off and no result
hidden.value = true
} else if (result && (isPersistent || props.event?.type === 'mouseenter')) {
// Show tooltip when there's a result and either persistent or hovering
hidden.value = false
nextTick(() => reposition())
}
})
// Watch for route changes and hide tooltip
watch(() => route.path, () => {
hidden.value = true
targetElement.value = null
})
</script>
+1 -1
View File
@@ -7,7 +7,7 @@
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
variant: {
+1 -1
View File
@@ -10,7 +10,7 @@
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
variant: {
+1 -1
View File
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
+1 -1
View File
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
+1 -1
View File
@@ -11,7 +11,7 @@
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
modelValue: {
-2
View File
@@ -3,6 +3,4 @@ import App from './App.vue'
import './index.css'
import router from './router'
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
createApp(App).use(router).mount('#app')
+46
View File
@@ -0,0 +1,46 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return ''
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const renderer = new marked.Renderer()
renderer.link = (tokenOrHref, title, text) => {
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
? tokenOrHref
: null
const href = tokenObject ? tokenObject.href : tokenOrHref
const resolvedTitle = tokenObject ? tokenObject.title : title
const resolvedText = tokenObject ? tokenObject.text : text
const url = escapeHtml(href || '')
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
const linkText = resolvedText || ''
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium"${titleAttribute}>${linkText}</a>`
}
marked.use({
renderer,
breaks: true,
gfm: true,
headerIds: false,
mangle: false
})
export const formatAnnouncementMessage = (message) => {
if (!message) {
return ''
}
const markdown = String(message)
const html = marked.parse(markdown)
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
}
+22 -2
View File
@@ -31,8 +31,28 @@ export const generatePrettyTimeAgo = (timestamp) => {
* @returns {string} Time difference string
*/
export const generatePrettyTimeDifference = (start, end) => {
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
return minutes + (minutes === 1 ? ' minute' : ' minutes');
const ms = new Date(start) - new Date(end)
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
const hoursText = hours + (hours === 1 ? ' hour' : ' hours')
if (remainingMinutes > 0) {
return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes')
}
return hoursText
} else if (minutes > 0) {
const remainingSeconds = seconds % 60
const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes')
if (remainingSeconds > 0) {
return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds')
}
return minutesText
} else {
return seconds + (seconds === 1 ? ' second' : ' seconds')
}
}
/**
+25 -23
View File
@@ -35,7 +35,7 @@
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}</div>
</CardContent>
</Card>
@@ -89,13 +89,13 @@
<EndpointCard
v-if="endpointStatus"
:endpoint="endpointStatus"
:maxResults="50"
:maxResults="resultPageSize"
: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" />
<Pagination @page="changePage" :numberOfResultsPerPage="resultPageSize" :currentPageProp="currentPage" />
</div>
</div>
</CardContent>
@@ -117,7 +117,13 @@
</div>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
<ResponseTimeChart
v-if="endpointStatus && endpointStatus.key"
:endpointKey="endpointStatus.key"
:duration="selectedChartDuration"
:serverUrl="serverUrl"
:events="endpointStatus.events || []"
/>
</CardContent>
</Card>
@@ -206,7 +212,7 @@ import EndpointCard from '@/components/EndpointCard.vue'
import Settings from '@/components/Settings.vue'
import Pagination from '@/components/Pagination.vue'
import Loading from '@/components/Loading.vue'
import { SERVER_URL } from '@/main.js'
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
const router = useRouter()
@@ -217,10 +223,10 @@ 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 resultPageSize = 50
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(() => {
@@ -254,7 +260,7 @@ const pageAverageResponseTime = computed(() => {
}
}
if (count === 0) return 'N/A'
return Math.round(total / count / 1000000)
return `${Math.round(total / count / 1000000)}ms`
})
const pageResponseTimeRange = computed(() => {
@@ -267,17 +273,17 @@ const pageResponseTimeRange = computed(() => {
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)
const duration = result.duration
if (duration) {
min = Math.min(min, duration)
max = Math.max(max, duration)
hasData = true
}
}
if (!hasData) return 'N/A'
const minMs = Math.round(min)
const maxMs = Math.round(max)
const minMs = Math.round(min / 1000000)
const maxMs = Math.round(max / 1000000)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
@@ -297,7 +303,7 @@ const lastCheckTime = computed(() => {
const fetchData = async () => {
isRefreshing.value = true
try {
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
const response = await fetch(`/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
credentials: 'include'
})
@@ -369,8 +375,8 @@ const changePage = (page) => {
fetchData()
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
const showTooltip = (result, event, action = 'hover') => {
emit('showTooltip', result, event, action)
}
const prettifyTimestamp = (timestamp) => {
@@ -378,19 +384,15 @@ const prettifyTimestamp = (timestamp) => {
}
const generateHealthBadgeImageURL = () => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
return `/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
}
const generateUptimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
return `/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`
return `/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
onMounted(() => {
+36 -13
View File
@@ -4,8 +4,8 @@
<div class="mb-6">
<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>
<h1 class="text-4xl font-bold tracking-tight">{{ dashboardHeading }}</h1>
<p class="text-muted-foreground mt-2">{{ dashboardSubheading }}</p>
</div>
<div class="flex items-center gap-4">
<Button
@@ -22,8 +22,8 @@
</Button>
</div>
</div>
<!-- Announcement Banner -->
<AnnouncementBanner :announcements="props.announcements" />
<!-- Announcement Banner (Active Announcements) -->
<AnnouncementBanner :announcements="activeAnnouncements" />
<!-- Search bar -->
<SearchBar
@search="handleSearch"
@@ -82,7 +82,7 @@
v-for="suite in items.suites"
:key="suite.key"
:suite="suite"
:maxResults="50"
:maxResults="resultPageSize"
@showTooltip="showTooltip"
/>
</div>
@@ -96,7 +96,7 @@
v-for="endpoint in items.endpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:maxResults="resultPageSize"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
@@ -116,7 +116,7 @@
v-for="suite in paginatedSuites"
:key="suite.key"
:suite="suite"
:maxResults="50"
:maxResults="resultPageSize"
@showTooltip="showTooltip"
/>
</div>
@@ -130,7 +130,7 @@
v-for="endpoint in paginatedEndpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:maxResults="resultPageSize"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
@@ -170,6 +170,11 @@
</Button>
</div>
</div>
<!-- Past Announcements Section -->
<div v-if="archivedAnnouncements.length > 0" class="mt-12 pb-8">
<PastAnnouncements :announcements="archivedAnnouncements" />
</div>
</div>
<Settings @refreshData="fetchData" />
@@ -187,7 +192,7 @@ import SearchBar from '@/components/SearchBar.vue'
import Settings from '@/components/Settings.vue'
import Loading from '@/components/Loading.vue'
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
import { SERVER_URL } from '@/main.js'
import PastAnnouncements from '@/components/PastAnnouncements.vue'
const props = defineProps({
announcements: {
@@ -196,6 +201,15 @@ const props = defineProps({
}
})
// Computed properties for active and archived announcements
const activeAnnouncements = computed(() => {
return props.announcements ? props.announcements.filter(a => !a.archived) : []
})
const archivedAnnouncements = computed(() => {
return props.announcements ? props.announcements.filter(a => a.archived) : []
})
const emit = defineEmits(['showTooltip'])
const endpointStatuses = ref([])
@@ -210,6 +224,7 @@ const showAverageResponseTime = ref(true)
const groupByGroup = ref(false)
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
const uncollapsedGroups = ref(new Set())
const resultPageSize = 50
const filteredEndpoints = computed(() => {
let filtered = [...endpointStatuses.value]
@@ -418,7 +433,7 @@ const fetchData = async () => {
}
try {
// Fetch endpoints
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
const endpointResponse = await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {
credentials: 'include'
})
if (endpointResponse.status === 200) {
@@ -429,7 +444,7 @@ const fetchData = async () => {
}
// Fetch suites
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=100`, {
const suiteResponse = await fetch(`/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {
credentials: 'include'
})
if (suiteResponse.status === 200) {
@@ -471,8 +486,8 @@ const toggleShowAverageResponseTime = () => {
showAverageResponseTime.value = !showAverageResponseTime.value
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
const showTooltip = (result, event, action = 'hover') => {
emit('showTooltip', result, event, action)
}
const calculateUnhealthyCount = (endpoints) => {
@@ -517,6 +532,14 @@ const initializeCollapsedGroups = () => {
}
}
const dashboardHeading = computed(() => {
return window.config && window.config.dashboardHeading && window.config.dashboardHeading !== '{{ .UI.DashboardHeading }}' ? window.config.dashboardHeading : "Health Dashboard"
})
const dashboardSubheading = computed(() => {
return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.DashboardSubheading }}' ? window.config.dashboardSubheading : "Monitor the health of your endpoints in real-time"
})
onMounted(() => {
fetchData()
})
+22 -11
View File
@@ -14,7 +14,7 @@
<p class="text-muted-foreground mt-2">
<span v-if="suite?.group">{{ suite.group }} </span>
<span v-if="latestResult">
{{ selectedResult && selectedResult !== sortedResults[0] ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
{{ selectedResult && selectedResult.timestamp !== sortedResults[0]?.timestamp ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
</span>
</p>
</div>
@@ -41,7 +41,7 @@
<!-- Latest Execution -->
<Card v-if="latestResult">
<CardHeader>
<CardTitle>Latest Execution</CardTitle>
<CardTitle>{{ selectedResult?.timestamp === sortedResults[0]?.timestamp ? 'Latest Execution' : `Execution at ${formatTimestamp(selectedResult.timestamp)}` }}</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
@@ -107,7 +107,7 @@
:key="index"
class="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
@click="selectedResult = result"
:class="{ 'bg-accent': selectedResult === result }"
:class="{ 'bg-accent': selectedResult && selectedResult.timestamp === result.timestamp }"
>
<div class="flex items-center gap-3">
<StatusBadge :status="result.success ? 'healthy' : 'unhealthy'" size="sm" />
@@ -154,7 +154,6 @@ import StepDetailsModal from '@/components/StepDetailsModal.vue'
import Settings from '@/components/Settings.vue'
import Loading from '@/components/Loading.vue'
import { generatePrettyTimeAgo } from '@/utils/time'
import { SERVER_URL } from '@/main'
const router = useRouter()
const route = useRoute()
@@ -184,20 +183,30 @@ const latestResult = computed(() => {
// Methods
const fetchData = async () => {
loading.value = true
// Don't show loading state on refresh to prevent UI flicker
const isInitialLoad = !suite.value
if (isInitialLoad) {
loading.value = true
}
try {
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
const response = await fetch(`/api/v1/suites/${route.params.key}/statuses`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
const oldSuite = suite.value
suite.value = data
if (data.results && data.results.length > 0 && !selectedResult.value) {
if (data.results && data.results.length > 0) {
// Sort results by timestamp to get the most recent one
const sorted = [...data.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
selectedResult.value = sorted[0]
// Update selectedResult if: no result selected, or currently viewing the latest result
const wasViewingLatest = !selectedResult.value ||
(oldSuite?.results && selectedResult.value.timestamp === [...oldSuite.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]?.timestamp)
if (wasViewingLatest) {
selectedResult.value = sorted[0]
}
}
} else if (response.status === 404) {
suite.value = null
@@ -207,7 +216,9 @@ const fetchData = async () => {
} catch (error) {
console.error('[SuiteDetails][fetchData] Error:', error)
} finally {
loading.value = false
if (isInitialLoad) {
loading.value = false
}
}
}
+16 -2
View File
@@ -6,5 +6,19 @@ module.exports = {
filenameHashing: false,
productionSourceMap: false,
outputDir: '../static',
publicPath: '/'
}
publicPath: '/',
devServer: {
port: 8081,
https: false,
client: {
webSocketURL:'auto://0.0.0.0/ws'
},
proxy: {
'^/api|^/css|^/oicd': {
target: "http://localhost:8080",
changeOrigin: true,
secure: false,
}
}
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>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}}
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", 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];
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long