Compare commits

...
111 Commits
Author SHA1 Message Date
Vlad ShulczandGitHub 08c463542f docs: update the postgres bind mount in the compose example (#1761)
Postgres 18 moved its declared volume to /var/lib/postgresql, so the
example container fails to start with the old path.

Signed-off-by: vshulcz <vshulcz@gmail.com>
2026-08-17 21:22:58 -04:00
0fd2520088 docs(alerting): Revise HomeAssistant alerts and add Home Assistant integration info (#1723)
* Revise HomeAssistant alerts and add integration info

Updated HomeAssistant alerts section to indicate legacy status and added Home Assistant integration details.

* Fix typo

* Tweak wording from Legacy to Native

* Tweak on feedback

---------

Co-authored-by: TwiN <twin@linux.com>
2026-08-14 22:09:04 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7a73be52c5 chore(deps): bump docker/login-action from 4.5.2 to 4.6.0 (#1745)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.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>
2026-08-08 22:51:58 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
73381e4179 chore(deps): bump docker/login-action from 4 to 4.5.2 (#1739)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  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>
2026-08-01 21:23:33 -04:00
b7100620c3 feat(client): Allow configuration of client cookie storage (#1716)
* Allow configuration of cookie storage

* Update client/config.go

---------

Co-authored-by: TwiN <twin@linux.com>
2026-07-25 17:12:10 -04:00
ae7ca199aa fix(jsonpath): prevent panic on negative array index (#1703)
A jsonpath condition with a negative array index (e.g. [BODY].data[-1]
or [BODY][-1]) reached array[arrayIndex] with a negative index because
the len(array) > arrayIndex bounds checks are satisfied by any negative
value, causing a 'runtime error: index out of range [-1]' panic. Since
condition evaluation runs in the watchdog with no recover(), this would
crash the process.

Guard against negative indices alongside the existing strconv.Atoi error
check so an out-of-range negative index returns nil (treated as an
invalid path) instead of panicking. Adds tests for negative indices on
keyed arrays, root arrays, and negative-index-followed-by-key.

Made by an AI agent (Claude Code, model Claude Opus 4.8).

Co-authored-by: TwiN <twin@linux.com>
2026-07-18 22:45:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
53392dd472 chore(deps): bump actions/checkout from 5 to 7 (#1694)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 22:44:11 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
4566e746f3 chore(deps): bump golang.org/x/crypto from 0.51.0 to 0.52.0 (#1675)
* chore(deps): bump golang.org/x/crypto from 0.51.0 to 0.52.0

Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

* Stabilize flaky network-dependent tests

---------

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>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-09 20:30:22 -04:00
08895200bb feat(dns): Support TXT query type (#1679)
Allows monitoring of things like spf records and others.

Co-authored-by: TwiN <twin@linux.com>
2026-06-05 22:00:42 -04:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
809e5068d7 ci: Remove codecov from repository (#1684)
* Remove codecov from repository

* Remove .github/codecov.yml since Codecov integration was removed (#1685)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-03 21:16:08 -04:00
TwiNandGitHub ed1107b41a chore(deps): Bump all Go dependencies (#1674)
* chore(deps): Bump all Go dependencies

* test: Use httptest.NewRequest instead of http.NewRequest
2026-05-19 19:52:26 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
154ee6e055 chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#1672)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6.0.0 to 6.0.1.
- [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/v6.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  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>
2026-05-19 19:25:28 -04:00
9095a5254e chore(deps): update lib/pq to v1.11.2 to fix Supabase PostgreSQL connection regression (#1644)
* fix: update lib/pq to v1.11.2 to fix Supabase PostgreSQL connection regression

lib/pq v1.11.0 introduced a Config struct refactor that unconditionally
sends an empty 'options' parameter during the PostgreSQL startup handshake.
Supabase's Supavisor connection pooler rejects startup messages containing
empty parameters, returning :bad_startup_payload and causing an EOF panic
during Gatus initialization.

This bump to lib/pq v1.11.2 (commit 1412805) fixes the issue by omitting
empty startup parameters from the handshake.

Fixes TwiN/gatus#1633
See: https://github.com/lib/pq/issues/1259

Changes:
- go.mod: bump github.com/lib/pq v1.11.1 -> v1.11.2
- go.sum: update checksums for lib/pq v1.11.2
- storage/store/sql/supabase_test.go: add regression test

This contribution was AI-assisted.

* Remove regression test per maintainer feedback

@TwiN requested removal of the test - version bumps rarely need regression tests since they don't get bumped back down.

---------

Co-authored-by: Wahaj Ahmed <wahajahmed010@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2026-05-15 18:34:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fba833f64b chore(deps): bump modernc.org/sqlite from 1.48.2 to 1.50.0 (#1647)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.48.2 to 1.50.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.48.2...v1.50.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.50.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>
2026-04-30 19:43:06 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
834bfab5c5 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.19.13 to 1.19.15 (#1645)
chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials

Bumps [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) from 1.19.13 to 1.19.15.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/credentials/v1.19.13...credentials/v1.19.15)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.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>
2026-04-27 20:55:40 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f9e03cb2c3 chore(deps): bump codecov/codecov-action from 5.5.3 to 6.0.0 (#1601)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.3 to 6.0.0.
- [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.3...v6.0.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 21:39:07 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
760b75348e chore(deps): bump modernc.org/sqlite from 1.47.0 to 1.48.2 (#1627)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.47.0 to 1.48.2.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.47.0...v1.48.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.48.2
  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>
2026-04-26 21:36:58 -04:00
SurDaft - Jack StuppleandGitHub 447c7ad321 docs: Document remote.client configuration (#1637)
chore(docs): Document remote.client configuration

Document remote.client configuration for ticket #64.
2026-04-22 22:40:37 -04:00
osalloumandGitHub bc433161d6 fix(storage): close rows before nested queries to prevent PostgreSQL driver error (#1503)
Fixes #1435
2026-04-18 20:19:13 -04:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>TwiN
42b51f5da5 fix(test): Remove t.Parallel() from tests racing on injectedHTTPClient (#1630)
Remove t.Parallel() from tests causing race condition on injectedHTTPClient

Agent-Logs-Url: https://github.com/TwiN/gatus/sessions/bd8f2b87-d453-4653-a61d-4bf4dda4024b

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2026-04-18 20:08:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
48056def12 chore(deps): bump google.golang.org/api from 0.265.0 to 0.273.1 (#1613)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.265.0 to 0.273.1.
- [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.265.0...v0.273.1)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.273.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>
2026-04-11 22:54:38 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f06c4f132b chore(deps): bump actions/github-script from 8 to 9 (#1624)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 22:13:35 -04:00
TwiN 64116ce3da docs(ai): Add AGENTS.md 2026-04-03 13:48:31 -04:00
Héctor LuacesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>TwiN
12be9facda feat(ui): Add customizable OIDC login screen branding (#1583)
* feat(ui): Add customizable OIDC login screen branding

As per https://github.com/TwiN/gatus/discussions/1579, this PR
allows for some customizations in the OIDC login screen:

- If a logo is set it will be displayed alongside the Gatus one
- New ui config `ui.login-subtitle`: customises the message in
  the screen
- If set, use `ui.header` will be used to customise the title

I haven't commited the web static assets. I believe that's
triggered with a comment? let me know if I should be doing it.

Also please clarify if I've forgotten about something here,
this is my first pr.

* chore(ui): Regenerate static assets

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-04-01 20:56:47 -04:00
CopilotGitHubTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
f925cb4924 fix(ci): regenerate-static-assets workflow reporting false errors on success (#1614)
* Initial plan

* Fix workflow logic to properly handle success status

Agent-Logs-Url: https://github.com/TwiN/gatus/sessions/16d5eaf0-8b26-4f8e-8818-f245652fb022

Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2026-04-01 20:45:20 -04:00
lifandGitHub f4428aa600 fix(client): return error from rdapQuery when no expiration event found (#1603)
* fix: return error from rdapQuery when no expiration event found

When an RDAP response has no expiration event, rdapQuery returned a
zero-value ExpirationDate with nil error. This prevented
GetDomainExpiration from falling back to WHOIS, causing failures
for TLDs like .net where RDAP may omit the expiration event.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: include hostname in RDAP error and add .net domain tests

Add .net domain test cases to TestRdapQuery and TestGetDomainExpiration
to cover the fallback path when RDAP lacks an expiration event (#1570).
Include hostname in the error message for easier debugging.

Signed-off-by: majiayu000 <1835304752@qq.com>

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-03-31 20:02:04 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0e390765e3 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.19.7 to 1.19.13 (#1608)
chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials

Bumps [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) from 1.19.7 to 1.19.13.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/m2/v1.19.7...credentials/v1.19.13)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.13
  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>
2026-03-31 18:54:45 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
893601a5f4 chore(deps): bump golang.org/x/crypto from 0.48.0 to 0.49.0 (#1599)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.49.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>
2026-03-27 23:24:55 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
322f278e8f chore(deps): bump modernc.org/sqlite from 1.46.1 to 1.47.0 (#1598)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.46.1 to 1.47.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.46.1...v1.47.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.47.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>
2026-03-25 22:48:17 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
814a9622c4 chore(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#1595)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.36.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>
2026-03-24 21:25:02 -04:00
PythonGermanyandGitHub a2fc5ab9bb fix(security): Do not allow invalid security configs (#1531)
* fix: Do not allow invalid security configs

* test: Fix empty security config test

* test: Add missing security config test scenarios
2026-03-23 17:35:42 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
593826ec5f chore(deps): bump golang.org/x/crypto from 0.47.0 to 0.48.0 (#1547)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.48.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>
2026-03-23 16:54:21 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9b2efa290f chore(deps): bump modernc.org/sqlite from 1.44.3 to 1.46.1 (#1554)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.44.3 to 1.46.1.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.44.3...v1.46.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.46.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>
2026-03-23 16:54:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
714143204b chore(deps): bump codecov/codecov-action from 5.5.2 to 5.5.3 (#1587)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.2 to 5.5.3.
- [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.2...v5.5.3)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.3
  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>
2026-03-23 16:53:45 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ff396b5079 chore(deps): bump docker/build-push-action from 6 to 7 (#1588)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 16:53:26 -04:00
jaydeethreeandGitHub b20935ecfe fix(alerting): Remove square brackets from email subject (#1586)
* Email alerts: Use parens instead of square brackets in the subject to resolve Gmail threading issues. Fixes #1563

* Remove parens

* Add `:` after alert name
2026-03-21 21:25:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3a6ad14cba chore(deps): bump docker/login-action from 3 to 4 (#1567)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 17:05:15 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
e6e211c916 chore(deps): bump docker/setup-qemu-action from 3 to 4 (#1566)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-03-18 16:42:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7cd09e7d03 chore(deps): bump docker/metadata-action from 5 to 6 (#1571)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 16:37:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fa8a387c9f chore(deps): bump docker/setup-buildx-action from 3 to 4 (#1572)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 16:37:36 -04:00
c7f0b85ba1 perf: Add PostgreSQL indexes for ~15x performance improvement (#1541)
fix: missing indexes are added to improve postgresql performance

Co-authored-by: TwiN <twin@linux.com>
2026-02-24 23:39:07 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
c4900ced6c chore(deps): bump golang.org/x/oauth2 from 0.34.0 to 0.35.0 (#1543)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.34.0...v0.35.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.35.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>
Co-authored-by: TwiN <twin@linux.com>
2026-02-19 22:31:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
433e59b0ce chore(deps): bump github.com/prometheus-community/pro-bing from 0.7.0 to 0.8.0 (#1544)
chore(deps): bump github.com/prometheus-community/pro-bing

Bumps [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) from 0.7.0 to 0.8.0.
- [Release notes](https://github.com/prometheus-community/pro-bing/releases)
- [Commits](https://github.com/prometheus-community/pro-bing/compare/v0.7.0...v0.8.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus-community/pro-bing
  dependency-version: 0.8.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>
2026-02-09 20:11:15 -05:00
f09d959c97 refactor: Simplify and modernize loops (#1522)
* refactor: Simplify loops

* refactor: Modernize loops using range over int

---------

Co-authored-by: TwiN <twin@linux.com>
2026-02-08 19:08:59 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
e5b4e0d381 chore(deps): bump google.golang.org/api from 0.264.0 to 0.265.0 (#1533)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.264.0 to 0.265.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.264.0...v0.265.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.265.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>
Co-authored-by: TwiN <twin@linux.com>
2026-02-07 22:27:02 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e580cd362a chore(deps): bump github.com/TwiN/whois from 1.2.0 to 1.3.0 (#1538)
Bumps [github.com/TwiN/whois](https://github.com/TwiN/whois) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/TwiN/whois/releases)
- [Commits](https://github.com/TwiN/whois/compare/v1.2.0...v1.3.0)

---
updated-dependencies:
- dependency-name: github.com/TwiN/whois
  dependency-version: 1.3.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>
2026-02-06 17:24:24 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b65b4d83e1 chore(deps): bump code.gitea.io/sdk/gitea from 0.22.1 to 0.23.2 (#1525)
Bumps code.gitea.io/sdk/gitea from 0.22.1 to 0.23.2.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.23.2
  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>
2026-02-03 15:56:35 -05:00
emrebasarannnandGitHub 618892c248 docs: Fix typo in README concurrency section (#1520)
fix(typo): Fixing typo in 'README.md/Concurrency' section
2026-02-01 10:43:18 -05:00
TwiNandGitHub 77cedb080d chore: Update to Go 1.25 (#1519) 2026-01-31 18:30:38 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
786af2dee4 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.19.2 to 1.19.7 (#1498)
chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials

Bumps [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) from 1.19.2 to 1.19.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/m2/v1.19.2...service/m2/v1.19.7)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.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>
2026-01-26 22:00:24 -05:00
48531911bb fix: handle HTTP header names case-insensitively (#1506)
Details: HTTP header names should be case-insensitive per RFC 7230. When the users
configure the headers like 'user-agent' (lowercase), gatus was adding its own 'User-Agent'
header because the case-sensitive map lookup failed to find the existing header.

Fixed headers:
- User-Agent
- Content-Type
- Host

Fixes #1491"

Co-authored-by: TwiN <twin@linux.com>
2026-01-24 21:49:16 -05:00
CopilotGitHubTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
720888009e test: Fix Gitea error handling in tests (#1508)
* Initial plan

* Fix Gitea test failures by handling authentication and network errors

Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>

* Refactor: Extract error checking logic into helper function

Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2026-01-24 21:42:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
961bffd8e7 chore(deps): bump github.com/miekg/dns from 1.1.68 to 1.1.70 (#1490)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.68 to 1.1.70.
- [Commits](https://github.com/miekg/dns/compare/v1.1.68...v1.1.70)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.70
  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>
2026-01-19 23:01:15 -05:00
Mateusz ŁoskotandGitHub 309d68e622 feat(ui): Add support for custom favicon (#1097)
feat: Add UI configuration for custom favicon

Adds new node `favicon` to `ui` configuration
with properties that allow to overwrite the three
favicons which are currently in use in the index.html template.

Closes #174

Signed-off-by: Mateusz Łoskot <mateusz@loskot.net>
2026-01-16 17:27:39 -05:00
TwiNandGitHub ce269905b7 ci: Fix typo in regenerate-static-assets comment 2026-01-16 17:26:26 -05:00
TwiNandGitHub d8724d683e ci: Improve regenerate-static-assets comments 2026-01-16 17:24:57 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f22e863a9 chore(deps): bump golang.org/x/crypto from 0.45.0 to 0.46.0 (#1488)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  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>
2026-01-15 23:29:05 -05:00
Lorenzo Pereira Piccoli XavierandGitHub 7bb959e072 feat(conditions,ui): Add endpoints[].ui.resolve-successful-conditions (#1486)
feat(ui&endpoints): add bolean option for show resolve-successful-conditions
2026-01-11 22:36:23 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
1095deb3c6 chore(deps): bump github.com/aws/aws-sdk-go-v2 from 1.40.0 to 1.41.0 (#1466)
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.40.0 to 1.41.0.
- [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/v1.40.0...v1.41.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-01-11 18:59:37 -05:00
PythonGermanyandGitHub c93a321073 fix(ci): Adjust static asset generation workflow (#1480)
* fix(ci): Add comment workflow has no changes to commit

* refactor: Use github icon syntax

* feat(ci): Mention user who triggered the workflow

* fix: Get actor from context

* fix: Update incorrect variable name

* feat(ci): Add workflow link for all comments
2026-01-09 22:55:33 -05:00
PythonGermanyGitHubTwiNgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
1bf05d6bf9 fix(ui): Persist show average response time setting (#1438)
* feat(ui): Persist show average response time

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-08 21:27:44 -05:00
1c532971cb ui: Disable hover effect if no link is set (#1419)
* fix(ui): Disable hover effect if no link is set

* refactor: Simplify conditional class logic

* chore(ui): Regenerate static assets

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2026-01-02 20:31:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
67bf6c16b6 chore(deps): bump google.golang.org/grpc from 1.75.1 to 1.77.0 (#1465)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.75.1 to 1.77.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.75.1...v1.77.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.77.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>
2026-01-02 19:59:48 -05:00
Luke TaintonandGitHub c94c9fa36a docs(alerting): Remove warning for Splunk alerting provider (#1475)
Remove warning for Splunk alerting provider

Removed warning about untested Splunk alerting provider from README.
2025-12-31 16:46:27 -05:00
PythonGermanyandGitHub 9587e5db8a docs: Update config section and add env var faq (#1450)
* docs: Add faq for environment variables in config giles

* docs: Move config infos to config section

* docs: Extend config env var example

* docs: Remove env var escape from example
2025-12-29 22:07:33 -05:00
2d5f0a5927 feat(alerting): ClickUp alerting provider (#1462)
* feat(alerting): Add ClickUp alerting provider

* test: added ClickUp tests, docs in README and switch http to github.com/TwiN/gatus/v5/client from http.Client

* docs: Update ClickUp API token instructions in README

* fix(alerting): Update ClickUp alert configuration and default values

* docs: fixed formatting and removed line breaks from content in README

* feat(alerting): Add group-specific overrides and validation checks, updated README

* Update alerting/provider/clickup/clickup.go

Co-authored-by: PythonGermany <97847597+PythonGermany@users.noreply.github.com>

* fix(alerting): add priority validation

* fix(alerting): set default priority to 3

* feat(alerting): add priority map for ClickUp provider

* feat(alerting): make notify-all configurable for ClickUp provider

* docs: add instructions for finding Assignee IDs for ClickUp

* refactor(alerting): simplified ClickUp configurations example

* refactor(alerting): clean up new lines  in ClickUp provider

---------

Co-authored-by: PythonGermany <97847597+PythonGermany@users.noreply.github.com>
2025-12-26 19:39:28 -05:00
PythonGermanyandGitHub 6c8761ca35 docs: Add missing alert provider group override options (#1467) 2025-12-22 18:43:47 -05:00
40b1576ec7 docs: Separate web and ui config into sections (#1439)
Co-authored-by: TwiN <twin@linux.com>
2025-12-21 20:59:04 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
64c3b12a7b chore(deps): bump code.gitea.io/sdk/gitea from 0.22.0 to 0.22.1 (#1410)
Bumps code.gitea.io/sdk/gitea from 0.22.0 to 0.22.1.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.22.1
  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-21 20:54:35 -05:00
PythonGermanyandGitHub 138f5bfb76 ci: Add workflow to regenerate static assets (#1457)
* feat(ci): Add workflow to regenerate static assets

* feat(ci): Use command action handle command triger

* fix(ci): Only give success response after commiting

* fix(ci): Only run for pr comments

* refactor: Update command trigger text

* refactor: Explicitly list permission levels allowed

* chore(ci): Allow regenerate command on draft prs
2025-12-21 19:47:51 -05:00
15a8055617 fix(client): Switch websocket library (#1423)
* fix(websocket): switch to gorilla/websocket

* fix(client): add missing t.Parallel() in tests

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-18 18:44:44 -05:00
13184232d1 fix(ui): Inconsistent time values in UI (#1452)
* fix(ui): Truncate displayed time values

* refactor(ui): Use util function

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-18 18:22:40 -05:00
TwiN d0cca91043 test: Update expectedBody for DNS test 2025-12-17 18:33:57 -05:00
PythonGermanyandGitHub 239d1f5118 chore(ui): Remove unnecessary eslint rule disables (#1422)
cleanup(ui): Remove unnecessary eslint rule disables
2025-12-16 16:03:24 -05:00
Glib ShpychkaandGitHub 47bc78dc25 docs: Update Telegram User ID to Chat ID in README (#1434)
fix(docs): Update Telegram User ID to Chat ID in README
2025-12-16 16:00:04 -05:00
PythonGermanyandGitHub 1df0801a61 ci: Add platform input for custom action workflow (#1437) 2025-12-11 16:17:28 -05:00
TwiN d42c5f899e chore(ui): Regenerate static assets 2025-12-10 20:45:06 -05: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
116 changed files with 4239 additions and 1444 deletions
@@ -1,4 +1,3 @@
version: "3.9"
services: services:
gatus: gatus:
container_name: gatus container_name: gatus
@@ -13,7 +12,7 @@ services:
prometheus: prometheus:
container_name: prometheus container_name: prometheus
image: prom/prometheus:v2.14.0 image: prom/prometheus:v3.5.0
restart: always restart: always
command: --config.file=/etc/prometheus/prometheus.yml command: --config.file=/etc/prometheus/prometheus.yml
ports: ports:
@@ -25,7 +24,7 @@ services:
grafana: grafana:
container_name: grafana container_name: grafana
image: grafana/grafana:6.4.4 image: grafana/grafana:12.1.0
restart: always restart: always
environment: environment:
GF_SECURITY_ADMIN_PASSWORD: secret GF_SECURITY_ADMIN_PASSWORD: secret
@@ -16,4 +16,10 @@ endpoints:
url: https://api.github.com/healthz url: https://api.github.com/healthz
interval: 5m interval: 5m
conditions: 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, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 0, "graphTooltip": 0,
"id": 566, "id": 41,
"links": [], "links": [],
"panels": [ "panels": [
{ {
@@ -39,7 +39,8 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green" "color": "green",
"value": 0
}, },
{ {
"color": "yellow", "color": "yellow",
@@ -79,7 +80,7 @@
"textMode": "auto", "textMode": "auto",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "12.0.2", "pluginVersion": "12.1.0",
"targets": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -122,7 +123,8 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "red" "color": "red",
"value": 0
}, },
{ {
"color": "yellow", "color": "yellow",
@@ -162,7 +164,7 @@
"textMode": "auto", "textMode": "auto",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "12.0.2", "pluginVersion": "12.1.0",
"targets": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -193,7 +195,8 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green" "color": "green",
"value": 0
} }
] ]
}, },
@@ -225,7 +228,7 @@
"textMode": "auto", "textMode": "auto",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "12.0.2", "pluginVersion": "12.1.0",
"targets": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -292,7 +295,7 @@
"sort": "none" "sort": "none"
} }
}, },
"pluginVersion": "12.0.2", "pluginVersion": "12.1.0",
"targets": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -321,7 +324,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "$datasource" "uid": "$datasource"
}, },
"description": "SSL certificate expiration times for all services", "description": "Domain expiration times for all domains",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -339,7 +342,8 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "red" "color": "red",
"value": 0
}, },
{ {
"color": "#EAB839", "color": "#EAB839",
@@ -395,7 +399,137 @@
"showHeader": true, "showHeader": true,
"sortBy": [] "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": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -444,6 +578,113 @@
], ],
"type": "table" "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": { "datasource": {
"type": "prometheus", "type": "prometheus",
@@ -483,7 +724,8 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "red" "color": "red",
"value": 0
}, },
{ {
"color": "green", "color": "green",
@@ -525,7 +767,7 @@
"h": 8, "h": 8,
"w": 12, "w": 12,
"x": 12, "x": 12,
"y": 8 "y": 16
}, },
"id": 2, "id": 2,
"options": { "options": {
@@ -546,7 +788,7 @@
} }
] ]
}, },
"pluginVersion": "12.0.2", "pluginVersion": "12.1.0",
"targets": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -689,7 +931,8 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green" "color": "green",
"value": 0
}, },
{ {
"color": "red", "color": "red",
@@ -703,9 +946,9 @@
}, },
"gridPos": { "gridPos": {
"h": 8, "h": 8,
"w": 12, "w": 24,
"x": 0, "x": 0,
"y": 16 "y": 24
}, },
"id": 4, "id": 4,
"options": { "options": {
@@ -721,7 +964,7 @@
"sort": "none" "sort": "none"
} }
}, },
"pluginVersion": "12.0.2", "pluginVersion": "12.1.0",
"targets": [ "targets": [
{ {
"datasource": { "datasource": {
@@ -735,112 +978,6 @@
], ],
"title": "Response Times", "title": "Response Times",
"type": "timeseries" "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, "preload": false,
@@ -855,8 +992,8 @@
"list": [ "list": [
{ {
"current": { "current": {
"text": "myprom", "text": "prometheus",
"value": "PA04845DA3A4B088E" "value": "cedv077q7bbwgd"
}, },
"description": "Select your Prometheus datasource", "description": "Select your Prometheus datasource",
"includeAll": false, "includeAll": false,
@@ -877,6 +1014,6 @@
"timepicker": {}, "timepicker": {},
"timezone": "", "timezone": "",
"title": "Gatus - Service Monitoring Dashboard", "title": "Gatus - Service Monitoring Dashboard",
"uid": "gatus-monitoring2", "uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
"version": 10 "version": 1
} }
@@ -1,4 +1,3 @@
version: "3.9"
services: services:
gatus: gatus:
container_name: gatus container_name: gatus
@@ -1,4 +1,3 @@
version: "3.9"
services: services:
nginx: nginx:
image: nginx:stable image: nginx:stable
@@ -1,4 +1,3 @@
version: "3.8"
services: services:
gatus: gatus:
image: twinproduction/gatus:latest image: twinproduction/gatus:latest
@@ -1,9 +1,10 @@
version: "3.9"
services: services:
postgres: postgres:
image: postgres image: postgres
volumes: volumes:
- ./data/db:/var/lib/postgresql/data # PostgreSQL 18 and above declare the volume at /var/lib/postgresql.
# For 17 and earlier, mount ./data/db at /var/lib/postgresql/data instead.
- ./data/db:/var/lib/postgresql
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
@@ -1,4 +1,3 @@
version: "3.9"
services: services:
gatus: gatus:
image: twinproduction/gatus:latest image: twinproduction/gatus:latest
@@ -1,4 +1,3 @@
version: "3.8"
services: services:
gatus: gatus:
image: twinproduction/gatus:latest image: twinproduction/gatus:latest
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

-15
View File
@@ -1,15 +0,0 @@
ignore:
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
- "watchdog/endpoint.go"
- "watchdog/external_endpoint.go"
- "watchdog/suite.go"
- "watchdog/watchdog.go"
comment: false
coverage:
status:
patch: off
project:
default:
target: 70%
threshold: null
+1
View File
@@ -0,0 +1 @@
See AGENTS.md for agent instructions
+2 -2
View File
@@ -22,9 +22,9 @@ jobs:
steps: steps:
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version: 1.24.4 go-version: 1.26.3
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}" ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Benchmark - name: Benchmark
run: go test -bench=. ./storage/store run: go test -bench=. ./storage/store
+16 -7
View File
@@ -5,35 +5,44 @@ on:
inputs: inputs:
tag: tag:
description: Custom tag to publish description: Custom tag to publish
platforms:
description: Platforms to publish to (comma separated list)
default: linux/amd64
type: choice
options:
- linux/amd64
- linux/arm/v7
- linux/arm64
jobs: jobs:
publish-custom: publish-custom:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Get image repository - name: Get image repository
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.GHCR_IMAGE_REPOSITORY }} images: ${{ env.GHCR_IMAGE_REPOSITORY }}
tags: | tags: |
type=raw,value=${{ inputs.tag }} type=raw,value=${{ inputs.tag }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
platforms: linux/amd64 platforms: ${{ inputs.platforms }}
pull: true pull: true
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+6 -6
View File
@@ -5,27 +5,27 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v4.6.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.IMAGE_REPOSITORY }} images: ${{ env.IMAGE_REPOSITORY }}
tags: | tags: |
type=raw,value=experimental type=raw,value=experimental
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
platforms: linux/amd64 platforms: linux/amd64
pull: true pull: true
+7 -7
View File
@@ -13,29 +13,29 @@ jobs:
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }} if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 240 timeout-minutes: 240
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Get image repository - name: Get image repository
run: | run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v4.6.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKER_IMAGE_REPOSITORY }} ${{ env.DOCKER_IMAGE_REPOSITORY }}
@@ -43,7 +43,7 @@ jobs:
tags: | tags: |
type=raw,value=latest type=raw,value=latest
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
+7 -7
View File
@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 240 timeout-minutes: 240
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Get image repository - name: Get image repository
run: | run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -20,19 +20,19 @@ jobs:
- name: Get the release - name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v4.6.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKER_IMAGE_REPOSITORY }} ${{ env.DOCKER_IMAGE_REPOSITORY }}
@@ -42,7 +42,7 @@ jobs:
type=raw,value=stable type=raw,value=stable
type=raw,value=latest type=raw,value=latest
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
@@ -0,0 +1,110 @@
name: regenerate-static-assets
on:
issue_comment:
types: [created]
jobs:
check-command:
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request }}
permissions:
pull-requests: write # required for adding reactions to command comments on PRs
checks: read # required to check if all ci checks have passed
outputs:
continue: ${{ steps.command.outputs.continue }}
steps:
- name: Check command trigger
id: command
uses: github/command@v2
with:
command: "/regenerate-static-assets"
permissions: "write,admin" # The allowed permission levels to invoke this command
allow_forks: true
allow_drafts: true
skip_ci: true
skip_completing: true
regenerate-static-assets:
runs-on: ubuntu-latest
needs: check-command
if: ${{ needs.check-command.outputs.continue == 'true' }}
permissions:
contents: write
outputs:
status: ${{ steps.commit.outputs.status }}
steps:
- name: Get PR branch
id: pr
uses: actions/github-script@v9
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('ref', pr.data.head.ref);
core.setOutput('repo', pr.data.head.repo.full_name);
- name: Checkout PR branch
uses: actions/checkout@v7
with:
repository: ${{ steps.pr.outputs.repo }}
ref: ${{ steps.pr.outputs.ref }}
- name: Regenerate static assets
run: |
make frontend-install
make frontend-build
- name: Commit and push changes
id: commit
run: |
echo "Checking for changes..."
if git diff --quiet; then
echo "No changes detected."
echo "status=no_changes" >> $GITHUB_OUTPUT
exit 0
fi
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
echo "Changes detected. Committing and pushing..."
git add .
git commit -m "chore(ui): Regenerate static assets"
git push origin ${{ steps.pr.outputs.ref }}
echo "status=success" >> $GITHUB_OUTPUT
create-response-comment:
runs-on: ubuntu-latest
needs: [check-command, regenerate-static-assets]
if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}
permissions:
pull-requests: write
steps:
- name: Create response comment
uses: actions/github-script@v9
with:
script: |
const status = '${{ needs.regenerate-static-assets.outputs.status }}';
let reaction = 'hooray';
let message = '';
var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
if (status === 'no_changes') {
message = `@${context.actor} No changes to commit ([ref](${workflowUrl})).`;
} else if (status === 'success') {
// Assets regenerated successfully - no comment needed, just add reaction
} else {
reaction = '-1';
message = `@${context.actor} There was an issue regenerating static assets. Please check the [workflow run logs](${workflowUrl}) for more details.`;
}
if (message.length) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: reaction
});
+2 -2
View File
@@ -13,6 +13,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- run: make frontend-install-dependencies - run: make frontend-install
- run: make frontend-build - run: make frontend-build
+3 -8
View File
@@ -18,17 +18,12 @@ jobs:
steps: steps:
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version: 1.24.4 go-version: 1.26.3
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build run: go build
- name: Test - name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges. # We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that # As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable) # 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 run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race
- name: Codecov
uses: codecov/codecov-action@v5.5.1
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
+84
View File
@@ -0,0 +1,84 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
## Commands
- `make install` — Build the `gatus` binary
- `make run` — Run in dev mode (uses `./config.yaml`)
- `make test` — Run all Go tests with coverage
- `make clean` — Remove built binary
- `make frontend-install` — Install frontend npm dependencies
- `make frontend-build` — Build the Vue.js frontend (**must run after any `web/` changes**)
- `make frontend-dev` — Start the Vue dev server
## Key Rules
- **Vendored dependencies**: After adding/updating a Go dependency, run `go mod tidy && go mod vendor`
- **Frontend build artifacts**: `web/static/` is generated by `make frontend-build` and embedded into the Go binary at compile time via `//go:embed`. Never edit files in `web/static/` directly
- **Config validation order**: In `config/config.go` `parseAndValidateConfigBytes`, `ValidateAlertingConfig` **must** run before `ValidateEndpointsConfig` because provider default alerts must be parsed before endpoint defaults are set
- **Invalid configs panic at startup** — validation errors are fatal, not graceful
- **Hot-reload**: `main.go` watches the config file and re-runs the full stop → save → load → init → start cycle. New startup logic must account for this
## Environment Variables
- `GATUS_CONFIG_PATH` — Config file path (default: `config/config.yaml`); can be a directory of YAML files (deep-merged)
- `GATUS_LOG_LEVEL``DEBUG`, `INFO`, `WARN`, `ERROR`
- `ENVIRONMENT=dev` — Enables CORS for frontend dev on `localhost:8081`
## Project Structure
- `main.go` — Entry point; loads config, initializes storage, starts watchdog + API server
- `config/` — Central config struct (`config.go`) and sub-packages per config section (each with `ValidateAndSetDefaults()`)
- `alerting/` — Alerting config (`config.go`), alert types (`alert/type.go`), and one sub-package per provider under `provider/`
- `api/` — Fiber-based HTTP API routes and SPA serving
- `watchdog/` — Monitoring loop; spawns a goroutine per endpoint with semaphore-based concurrency control
- `client/` — HTTP/gRPC client used for health checks
- `storage/store/` — Storage abstraction with `memory/`, `sql/` (SQLite + PostgreSQL) implementations
- `security/` — Authentication (basic + OIDC) and security middleware
- `web/app/` — Vue 3 frontend source; builds to `web/static/` (embedded into the Go binary)
- `vendor/` — Vendored Go dependencies (committed to repo)
## Common Changes
### Adding an Alerting Provider
Use `alerting/provider/slack/` as the reference implementation. Every new provider touches **6 locations**:
1. **Create provider package**`alerting/provider/{name}/{name}.go` + `{name}_test.go`
- `Config` struct with `Validate()` and `Merge(*Config)` methods
- `AlertProvider` struct with `DefaultConfig` (yaml inline), `DefaultAlert *alert.Alert`, `Overrides []Override`
- Use `client.GetHTTPClient(nil)` (not `http.DefaultClient`), always `defer response.Body.Close()`
2. **Register type** — Add `Type{Name} Type = "{name}"` in `alerting/alert/type.go`
3. **Add to config struct** — Add field to `alerting.Config` in `alerting/config.go`; the **YAML tag must exactly match** the `alert.Type` string (reflection-based lookup via `GetAlertingProviderByAlertType`)
4. **Add compile-time checks** — Both `_ AlertProvider = (*pkg.AlertProvider)(nil)` **and** `_ Config[pkg.Config] = (*pkg.Config)(nil)` in `alerting/provider/provider.go`
5. **Register in validation** — Add the type to the `alertTypes` slice in `config/config.go` `ValidateAlertingConfig`
6. **Document** — Add to README.md in alphabetical order
### Adding a Config Section
1. Create package in `config/` with struct + `ValidateAndSetDefaults()` method
2. Add field to `Config` struct in `config/config.go`
3. Add `Validate*` call in `parseAndValidateConfigBytes`**respect ordering constraints** (see comments in that function)
## Frontend
- Vue 3 Composition API (`<script setup>`) + Tailwind CSS
- Props-based data flow — **do not use provide/inject**
- All components must include `dark:` prefixed Tailwind classes for dark mode
- `window.config` contains server-injected UI settings (Go template placeholders)
## API Routes (`api/api.go`)
- Unprotected routes are registered **before** `ApplySecurityMiddleware`
- Protected routes are registered **after** — placement order matters
## Testing
- Config structs should have `*_test.go` files
- Use `t.Parallel()` where appropriate
- API tests spin up test servers (see existing `*_test.go` in `api/`)
## Commits & PRs
When creating a commit or PR as an agent, state that it was made by an agent and include your model name and version.
+1 -1
View File
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
RUN apk --update add ca-certificates RUN apk --update add ca-certificates
WORKDIR /app WORKDIR /app
COPY . ./ COPY . ./
RUN go mod tidy RUN go mod tidy -diff
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus . 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 # Run Tests inside docker image if you don't have a configured go environment
+8 -2
View File
@@ -25,12 +25,15 @@ test:
# Docker # # Docker #
########## ##########
.PHONY: docker-build
docker-build: docker-build:
docker build -t twinproduction/gatus:latest . docker build -t twinproduction/gatus:latest .
.PHONY: docker-run
docker-run: docker-run:
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
.PHONY: docker-build-and-run
docker-build-and-run: docker-build docker-run docker-build-and-run: docker-build docker-run
@@ -38,11 +41,14 @@ docker-build-and-run: docker-build docker-run
# Front end # # Front end #
############# #############
frontend-install-dependencies: .PHONY: frontend-install
frontend-install:
npm --prefix web/app install npm --prefix web/app install
.PHONY: frontend-build
frontend-build: frontend-build:
npm --prefix web/app run build npm --prefix web/app run build
frontend-run: .PHONY: frontend-dev
frontend-dev:
npm --prefix web/app run serve npm --prefix web/app run serve
+366 -125
View File
@@ -2,7 +2,6 @@
![test](https://github.com/TwiN/gatus/actions/workflows/test.yml/badge.svg) ![test](https://github.com/TwiN/gatus/actions/workflows/test.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus) [![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus)
[![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus) [![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
@@ -50,11 +49,15 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Conditions](#conditions) - [Conditions](#conditions)
- [Placeholders](#placeholders) - [Placeholders](#placeholders)
- [Functions](#functions) - [Functions](#functions)
- [Web](#web)
- [UI](#ui)
- [Announcements](#announcements)
- [Storage](#storage) - [Storage](#storage)
- [Client configuration](#client-configuration) - [Client configuration](#client-configuration)
- [Tunneling](#tunneling) - [Tunneling](#tunneling)
- [Alerting](#alerting) - [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring ClickUp alerts](#configuring-clickup-alerts)
- [Configuring Datadog alerts](#configuring-datadog-alerts) - [Configuring Datadog alerts](#configuring-datadog-alerts)
- [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts) - [Configuring Email alerts](#configuring-email-alerts)
@@ -95,7 +98,6 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Zulip alerts](#configuring-zulip-alerts) - [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts) - [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert) - [Setting a default alert](#setting-a-default-alert)
- [Announcements](#announcements)
- [Maintenance](#maintenance) - [Maintenance](#maintenance)
- [Security](#security) - [Security](#security)
- [Basic Authentication](#basic-authentication) - [Basic Authentication](#basic-authentication)
@@ -109,6 +111,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Docker](#docker) - [Docker](#docker)
- [Helm Chart](#helm-chart) - [Helm Chart](#helm-chart)
- [Terraform](#terraform) - [Terraform](#terraform)
- [Kubernetes](#kubernetes)
- [Running the tests](#running-the-tests) - [Running the tests](#running-the-tests)
- [Using in Production](#using-in-production) - [Using in Production](#using-in-production)
- [FAQ](#faq) - [FAQ](#faq)
@@ -119,6 +122,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint) - [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint) - [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-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 ICMP](#monitoring-an-endpoint-using-icmp)
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries) - [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh) - [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
@@ -131,6 +135,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [How do I sort by group by default?](#how-do-i-sort-by-group-by-default) - [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path) - [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port) - [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Use environment variables in config files](#use-environment-variables-in-config-files)
- [Configuring a startup delay](#configuring-a-startup-delay) - [Configuring a startup delay](#configuring-a-startup-delay)
- [Keeping your configuration small](#keeping-your-configuration-small) - [Keeping your configuration small](#keeping-your-configuration-small)
- [Proxy client configuration](#proxy-client-configuration) - [Proxy client configuration](#proxy-client-configuration)
@@ -219,6 +224,9 @@ This example would look similar to this:
![Simple example](.github/assets/example.jpg) ![Simple example](.github/assets/example.jpg)
If you want to test it locally, see [Docker](#docker).
## Configuration
By default, the configuration file is expected to be at `config/config.yaml`. By default, the configuration file is expected to be at `config/config.yaml`.
You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable. You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.
@@ -232,46 +240,29 @@ subdirectories are merged like so:
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`) > 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
> >
> See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example. > ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
> >
> When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`. > See [Use environment variables in config files](#use-environment-variables-in-config-files) or [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for examples.
If you want to test it locally, see [Docker](#docker). If you want to test it locally, see [Docker](#docker).
## Configuration ## Configuration
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------| |:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` | | `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` | | `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` | | `alerting` | [Alerting configuration](#alerting). | `{}` |
| `announcements` | [Announcements configuration](#announcements). | `[]` | | `announcements` | [Announcements configuration](#announcements). | `[]` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` | | `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` | | `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` | | `security` | [Security configuration](#security). | `{}` |
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` | | `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` | | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | | `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` | | `web` | [Web configuration](#web). | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` | | `ui` | [UI configuration](#ui). | `{}` |
| `web.port` | Port to listen on. | `8080` | | `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`. If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`. Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
@@ -309,7 +300,9 @@ 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-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-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-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.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.resolve-successful-conditions` | Whether to resolve successful conditions for the UI (helpful to expose body assertions even when checks pass). | `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[].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. | `{}` | | `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
| `endpoints[].always-run` | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` | | `endpoints[].always-run` | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` |
@@ -369,14 +362,14 @@ 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`. - 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. - `{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. - `{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). - `{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. You must also pass the token as a `Bearer` token in the `Authorization` header.
### Suites (ALPHA) ### 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. 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: Here are a few cases in which suites could be useful:
@@ -418,7 +411,7 @@ suites:
context: context:
price: "19.99" # Initial static value in context price: "19.99" # Initial static value in context
endpoints: endpoints:
# Step 1: Create an item and store the item ID # Step 1: Create an item and store the item ID
- name: create-item - name: create-item
url: https://api.example.com/items url: https://api.example.com/items
method: POST method: POST
@@ -432,7 +425,7 @@ suites:
alerts: alerts:
- type: slack - type: slack
description: "Failed to create item" description: "Failed to create item"
# Step 2: Update the item using the stored item ID # Step 2: Update the item using the stored item ID
- name: update-item - name: update-item
url: https://api.example.com/items/[CONTEXT].itemId url: https://api.example.com/items/[CONTEXT].itemId
@@ -443,7 +436,7 @@ suites:
alerts: alerts:
- type: slack - type: slack
description: "Failed to update item" description: "Failed to update item"
# Step 3: Fetch the item and validate the price # Step 3: Fetch the item and validate the price
- name: get-item - name: get-item
url: https://api.example.com/items/[CONTEXT].itemId url: https://api.example.com/items/[CONTEXT].itemId
@@ -454,7 +447,7 @@ suites:
alerts: alerts:
- type: slack - type: slack
description: "Item price did not update correctly" 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) # Step 4: Delete the item (always-run: true to ensure cleanup even if step 2 or 3 fails)
- name: delete-item - name: delete-item
url: https://api.example.com/items/[CONTEXT].itemId url: https://api.example.com/items/[CONTEXT].itemId
@@ -520,20 +513,59 @@ Here are some examples of conditions you can use:
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`. > 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Web
Allows you to configure how and where the dashboard is being served.
| Parameter | Description | Default |
|:---------------------------|:--------------------------------------------------------------------------------------------|:----------|
| `web` | Web configuration | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
### UI
Allows you to configure the application wide defaults for the dashboard's UI. Some of these parameters can be overridden locally by users using the local storage of their browser.
| Parameter | Description | Default |
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|
| `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. Also used as the title on the OIDC login page. | `Gatus` |
| `ui.logo` | URL to the logo to display. When set, shown alongside the Gatus logo on the OIDC login page. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.favicon.default` | Favourite default icon to display in web browser tab or address bar. | `/favicon.ico` |
| `ui.favicon.size16x16` | Favourite icon to display in web browser for 16x16 size. | `/favicon-16x16.png` |
| `ui.favicon.size32x32` | Favourite icon to display in web browser for 32x32 size. | `/favicon-32x32.png` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
| `ui.login-subtitle` | Subtitle displayed on the OIDC login page. | `System Monitoring Dashboard` |
### Announcements ### 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 | This is essentially what some status page calls "incident communications".
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
| `announcements` | List of announcements to display | `[]` | | Parameter | Description | Default |
| `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` | List of announcements to display | `[]` |
| `announcements[].message` | The message to display to users | Required | | `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: Types:
- **outage**: Indicates service disruptions or critical issues (red theme) - **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) - **information**: General information or updates (blue theme)
- **operational**: Indicates resolved issues or normal operations (green theme) - **operational**: Indicates resolved issues or normal operations (green theme)
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified) - **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
@@ -541,17 +573,24 @@ Types:
Example Configuration: Example Configuration:
```yaml ```yaml
announcements: announcements:
- timestamp: 2025-08-15T14:00:00Z - timestamp: 2025-11-07T14:00:00Z
type: outage type: outage
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC" message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
- timestamp: 2025-08-15T16:15:00Z - timestamp: 2025-11-07T16:15:00Z
type: operational type: operational
message: "Database maintenance completed successfully. All systems operational." message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-08-15T12:00:00Z - timestamp: 2025-11-07T12:00:00Z
type: information type: information
message: "New monitoring dashboard features will be deployed next week" 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 ### Storage
| Parameter | Description | Default | | Parameter | Description | Default |
@@ -615,6 +654,7 @@ the client used to send the request.
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` | | `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` | | `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
| `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` | | `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
| `client.store-cookies` | Whether to store cookies between requests. | `false` |
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved > 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
@@ -627,6 +667,7 @@ client:
insecure: false insecure: false
ignore-redirect: false ignore-redirect: false
timeout: 10s timeout: 10s
store-cookies: false
``` ```
Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`. Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.
@@ -706,7 +747,7 @@ endpoints:
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container. > 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
### Tunneling ### 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. 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. SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
@@ -743,7 +784,7 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
``` ```
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently. > ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
> This may lead to inaccurate response time measurements. > This may lead to inaccurate response time measurements.
@@ -797,6 +838,7 @@ endpoints:
| Parameter | Description | Default | | Parameter | Description | Default |
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------| |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` | | `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
| `alerting.clickup` | Configuration for alerts of type `clickup`. <br />See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.datadog` | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` | | `alerting.datadog` | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | | `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
@@ -848,6 +890,9 @@ endpoints:
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` | | `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` | | `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.aws-ses.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.aws-ses.overrides[].*` | See `alerting.aws-ses.*` parameters | `{}` |
```yaml ```yaml
alerting: alerting:
@@ -878,6 +923,72 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b
Make sure you have the ability to use `ses:SendEmail`. Make sure you have the ability to use `ses:SendEmail`.
#### Configuring ClickUp alerts
| Parameter | Description | Default |
| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ |
| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` |
| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` |
| `alerting.clickup.token` | ClickUp API token | Required `""` |
| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` |
| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` |
| `alerting.clickup.status` | Initial status for created tasks | `""` |
| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` |
| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` |
| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` |
| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` |
| `alerting.clickup.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` |
The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved.
The following placeholders are supported in `name` and `content`:
- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group`
- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name`
- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description`
- `[RESULT_ERRORS]` - Resolved from the health evaluation errors
```yaml
alerting:
clickup:
list-id: "123456789"
token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
assignees:
- "12345"
- "67890"
status: "in progress"
priority: high
name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]"
content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: clickup
send-on-resolved: true
```
To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to)
To find your List ID:
1. Open the ClickUp list where you want tasks to be created
2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}`
To find Assignee IDs:
1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people`
2. Hover over a team member
3. Click the 3 dots (overflow menu)
3. Click `Copy member ID`
#### Configuring Datadog alerts #### Configuring Datadog 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. > ⚠️ **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.
@@ -1193,6 +1304,10 @@ Here's an example of what the notifications look like:
#### Configuring HomeAssistant alerts #### Configuring HomeAssistant alerts
> [!NOTE]
> It is recommended to use the native Home Assistant integration instead (see below).
| Parameter | Description | Default Value | | Parameter | Description | Default Value |
|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------| |:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------|
| `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` | | `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` |
@@ -1267,6 +1382,61 @@ To get your HomeAssistant long-lived access token:
5. Give it a name (e.g., "Gatus") 5. Give it a name (e.g., "Gatus")
6. Copy the token - you'll only see it once! 6. Copy the token - you'll only see it once!
##### Native Home Assistant Integration
Gatus can be integrated into [Home Assistant](https://www.home-assistant.io/) to monitor the status of your endpoints directly from your home automation dashboard.
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=gatus)
<details>
<summary>Configuration & Automation Examples</summary>
###### Configuration
To add the integration, click the button above or:
1. In Home Assistant, navigate to **Settings** > **Devices & Services**.
2. Click **Add Integration** in the bottom-right corner.
3. Search for **Gatus** and select it.
4. Enter the base URL of your Gatus instance (e.g., `http://192.168.1.50:8080`).
Home Assistant will create a binary sensor for each endpoint configured in Gatus. Because these sensors use the `connectivity` device class, they will show as `Connected` (Up) when the endpoint is healthy, and `Disconnected` (Down) when the endpoint is unhealthy.
###### Automation Example
You can easily set up a UI automation in Home Assistant to receive mobile notifications if any endpoint goes down:
1. Navigate to **Settings** > **Automations & Scenes** > **Create Automation** > **Create new automation**.
2. Under **Triggers**, click **Add Trigger** and select **State**.
3. In the **Entity** field, select the Gatus binary sensor(s) you want to monitor.
4. Set the **To** field to `off` (since a state of `off` represents a disconnected/down endpoint).
5. Under **Actions**, click **Add Action** and select **Perform action**.
6. Select your notification service (e.g., `Send a notification via mobile_app_<your_device_name>`).
7. Set the **Message** field. You can use template values to dynamically include the endpoint name:
```yaml
message: "The endpoint {{ trigger.to_state.name }} is down!"
```
For advanced users, here is the YAML representation of the automation:
```yaml
alias: "Notify when Gatus endpoint goes down"
description: "Sends a mobile notification if a Gatus endpoint goes down"
trigger:
- platform: state
entity_id:
- binary_sensor.website
- binary_sensor.make_sure_header_is_rendered
to: "off"
action:
- action: notify.mobile_app_your_device_name
data:
title: "Gatus Alert"
message: "The endpoint {{ trigger.to_state.name }} is down!"
```
</details>
#### Configuring IFTTT alerts #### Configuring IFTTT alerts
@@ -1300,7 +1470,7 @@ endpoints:
``` ```
#### Configuring ilert alerts #### Configuring Ilert alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------| |:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` | | `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
@@ -1852,6 +2022,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 #### 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. > ⚠️ **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.
@@ -1918,40 +2122,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 #### Configuring Slack alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------| |:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
@@ -1993,8 +2163,6 @@ Here's an example of what the notifications look like:
#### Configuring Splunk alerts #### Configuring Splunk 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 | | Parameter | Description | Default |
|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------| |:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` | | `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
@@ -2178,8 +2346,8 @@ Here's an example of what the notifications look like:
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------| |:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` | | `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` | | `alerting.telegram.id` | Telegram Chat 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.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `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 | | `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
@@ -2390,15 +2558,18 @@ endpoints:
#### Configuring custom alerts #### Configuring custom alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
| `alerting.custom.url` | Custom alerting request url | Required `""` | | `alerting.custom.url` | Custom alerting request url | Required `""` |
| `alerting.custom.method` | Request method | `GET` | | `alerting.custom.method` | Request method | `GET` |
| `alerting.custom.body` | Custom alerting request body. | `""` | | `alerting.custom.body` | Custom alerting request body. | `""` |
| `alerting.custom.headers` | Custom alerting request headers | `{}` | | `alerting.custom.headers` | Custom alerting request headers | `{}` |
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.custom.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.custom.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.custom.overrides[].*` | See `alerting.custom.*` parameters | `{}` |
While they're called alerts, you can use this feature to call anything. While they're called alerts, you can use this feature to call anything.
@@ -2675,6 +2846,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_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_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_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 | | 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. See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
@@ -2730,12 +2902,13 @@ This is an experimental feature. It may be removed or updated in a breaking mann
there are known issues with this feature. If you'd like to provide some feedback, please write a comment in [#64](https://github.com/TwiN/gatus/issues/64). there are known issues with this feature. If you'd like to provide some feedback, please write a comment in [#64](https://github.com/TwiN/gatus/issues/64).
Use at your own risk. Use at your own risk.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------|:---------------------------------------------|:--------------| |:-----------------------------------|:-----------------------------------------------|:--------------|
| `remote` | Remote configuration | `{}` | | `remote` | Remote configuration | `{}` |
| `remote.instances` | List of remote instances | Required `[]` | | `remote.instances` | List of remote instances | Required `[]` |
| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` | | `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` |
| `remote.instances.url` | URL from which to retrieve endpoint statuses | Required `""` | | `remote.instances.url` | URL from which to retrieve endpoint statuses | Required `""` |
| `remote.client` | [Client configuration](#client-configuration). | `{}` |
```yaml ```yaml
remote: remote:
@@ -2789,7 +2962,10 @@ To get more details, please check [chart's configuration](https://github.com/Twi
### Terraform ### 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 ## Running the tests
@@ -2834,7 +3010,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
### Recommended interval ### 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. 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. In other words, even if you have multiple endpoints with the same interval, they are not guaranteed to run at the same time.
@@ -2942,8 +3118,47 @@ endpoints:
``` ```
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]` The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
shows whether the connection was successfully established. You can use Go template shows whether the connection was successfully established. You can use Go template
syntax. 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 ### Monitoring an endpoint using ICMP
@@ -2960,9 +3175,12 @@ endpoints:
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP. 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://`. 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. 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 ### Monitoring an endpoint using DNS queries
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS: Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
@@ -2988,7 +3206,8 @@ 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://`: You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
```yaml ```yaml
endpoints: endpoints:
- name: ssh-example # Password-based SSH example
- name: ssh-example-password
url: "ssh://example.com:22" # port is optional. Default is 22. url: "ssh://example.com:22" # port is optional. Default is 22.
ssh: ssh:
username: "username" username: "username"
@@ -3002,10 +3221,24 @@ endpoints:
- "[CONNECTED] == true" - "[CONNECTED] == true"
- "[STATUS] == 0" - "[STATUS] == 0"
- "[BODY].memory.used > 500" - "[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 you can also use no authentication to monitor the endpoint by not specifying the username,
and password fields. password and private key fields.
```yaml ```yaml
endpoints: endpoints:
@@ -3014,6 +3247,7 @@ endpoints:
ssh: ssh:
username: "" username: ""
password: "" password: ""
private-key: ""
interval: 1m interval: 1m
conditions: conditions:
@@ -3078,14 +3312,14 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 240h" - "[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`). > [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 > 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`. > using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
### Concurrency ### Concurrency
By default, Gatus allows up to 5 endpoints/suites to be monitored concurrently. This provides a balance between performance and resource usage while maintaining accurate response time measurements. By default, Gatus allows up to 3 endpoints/suites to be monitored concurrently. This provides a balance between performance and resource usage while maintaining accurate response time measurements.
You can configure the concurrency level using the `concurrency` parameter: You can configure the concurrency level using the `concurrency` parameter:
@@ -3107,7 +3341,7 @@ concurrency: 0
**Use cases for higher concurrency:** **Use cases for higher concurrency:**
- You have a large number of endpoints to monitor - 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 - You're using Gatus for load testing scenarios
**Legacy configuration:** **Legacy configuration:**
@@ -3191,7 +3425,7 @@ ui:
default-sort-by: group 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 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 ### Exposing Gatus on a custom path
@@ -3208,12 +3442,19 @@ web:
``` ```
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
variable instead, you can use that environment variable directly in the configuration file: variable instead see [Use environment variables in config files](#use-environment-variables-in-config-files).
### Use environment variables in config files
You can use environment variables directly in the configuration file which will be substituted from the environment:
```yaml ```yaml
web: web:
port: ${PORT} port: ${PORT}
```
ui:
title: $TITLE
```
⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
### Configuring a startup delay ### Configuring a startup delay
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup. If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.
+3
View File
@@ -8,6 +8,9 @@ const (
// TypeAWSSES is the Type for the awsses alerting provider // TypeAWSSES is the Type for the awsses alerting provider
TypeAWSSES Type = "aws-ses" TypeAWSSES Type = "aws-ses"
// TypeClickUp is the Type for the clickup alerting provider
TypeClickUp Type = "clickup"
// TypeCustom is the Type for the custom alerting provider // TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom" TypeCustom Type = "custom"
+4
View File
@@ -7,6 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -54,6 +55,9 @@ type Config struct {
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider // AWSSimpleEmailService is the configuration for the aws-ses alerting provider
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"` AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
// ClickUp is the configuration for the clickup alerting provider
ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"`
// Custom is the configuration for the custom alerting provider // Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"` Custom *custom.AlertProvider `yaml:"custom,omitempty"`
+285
View File
@@ -0,0 +1,285 @@
package clickup
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrListIDNotSet = errors.New("list-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
)
var priorityMap = map[string]int{
"urgent": 1,
"high": 2,
"normal": 3,
"low": 4,
"none": 0,
}
type Config struct {
APIURL string `yaml:"api-url"`
ListID string `yaml:"list-id"`
Token string `yaml:"token"`
Assignees []string `yaml:"assignees"`
Status string `yaml:"status"`
Priority string `yaml:"priority"`
NotifyAll *bool `yaml:"notify-all,omitempty"`
Name string `yaml:"name,omitempty"`
MarkdownContent string `yaml:"content,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.ListID == "" {
return ErrListIDNotSet
}
if cfg.Token == "" {
return ErrTokenNotSet
}
if cfg.Priority == "" {
cfg.Priority = "normal"
}
if _, ok := priorityMap[cfg.Priority]; !ok {
return ErrInvalidPriority
}
if cfg.NotifyAll == nil {
defaultNotifyAll := true
cfg.NotifyAll = &defaultNotifyAll
}
if cfg.APIURL == "" {
cfg.APIURL = "https://api.clickup.com/api/v2"
}
if cfg.Name == "" {
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
}
if cfg.MarkdownContent == "" {
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.APIURL != "" {
cfg.APIURL = override.APIURL
}
if override.ListID != "" {
cfg.ListID = override.ListID
}
if override.Token != "" {
cfg.Token = override.Token
}
if override.Status != "" {
cfg.Status = override.Status
}
if override.Priority != "" {
cfg.Priority = override.Priority
}
if override.NotifyAll != nil {
cfg.NotifyAll = override.NotifyAll
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
if override.Name != "" {
cfg.Name = override.Name
}
if override.MarkdownContent != "" {
cfg.MarkdownContent = override.MarkdownContent
}
}
// AlertProvider is the configuration necessary for sending an alert using ClickUp
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default configuration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
if resolved {
return provider.CloseTask(cfg, ep)
}
// Replace placeholders
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
body := map[string]interface{}{
"name": name,
"markdown_content": markdownContent,
"assignees": cfg.Assignees,
"status": cfg.Status,
"notify_all": *cfg.NotifyAll,
}
if cfg.Priority != "none" {
body["priority"] = priorityMap[cfg.Priority]
}
return provider.CreateTask(cfg, body)
}
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
}
var fetchResponse struct {
Tasks []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
return err
}
var matchingTaskIDs []string
for _, task := range fetchResponse.Tasks {
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
matchingTaskIDs = append(matchingTaskIDs, task.ID)
}
}
if len(matchingTaskIDs) == 0 {
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
}
for _, taskID := range matchingTaskIDs {
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
return fmt.Errorf("failed to close task %s: %v", taskID, err)
}
}
return nil
}
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
body := map[string]interface{}{"status": status}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+310
View File
@@ -0,0 +1,310 @@
package clickup
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
if err := invalidProviderNoListID.Validate(); err == nil {
t.Error("provider shouldn't have been valid without list-id")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid without token")
}
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
if err := invalidProviderBadPriority.Validate(); err == nil {
t.Error("provider shouldn't have been valid with invalid priority")
}
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if validProvider.DefaultConfig.Priority != "normal" {
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
}
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
if err := validProviderWithAPIURL.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
if err := validProviderWithPriority.Validate(); err != nil {
t.Error("provider should've been valid with priority 'urgent'")
}
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
if err := validProviderWithNone.Validate(); err != nil {
t.Error("provider should've been valid with priority 'none'")
}
}
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := provider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{
{
"id": "task-123",
"name": "Health Check: endpoint-group:endpoint-name",
},
},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
if r.Method == "PUT" {
// Mock update task status response
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-no-matching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response with no matching tasks
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved-error-fetching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
Errors: []string{"error1", "error2"},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
},
{
Name: "provider-with-alert-override-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"list-id": "override-list-id",
"token": "override-token",
}},
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
},
{
Name: "provider-with-partial-alert-override-should-merge",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"status": "closed",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
},
{
Name: "provider-with-assignees-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"assignees": []string{"user1", "user2"},
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
},
{
Name: "provider-with-priority-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "urgent",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
},
{
Name: "provider-with-none-priority",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "none",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
Overrides: []Override{
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
},
},
InputGroup: "core",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ListID != scenario.ExpectedOutput.ListID {
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Status != scenario.ExpectedOutput.Status {
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
// 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)
}
})
}
}
+2 -2
View File
@@ -143,10 +143,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName()) subject = fmt.Sprintf("%s: Alert resolved", ep.DisplayName())
message = 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 { } else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName()) subject = fmt.Sprintf("%s: Alert triggered", ep.DisplayName())
message = 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)
} }
var formattedConditionResults string var formattedConditionResults string
+5 -5
View File
@@ -86,7 +86,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"}, Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered", ExpectedSubject: "endpoint-name: Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
}, },
{ {
@@ -95,7 +95,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"}, Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved", ExpectedSubject: "endpoint-name: Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n", ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
}, },
{ {
@@ -104,7 +104,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}}, Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered", ExpectedSubject: "endpoint-name: Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
}, },
{ {
@@ -113,7 +113,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}}, Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved", ExpectedSubject: "endpoint-name: Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n", ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
}, },
{ {
@@ -122,7 +122,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}}, Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered", ExpectedSubject: "endpoint-name: Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
}, },
} }
+17 -4
View File
@@ -12,6 +12,19 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
// isIgnorableTestError checks if an error is expected during testing when making API calls with dummy credentials
func isIgnorableTestError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "user does not exist") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "invalid username, password or token") ||
strings.Contains(errStr, "unknown API error") ||
strings.Contains(errStr, "dial tcp")
}
func TestAlertProvider_Validate(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
@@ -50,7 +63,7 @@ func TestAlertProvider_Validate(t *testing.T) {
if scenario.ExpectedError && err == nil { if scenario.ExpectedError && err == nil {
t.Error("expected error, got none") t.Error("expected error, got none")
} }
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") { if !scenario.ExpectedError && err != nil && !isIgnorableTestError(err) {
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
}) })
@@ -87,7 +100,7 @@ func TestAlertProvider_Send(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert) cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") { if err != nil && !isIgnorableTestError(err) {
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com") cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
@@ -203,7 +216,7 @@ func TestAlertProvider_GetConfig(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert) got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") { if err != nil && !isIgnorableTestError(err) {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL { if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
@@ -221,7 +234,7 @@ func TestAlertProvider_GetConfig(t *testing.T) {
} }
} }
// Test ValidateOverrides as well, since it really just calls GetConfig // Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") { if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !isIgnorableTestError(err) {
t.Errorf("unexpected error: %s", err) t.Errorf("unexpected error: %s", err)
} }
}) })
+2 -3
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -170,9 +171,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present) // Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
mergedMetadata := map[string]interface{}{} mergedMetadata := map[string]interface{}{}
// Copy cfg.Metadata // Copy cfg.Metadata
for k, v := range cfg.Metadata { maps.Copy(mergedMetadata, cfg.Metadata)
mergedMetadata[k] = v
}
// Add extra labels from endpoint (if present) // Add extra labels from endpoint (if present)
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 { if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
for k, v := range ep.ExtraLabels { for k, v := range ep.ExtraLabels {
+3
View File
@@ -3,6 +3,7 @@ package provider
import ( import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -92,6 +93,7 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl
var ( var (
// Validate provider interface implementation on compile // Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil) _ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*clickup.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*datadog.AlertProvider)(nil) _ AlertProvider = (*datadog.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
@@ -133,6 +135,7 @@ var (
// Validate config interface implementation on compile // Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil) _ Config[awsses.Config] = (*awsses.Config)(nil)
_ Config[clickup.Config] = (*clickup.Config)(nil)
_ Config[custom.Config] = (*custom.Config)(nil) _ Config[custom.Config] = (*custom.Config)(nil)
_ Config[datadog.Config] = (*datadog.Config)(nil) _ Config[datadog.Config] = (*datadog.Config)(nil)
_ Config[discord.Config] = (*discord.Config)(nil) _ Config[discord.Config] = (*discord.Config)(nil)
+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", ResponseTimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg)) 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/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 // This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg)) unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA // SPA
+1 -1
View File
@@ -299,7 +299,7 @@ func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Con
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
} }
// the threshold config requires 5 values, so we can be sure it's set here // the threshold config requires 5 values, so we can be sure it's set here
for i := 0; i < 5; i++ { for i := range 5 {
if responseTime <= thresholds[i] { if responseTime <= thresholds[i] {
return badgeColors[i] return badgeColors[i]
} }
+60
View File
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
} }
return nil 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)
}
})
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/security"
@@ -27,7 +28,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
t.Error("expected err to be nil, but was", err) t.Error("expected err to be nil, but was", err)
} }
// Test the config handler // Test the config handler
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody) request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
response, err := app.Test(request) response, err := app.Test(request)
if err != nil { if err != nil {
t.Error("expected err to be nil, but was", err) t.Error("expected err to be nil, but was", err)
+10 -1
View File
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return c.Status(500).SendString(err.Error()) 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) 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 // Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() { if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting) watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} else {
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
} }
if cfg.Metrics { if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels) metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
+9 -2
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/url"
"time" "time"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
default: default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") 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()) uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
default: default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") 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()) responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
+76 -34
View File
@@ -13,6 +13,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/smtp" "net/smtp"
"os"
"runtime" "runtime"
"strings" "strings"
"time" "time"
@@ -20,13 +21,13 @@ import (
"github.com/TwiN/gocache/v2" "github.com/TwiN/gocache/v2"
"github.com/TwiN/logr" "github.com/TwiN/logr"
"github.com/TwiN/whois" "github.com/TwiN/whois"
"github.com/gorilla/websocket"
"github.com/ishidawataru/sctp" "github.com/ishidawataru/sctp"
"github.com/miekg/dns" "github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing" ping "github.com/prometheus-community/pro-bing"
"github.com/registrobr/rdap" "github.com/registrobr/rdap"
"github.com/registrobr/rdap/protocol" "github.com/registrobr/rdap/protocol"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
) )
const ( const (
@@ -247,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 // CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
// using the SSH protocol. // 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 var port string
if strings.Contains(address, ":") { if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":") addressAndPort := strings.Split(address, ":")
@@ -259,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
} else { } else {
port = "22" 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(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username, User: username,
Auth: []ssh.AuthMethod{ Auth: authMethods,
ssh.Password(password), Timeout: config.Timeout,
},
Timeout: config.Timeout,
}) })
if err != nil { if err != nil {
return false, nil, err return false, nil, err
@@ -343,12 +356,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
pinger := ping.New(address) pinger := ping.New(address)
pinger.Count = 1 pinger.Count = 1
pinger.Timeout = config.Timeout pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every GOOS except darwin pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
// 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.SetNetwork(config.Network) pinger.SetNetwork(config.Network)
err := pinger.Run() err := pinger.Run()
if err != nil { if err != nil {
@@ -364,51 +372,75 @@ func Ping(address string, config *Config) (bool, time.Duration) {
return true, 0 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 // 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) { func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const ( const (
Origin = "http://localhost/" Origin = "http://localhost/"
MaximumMessageSize = 1024 // in bytes
) )
wsConfig, err := websocket.NewConfig(address, Origin) var (
if err != nil { dialer = websocket.Dialer{
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err) EnableCompression: true,
}
if headers != nil {
if wsConfig.Header == nil {
wsConfig.Header = make(http.Header)
}
for name, value := range headers {
wsConfig.Header.Set(name, value)
} }
wsHeaders = make(http.Header)
)
wsHeaders.Set("Origin", Origin)
for name, value := range headers {
wsHeaders.Set(name, value)
} }
ctx := context.Background()
if config != nil { if config != nil {
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout} if config.Timeout > 0 {
wsConfig.TlsConfig = &tls.Config{ var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
defer cancel()
}
dialer.TLSClientConfig = &tls.Config{
InsecureSkipVerify: config.Insecure, InsecureSkipVerify: config.Insecure,
} }
if config.HasTLSConfig() && config.TLS.isValid() == nil { if config.HasTLSConfig() && config.TLS.isValid() == nil {
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS) dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)
} }
} }
// Dial URL // Dial URL
ws, err := websocket.DialConfig(wsConfig) ws, _, err := dialer.DialContext(ctx, address, wsHeaders)
if err != nil { if err != nil {
return false, nil, fmt.Errorf("error dialing websocket: %w", err) return false, nil, fmt.Errorf("error dialing websocket: %w", err)
} }
defer ws.Close() defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr()) body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message // Write message
if _, err := ws.Write([]byte(body)); err != nil { if err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err) return false, nil, fmt.Errorf("error writing websocket body: %w", err)
} }
// Read message // Read message
var n int msgType, msg, err := ws.ReadMessage()
msg := make([]byte, MaximumMessageSize) if err != nil {
if n, err = ws.Read(msg); err != nil {
return false, nil, fmt.Errorf("error reading websocket message: %w", err) return false, nil, fmt.Errorf("error reading websocket message: %w", err)
} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage)
} }
return true, msg[:n], nil return true, msg, nil
} }
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) { func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
@@ -467,6 +499,13 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
if srv, ok := rr.(*dns.SRV); ok { if srv, ok := rr.(*dns.SRV); ok {
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port)) body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
} }
case dns.TypeTXT:
if txt, ok := rr.(*dns.TXT); ok {
if len(body) > 0 {
body = append(body, '\n')
}
body = append(body, []byte(strings.Join(txt.Txt, ""))...)
}
default: default:
body = []byte("query type is not supported yet") body = []byte("query type is not supported yet")
} }
@@ -496,6 +535,9 @@ func rdapQuery(hostname string) (*whois.Response, error) {
break break
} }
} }
if response.ExpirationDate.IsZero() {
return nil, fmt.Errorf("no expiration event found in RDAP response for %s", hostname)
}
return &response, nil return &response, nil
} }
+87 -19
View File
@@ -6,6 +6,9 @@ import (
"io" "io"
"net/http" "net/http"
"net/netip" "net/netip"
"os"
"runtime"
"strings"
"testing" "testing"
"time" "time"
@@ -14,6 +17,17 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func isIgnorableNetworkTestError(err error) bool {
if err == nil {
return false
}
errString := err.Error()
return strings.Contains(errString, "no such host") ||
strings.Contains(errString, "connection reset by peer") ||
strings.Contains(errString, "i/o timeout") ||
strings.Contains(errString, "server misbehaving")
}
func TestGetHTTPClient(t *testing.T) { func TestGetHTTPClient(t *testing.T) {
cfg := &Config{ cfg := &Config{
Insecure: false, Insecure: false,
@@ -40,6 +54,7 @@ func TestGetHTTPClient(t *testing.T) {
} }
func TestRdapQuery(t *testing.T) { func TestRdapQuery(t *testing.T) {
t.Parallel()
if _, err := rdapQuery("1.1.1.1"); err == nil { if _, err := rdapQuery("1.1.1.1"); err == nil {
t.Error("expected an error due to the invalid domain type") t.Error("expected an error due to the invalid domain type")
} }
@@ -51,6 +66,13 @@ func TestRdapQuery(t *testing.T) {
} else if response.ExpirationDate.Unix() <= 0 { } else if response.ExpirationDate.Unix() <= 0 {
t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix()) t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
} }
// .net domain: rdapQuery must either return a valid expiration or an error,
// but never silently return a zero-value expiration date (the bug in #1570)
if response, err := rdapQuery("google.net"); err != nil {
t.Logf("rdapQuery returned error for .net domain (fallback to WHOIS expected): %s", err)
} else if response.ExpirationDate.IsZero() {
t.Error("rdapQuery returned a zero expiration date without an error for .net domain")
}
} }
func TestGetDomainExpiration(t *testing.T) { func TestGetDomainExpiration(t *testing.T) {
@@ -79,6 +101,12 @@ func TestGetDomainExpiration(t *testing.T) {
} else if domainExpiration <= 0 { } else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0") t.Error("expected domain expiration to be higher than 0")
} }
// .net domain: should succeed via RDAP or WHOIS fallback (#1570)
if domainExpiration, err := GetDomainExpiration("google.net"); err != nil {
t.Errorf("expected error to be nil for .net domain, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0 for .net domain")
}
} }
func TestPing(t *testing.T) { func TestPing(t *testing.T) {
@@ -129,10 +157,36 @@ 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) { func TestCanPerformStartTLS(t *testing.T) {
type args struct { type args struct {
address string address string
insecure bool insecure bool
dnsresolver string dnsresolver string
} }
tests := []struct { tests := []struct {
@@ -168,7 +222,7 @@ func TestCanPerformStartTLS(t *testing.T) {
{ {
name: "dns resolver", name: "dns resolver",
args: args{ args: args{
address: "smtp.gmail.com:587", address: "smtp.gmail.com:587",
dnsresolver: "tcp://1.1.1.1:53", dnsresolver: "tcp://1.1.1.1:53",
}, },
wantConnected: true, wantConnected: true,
@@ -179,6 +233,9 @@ func TestCanPerformStartTLS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver}) connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
if !tt.wantErr && isIgnorableNetworkTestError(err) {
t.Skipf("skipping due to transient network error: %v", err)
}
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr) t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
return return
@@ -248,6 +305,9 @@ func TestCanPerformTLS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second}) connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if !tt.wantErr && isIgnorableNetworkTestError(err) {
t.Skipf("skipping due to transient network error: %v", err)
}
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr) t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return return
@@ -260,6 +320,7 @@ func TestCanPerformTLS(t *testing.T) {
} }
func TestCanCreateConnection(t *testing.T) { func TestCanCreateConnection(t *testing.T) {
t.Parallel()
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second}) connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
if connected { if connected {
t.Error("should've failed, because there's no port in the address") t.Error("should've failed, because there's no port in the address")
@@ -329,6 +390,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
} }
func TestQueryWebSocket(t *testing.T) { func TestQueryWebSocket(t *testing.T) {
t.Parallel()
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second}) _, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil { if err == nil {
t.Error("expected an error due to the address being invalid") t.Error("expected an error due to the address being invalid")
@@ -340,7 +402,8 @@ func TestQueryWebSocket(t *testing.T) {
} }
func TestTlsRenegotiation(t *testing.T) { func TestTlsRenegotiation(t *testing.T) {
tests := []struct { t.Parallel()
scenarios := []struct {
name string name string
cfg TLSConfig cfg TLSConfig
expectedConfig tls.RenegotiationSupport expectedConfig tls.RenegotiationSupport
@@ -371,18 +434,19 @@ func TestTlsRenegotiation(t *testing.T) {
expectedConfig: tls.RenegotiateNever, expectedConfig: tls.RenegotiateNever,
}, },
} }
for _, test := range tests { for _, scenario := range scenarios {
t.Run(test.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
tls := &tls.Config{} tls := &tls.Config{}
tlsConfig := configureTLS(tls, test.cfg) tlsConfig := configureTLS(tls, scenario.cfg)
if tlsConfig.Renegotiation != test.expectedConfig { if tlsConfig.Renegotiation != scenario.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation) t.Errorf("expected tls renegotiation to be %v, but got %v", scenario.expectedConfig, tls.Renegotiation)
} }
}) })
} }
} }
func TestQueryDNS(t *testing.T) { func TestQueryDNS(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
inputDNS dns.Config inputDNS dns.Config
@@ -439,7 +503,7 @@ func TestQueryDNS(t *testing.T) {
}, },
inputURL: "8.8.8.8", inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.", expectedBody: "*.ns.cloudflare.com.",
}, },
{ {
name: "test Config with type PTR", name: "test Config with type PTR",
@@ -461,6 +525,16 @@ func TestQueryDNS(t *testing.T) {
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "one.one.one.one.", expectedBody: "one.one.one.one.",
}, },
{
name: "test Config with type TXT",
inputDNS: dns.Config{
QueryType: "TXT",
QueryName: "example.com.",
},
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "*v=spf1*",
},
{ {
name: "test Config with fake type and retrieve error", name: "test Config with fake type and retrieve error",
inputDNS: dns.Config{ inputDNS: dns.Config{
@@ -480,8 +554,8 @@ func TestQueryDNS(t *testing.T) {
if dnsRCode != scenario.expectedDNSCode { if dnsRCode != scenario.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode) t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
} }
if scenario.inputDNS.QueryType == "NS" { if scenario.inputDNS.QueryType == "NS" || scenario.inputDNS.QueryType == "TXT" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix // Some record types can have multiple valid answers, so wildcard matching is used in those scenarios
if !pattern.Match(scenario.expectedBody, string(body)) { if !pattern.Match(scenario.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody) t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
} }
@@ -512,15 +586,13 @@ func TestQueryDNS(t *testing.T) {
} }
func TestCheckSSHBanner(t *testing.T) { func TestCheckSSHBanner(t *testing.T) {
t.Parallel()
cfg := &Config{Timeout: 3} cfg := &Config{Timeout: 3}
t.Run("no-auth-ssh", func(t *testing.T) { t.Run("no-auth-ssh", func(t *testing.T) {
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg) connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
if err != nil { if err != nil {
t.Errorf("Expected: error != nil, got: %v ", err) t.Errorf("Expected: error != nil, got: %v ", err)
} }
if connected == false { if connected == false {
t.Errorf("Expected: connected == true, got: %v", connected) t.Errorf("Expected: connected == true, got: %v", connected)
} }
@@ -528,14 +600,11 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 0, got: %v", status) t.Errorf("Expected: 0, got: %v", status)
} }
}) })
t.Run("invalid-address", func(t *testing.T) { t.Run("invalid-address", func(t *testing.T) {
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg) connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
if err == nil { if err == nil {
t.Errorf("Expected: error, got: %v ", err) t.Errorf("Expected: error, got: %v ", err)
} }
if connected != false { if connected != false {
t.Errorf("Expected: connected == false, got: %v", connected) t.Errorf("Expected: connected == false, got: %v", connected)
} }
@@ -543,5 +612,4 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 1, got: %v", status) t.Errorf("Expected: 1, got: %v", status)
} }
}) })
} }
+12
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"regexp" "regexp"
"strconv" "strconv"
@@ -34,6 +35,7 @@ var (
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: defaultTimeout, Timeout: defaultTimeout,
Network: "ip", Network: "ip",
StoreCookies: false,
} }
) )
@@ -82,6 +84,9 @@ type Config struct {
// ResolvedTunnel is the resolved SSH tunnel for this specific Config // ResolvedTunnel is the resolved SSH tunnel for this specific Config
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"` ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
// StoreCookies determines whether cookies are stored and included across requests.
StoreCookies bool `yaml:"store-cookies,omitempty"`
httpClient *http.Client httpClient *http.Client
} }
@@ -280,6 +285,13 @@ func (c *Config) getHTTPClient() *http.Client {
} }
} }
} }
if c.StoreCookies {
jar, err := cookiejar.New(nil)
if err != nil {
logr.Fatalf("[client.getHTTPClient] Failed to initialize cookie jar: %v", err)
}
c.httpClient.Jar = jar
}
} }
return c.httpClient return c.httpClient
} }
+15
View File
@@ -17,6 +17,9 @@ func TestConfig_getHTTPClient(t *testing.T) {
if insecureClient.Timeout != defaultTimeout { if insecureClient.Timeout != defaultTimeout {
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s") t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
} }
if insecureClient.Jar != nil {
t.Error("expected Config.StoreCookies to default the HTTP client to not store cookies")
}
request, _ := http.NewRequest("GET", "", nil) request, _ := http.NewRequest("GET", "", nil)
if err := insecureClient.CheckRedirect(request, nil); err != nil { if err := insecureClient.CheckRedirect(request, nil); err != nil {
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil") t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
@@ -31,12 +34,24 @@ func TestConfig_getHTTPClient(t *testing.T) {
if secureClient.Timeout != 5*time.Second { if secureClient.Timeout != 5*time.Second {
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s") t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
} }
if insecureClient.Jar != nil {
t.Error("expected Config.StoreCookies to default the HTTP client to not store cookies")
}
request, _ = http.NewRequest("GET", "", nil) request, _ = http.NewRequest("GET", "", nil)
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse { if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse") t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
} }
} }
func TestConfig_getHTTPClient_withCookieJar(t *testing.T) {
config := &Config{StoreCookies: true}
config.ValidateAndSetDefaults()
client := config.getHTTPClient()
if client.Jar == nil {
t.Error("expected Config.StoreCookies to cause the HTTP client to have a CookieJar")
}
}
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) { func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
type args struct { type args struct {
dnsResolver string dnsResolver string
+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 is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"` 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 // 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)
}
}
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -136,7 +137,7 @@ func (config *Config) GetUniqueExtraMetricLabels() []string {
continue continue
} }
for label := range ep.ExtraLabels { for label := range ep.ExtraLabels {
if contains(labels, label) { if slices.Contains(labels, label) {
continue continue
} }
labels = append(labels, label) labels = append(labels, label)
@@ -594,6 +595,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
} }
alertTypes := []alert.Type{ alertTypes := []alert.Type{
alert.TypeAWSSES, alert.TypeAWSSES,
alert.TypeClickUp,
alert.TypeCustom, alert.TypeCustom,
alert.TypeDatadog, alert.TypeDatadog,
alert.TypeDiscord, alert.TypeDiscord,
+5 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"time" "time"
@@ -12,6 +13,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -1854,6 +1856,7 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
func TestGetAlertingProviderByAlertType(t *testing.T) { func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{ alertingConfig := &alerting.Config{
AWSSimpleEmailService: &awsses.AlertProvider{}, AWSSimpleEmailService: &awsses.AlertProvider{},
ClickUp: &clickup.AlertProvider{},
Custom: &custom.AlertProvider{}, Custom: &custom.AlertProvider{},
Datadog: &datadog.AlertProvider{}, Datadog: &datadog.AlertProvider{},
Discord: &discord.AlertProvider{}, Discord: &discord.AlertProvider{},
@@ -1898,6 +1901,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
expected provider.AlertProvider expected provider.AlertProvider
}{ }{
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService}, {alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom}, {alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog}, {alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord}, {alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
@@ -2049,7 +2053,7 @@ func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels)) t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
} }
for _, label := range tt.expected { for _, label := range tt.expected {
if !contains(labels, label) { if !slices.Contains(labels, label) {
t.Errorf("expected label %s to be present", label) t.Errorf("expected label %s to be present", label)
} }
} }
+52 -9
View File
@@ -26,7 +26,7 @@ type Condition string
// Validate checks if the Condition is valid // Validate checks if the Condition is valid
func (c Condition) Validate() error { func (c Condition) Validate() error {
r := &Result{} r := &Result{}
c.evaluate(r, false, nil) c.evaluate(r, false, false, nil)
if len(r.Errors) != 0 { if len(r.Errors) != 0 {
return errors.New(r.Errors[0]) return errors.New(r.Errors[0])
} }
@@ -34,44 +34,50 @@ func (c Condition) Validate() error {
} }
// evaluate the Condition with the Result and an optional context // evaluate the Condition with the Result and an optional context
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool { func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, resolveSuccessfulConditions bool, context *gontext.Gontext) bool {
condition := string(c) condition := string(c)
success := false success := false
conditionToDisplay := condition conditionToDisplay := condition
shouldResolveCondition := func(success bool) bool {
if success {
return resolveSuccessfulConditions
}
return !dontResolveFailedConditions
}
if strings.Contains(condition, " == ") { if strings.Contains(condition, " == ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context) parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
success = isEqual(resolvedParameters[0], resolvedParameters[1]) success = isEqual(resolvedParameters[0], resolvedParameters[1])
if !success && !dontResolveFailedConditions { if shouldResolveCondition(success) {
conditionToDisplay = prettify(parameters, resolvedParameters, "==") conditionToDisplay = prettify(parameters, resolvedParameters, "==")
} }
} else if strings.Contains(condition, " != ") { } else if strings.Contains(condition, " != ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context) parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
success = !isEqual(resolvedParameters[0], resolvedParameters[1]) success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if !success && !dontResolveFailedConditions { if shouldResolveCondition(success) {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=") conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
} }
} else if strings.Contains(condition, " <= ") { } else if strings.Contains(condition, " <= ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context) parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
success = resolvedParameters[0] <= resolvedParameters[1] success = resolvedParameters[0] <= resolvedParameters[1]
if !success && !dontResolveFailedConditions { if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
} }
} else if strings.Contains(condition, " >= ") { } else if strings.Contains(condition, " >= ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context) parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
success = resolvedParameters[0] >= resolvedParameters[1] success = resolvedParameters[0] >= resolvedParameters[1]
if !success && !dontResolveFailedConditions { if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
} }
} else if strings.Contains(condition, " > ") { } else if strings.Contains(condition, " > ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context) parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
success = resolvedParameters[0] > resolvedParameters[1] success = resolvedParameters[0] > resolvedParameters[1]
if !success && !dontResolveFailedConditions { if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
} }
} else if strings.Contains(condition, " < ") { } else if strings.Contains(condition, " < ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context) parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
success = resolvedParameters[0] < resolvedParameters[1] success = resolvedParameters[0] < resolvedParameters[1]
if !success && !dontResolveFailedConditions { if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
} }
} else { } else {
@@ -209,7 +215,44 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
} }
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string { 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 := range 2 {
// 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 // prettify returns a string representation of a condition with its parameters resolved between parentheses
+9 -9
View File
@@ -8,7 +8,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)") condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -17,7 +17,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)") condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -26,7 +26,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
condition := Condition("[BODY].name == john.doe") condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -35,7 +35,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
condition := Condition("[BODY].name == john.doe") condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -44,7 +44,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
condition := Condition("[BODY].user.name == bob.doe") condition := Condition("[BODY].user.name == bob.doe")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -53,7 +53,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
condition := Condition("len([BODY].name) == 8") condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -62,7 +62,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
condition := Condition("len([BODY].name) == 8") condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -71,7 +71,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200} result := &Result{HTTPStatus: 200}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@@ -80,7 +80,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400} result := &Result{HTTPStatus: 400}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
} }
b.ReportAllocs() b.ReportAllocs()
} }
+16 -7
View File
@@ -62,6 +62,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition Condition Condition Condition
Result *Result Result *Result
DontResolveFailedConditions bool DontResolveFailedConditions bool
ResolveSuccessfulConditions bool
ExpectedSuccess bool ExpectedSuccess bool
ExpectedOutput string ExpectedOutput string
}{ }{
@@ -184,6 +185,14 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: true, ExpectedSuccess: true,
ExpectedOutput: "[BODY] == test", ExpectedOutput: "[BODY] == test",
}, },
{
Name: "body-resolved-on-success",
Condition: Condition("[BODY].status == UP"),
Result: &Result{Body: []byte("{\"status\":\"UP\"}")},
ResolveSuccessfulConditions: true,
ExpectedSuccess: true,
ExpectedOutput: "[BODY].status (UP) == UP",
},
{ {
Name: "body-numerical-equal", Name: "body-numerical-equal",
Condition: Condition("[BODY] == 123"), Condition: Condition("[BODY] == 123"),
@@ -476,7 +485,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)), Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
Result: &Result{CertificateExpiration: time.Hour * 24 * 14}, Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000", ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
}, },
{ {
Name: "certificate-expiration-greater-than-duration", Name: "certificate-expiration-greater-than-duration",
@@ -490,7 +499,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"), Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
Result: &Result{CertificateExpiration: 24 * time.Hour}, Result: &Result{CertificateExpiration: 24 * time.Hour},
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)", ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
}, },
{ {
Name: "no-placeholders", Name: "no-placeholders",
@@ -757,7 +766,7 @@ func TestCondition_evaluate(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, nil) scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, scenario.ResolveSuccessfulConditions, nil)
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess { if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess) t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
} }
@@ -771,7 +780,7 @@ func TestCondition_evaluate(t *testing.T) {
func TestCondition_evaluateWithInvalidOperator(t *testing.T) { func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
condition := Condition("[STATUS] ? 201") condition := Condition("[STATUS] ? 201")
result := &Result{HTTPStatus: 201} result := &Result{HTTPStatus: 201}
condition.evaluate(result, false, nil) condition.evaluate(result, false, false, nil)
if result.Success { if result.Success {
t.Error("condition was invalid, result should've been a failure") t.Error("condition was invalid, result should've been a failure")
} }
@@ -791,7 +800,7 @@ func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
"max_response_time": 5000, "max_response_time": 5000,
}) })
// Simulate suite endpoint evaluation with context // Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default) success := condition.evaluate(result, false, false, ctx) // false = don't skip resolution (default)
if success { if success {
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist") t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
} }
@@ -814,7 +823,7 @@ func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
"expected_status": 200, "expected_status": 200,
}) })
// Simulate suite endpoint evaluation with context // Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) success := condition.evaluate(result, false, false, ctx)
if !success { if !success {
t.Error("Condition should have succeeded") t.Error("Condition should have succeeded")
} }
@@ -839,7 +848,7 @@ func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
"valid_key": 5000, "valid_key": 5000,
}) })
// Simulate suite endpoint evaluation with context // Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) success := condition.evaluate(result, false, false, ctx)
if success { if success {
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist") t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
} }
+40 -12
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
@@ -50,6 +51,7 @@ const (
TypeSTARTTLS Type = "STARTTLS" TypeSTARTTLS Type = "STARTTLS"
TypeTLS Type = "TLS" TypeTLS Type = "TLS"
TypeHTTP Type = "HTTP" TypeHTTP Type = "HTTP"
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET" TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH" TypeSSH Type = "SSH"
TypeUNKNOWN Type = "UNKNOWN" TypeUNKNOWN Type = "UNKNOWN"
@@ -177,6 +179,8 @@ func (e *Endpoint) Type() Type {
return TypeTLS return TypeTLS
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"): case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return TypeHTTP 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://"): case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return TypeWS return TypeWS
case strings.HasPrefix(e.URL, "ssh://"): case strings.HasPrefix(e.URL, "ssh://"):
@@ -218,12 +222,12 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
e.Headers = make(map[string]string) e.Headers = make(map[string]string)
} }
// Automatically add user agent header if there isn't one specified in the endpoint configuration // Automatically add user agent header if there isn't one specified in the endpoint configuration
if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists { if !hasHeader(e.Headers, UserAgentHeader) {
e.Headers[UserAgentHeader] = GatusUserAgent e.Headers[UserAgentHeader] = GatusUserAgent
} }
// Automatically add "Content-Type: application/json" header if there's no Content-Type set // Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and endpoint.GraphQL is set to true // and endpoint.GraphQL is set to true
if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL { if !hasHeader(e.Headers, ContentTypeHeader) && e.GraphQL {
e.Headers[ContentTypeHeader] = "application/json" e.Headers[ContentTypeHeader] = "application/json"
} }
if len(e.Conditions) == 0 { if len(e.Conditions) == 0 {
@@ -329,7 +333,7 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
} }
// Evaluate the conditions // Evaluate the conditions
for _, condition := range processedEndpoint.Conditions { for _, condition := range processedEndpoint.Conditions {
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, context) success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context)
if !success { if !success {
result.Success = false result.Success = false
} }
@@ -353,6 +357,9 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
} }
result.port = "" result.port = ""
} }
if processedEndpoint.UIConfig.HideErrors {
result.Errors = nil
}
if processedEndpoint.UIConfig.HideConditions { if processedEndpoint.UIConfig.HideConditions {
result.ConditionResults = nil result.ConditionResults = nil
} }
@@ -483,12 +490,10 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeWS { } else if endpointType == TypeWS {
wsHeaders := map[string]string{} wsHeaders := map[string]string{}
if e.Headers != nil { if e.Headers != nil {
for k, v := range e.Headers { maps.Copy(wsHeaders, e.Headers)
wsHeaders[k] = v
}
} }
if _, exists := wsHeaders["User-Agent"]; !exists { if !hasHeader(wsHeaders, UserAgentHeader) {
wsHeaders["User-Agent"] = GatusUserAgent wsHeaders[UserAgentHeader] = GatusUserAgent
} }
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig) result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
if err != nil { if err != nil {
@@ -497,8 +502,8 @@ func (e *Endpoint) call(result *Result) {
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH { } else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner // If there's no username, password or private key specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 { 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) result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -509,7 +514,7 @@ func (e *Endpoint) call(result *Result) {
return return
} }
var cli *ssh.Client 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 { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
@@ -525,6 +530,19 @@ func (e *Endpoint) call(result *Result) {
result.Body = output result.Body = output
} }
result.Duration = time.Since(startTime) 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 { } else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request) response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
@@ -563,7 +581,7 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer) request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers { for k, v := range e.Headers {
request.Header.Set(k, v) request.Header.Set(k, v)
if k == HostHeader { if strings.EqualFold(k, HostHeader) {
request.Host = v request.Host = v
} }
} }
@@ -607,3 +625,13 @@ func (e *Endpoint) needsToRetrieveIP() bool {
} }
return false return false
} }
// hasHeader checks if a header exists in the map using a case-insensitive lookup
func hasHeader(headers map[string]string, name string) bool {
for k := range headers {
if strings.EqualFold(k, name) {
return true
}
}
return false
}
+307 -8
View File
@@ -21,6 +21,30 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestHasHeader(t *testing.T) {
scenarios := []struct {
name string
headers map[string]string
lookup string
expected bool
}{
{name: "exact-match", headers: map[string]string{"User-Agent": "test"}, lookup: "User-Agent", expected: true},
{name: "lowercase-lookup", headers: map[string]string{"User-Agent": "test"}, lookup: "user-agent", expected: true},
{name: "uppercase-lookup", headers: map[string]string{"user-agent": "test"}, lookup: "USER-AGENT", expected: true},
{name: "mixed-case", headers: map[string]string{"UsEr-AgEnT": "test"}, lookup: "uSeR-aGeNt", expected: true},
{name: "not-found", headers: map[string]string{"Content-Type": "test"}, lookup: "User-Agent", expected: false},
{name: "empty-headers", headers: map[string]string{}, lookup: "User-Agent", expected: false},
{name: "nil-headers", headers: nil, lookup: "User-Agent", expected: false},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if result := hasHeader(scenario.headers, scenario.lookup); result != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, result)
}
})
}
}
func TestEndpoint(t *testing.T) { func TestEndpoint(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
scenarios := []struct { scenarios := []struct {
@@ -268,6 +292,31 @@ func TestEndpoint(t *testing.T) {
} }
} }
func TestEndpoint_ResolveSuccessfulConditions(t *testing.T) {
defer client.InjectHTTPClient(nil)
endpoint := Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[BODY].status == UP"},
UIConfig: &ui.Config{ResolveSuccessfulConditions: true},
}
mockResponse := test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status":"UP"}`))}
})
client.InjectHTTPClient(&http.Client{Transport: mockResponse})
if err := endpoint.ValidateAndSetDefaults(); err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := endpoint.EvaluateHealth()
if len(result.ConditionResults) != 1 {
t.Fatalf("expected 1 condition result, got %d", len(result.ConditionResults))
}
expectedCondition := "[BODY].status (UP) == UP"
if result.ConditionResults[0].Condition != expectedCondition {
t.Errorf("expected condition to be '%s', got '%s'", expectedCondition, result.ConditionResults[0].Condition)
}
}
func TestEndpoint_IsEnabled(t *testing.T) { func TestEndpoint_IsEnabled(t *testing.T) {
if !(&Endpoint{Enabled: nil}).IsEnabled() { if !(&Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil") t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
@@ -511,26 +560,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
name string name string
username string username string
password string password string
privateKey string
expectedErr error expectedErr error
}{ }{
{ {
name: "fail when has no user", name: "fail when has no user but has password",
username: "", username: "",
password: "password", password: "password",
expectedErr: ssh.ErrEndpointWithoutSSHUsername, expectedErr: ssh.ErrEndpointWithoutSSHUsername,
}, },
{ {
name: "fail when has no password", name: "fail when has no user but has private key",
username: "username", username: "",
password: "", privateKey: "-----BEGIN",
expectedErr: ssh.ErrEndpointWithoutSSHPassword, 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", username: "username",
password: "password", password: "password",
expectedErr: nil, expectedErr: nil,
}, },
{
name: "success when username and private key are set",
username: "username",
privateKey: "-----BEGIN",
expectedErr: nil,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
@@ -539,8 +602,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
Name: "ssh-test", Name: "ssh-test",
URL: "https://example.com", URL: "https://example.com",
SSHConfig: &ssh.Config{ SSHConfig: &ssh.Config{
Username: scenario.username, Username: scenario.username,
Password: scenario.password, Password: scenario.password,
PrivateKey: scenario.privateKey,
}, },
Conditions: []Condition{Condition("[STATUS] == 0")}, Conditions: []Condition{Condition("[STATUS] == 0")},
} }
@@ -682,6 +746,76 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
} }
} }
func TestEndpoint_buildHTTPRequestWithLowercaseUserAgent(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition},
Headers: map[string]string{
"user-agent": "CustomAgent/1.0",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if _, exists := endpoint.Headers[UserAgentHeader]; exists {
t.Error("User-Agent header should not have been added since user-agent was already specified")
}
request := endpoint.buildHTTPRequest()
if userAgent := request.Header.Get("User-Agent"); userAgent != "CustomAgent/1.0" {
t.Errorf("request.Header.Get(User-Agent) should've been CustomAgent/1.0, but was %s", userAgent)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseContentType(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []Condition{condition},
GraphQL: true,
Headers: map[string]string{
"content-type": "application/graphql",
},
Body: `{ users { id } }`,
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if _, exists := endpoint.Headers[ContentTypeHeader]; exists {
t.Error("Content-Type header should not have been added since content-type was already specified")
}
request := endpoint.buildHTTPRequest()
if contentType := request.Header.Get("Content-Type"); contentType != "application/graphql" {
t.Errorf("request.Header.Get(Content-Type) should've been application/graphql, but was %s", contentType)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []Condition{condition},
Headers: map[string]string{
"host": "example.com",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Host != "example.com" {
t.Error("request.Host should've been example.com, but was", request.Host)
}
}
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) { func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
@@ -1448,3 +1582,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)
}
}
})
}
}
+3 -6
View File
@@ -1,6 +1,7 @@
package endpoint package endpoint
import ( import (
"slices"
"time" "time"
) )
@@ -66,11 +67,7 @@ type Result struct {
// AddError adds an error to the result's list of errors. // AddError adds an error to the result's list of errors.
// It also ensures that there are no duplicates. // It also ensures that there are no duplicates.
func (r *Result) AddError(error string) { func (r *Result) AddError(error string) {
for _, resultError := range r.Errors { if !slices.Contains(r.Errors, error) {
if resultError == error { r.Errors = append(r.Errors, error+"")
// If the error already exists, don't add it
return
}
} }
r.Errors = append(r.Errors, error+"")
} }
+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 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") 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. // ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint") ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
) )
type Config struct { type Config struct {
Username string `yaml:"username,omitempty"` Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"` Password string `yaml:"password,omitempty"`
PrivateKey string `yaml:"private-key,omitempty"`
} }
// Validate the SSH configuration // Validate the SSH configuration
func (cfg *Config) Validate() error { 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 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 { if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return nil return nil
} }
// If any authentication method is provided (password or private key), a username is required
if len(cfg.Username) == 0 { if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername return ErrEndpointWithoutSSHUsername
} }
if len(cfg.Password) == 0 { // If a username is provided, require at least a password or a private key
return ErrEndpointWithoutSSHPassword if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return ErrEndpointWithoutSSHAuth
} }
return nil return nil
} }
+18 -3
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
) )
func TestSSH_validate(t *testing.T) { func TestSSH_validatePasswordCfg(t *testing.T) {
cfg := &Config{} cfg := &Config{}
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error") t.Error("didn't expect an error")
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
cfg.Username = "username" cfg.Username = "username"
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
t.Error("expected an error") t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) { } else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err) t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
} }
cfg.Password = "password" cfg.Password = "password"
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err) 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)
}
})
}
+8
View File
@@ -16,9 +16,15 @@ type Config struct {
// HidePort whether to hide the port in the Result // HidePort whether to hide the port in the Result
HidePort bool `yaml:"hide-port"` 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 whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"` DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
// ResolveSuccessfulConditions whether to resolve successful conditions in the Result for display in the UI
ResolveSuccessfulConditions bool `yaml:"resolve-successful-conditions"`
// Badge is the configuration for the badges generated // Badge is the configuration for the badges generated
Badge *Badge `yaml:"badge"` Badge *Badge `yaml:"badge"`
} }
@@ -58,7 +64,9 @@ func GetDefaultConfig() *Config {
HideHostname: false, HideHostname: false,
HideURL: false, HideURL: false,
HidePort: false, HidePort: false,
HideErrors: false,
DontResolveFailedConditions: false, DontResolveFailedConditions: false,
ResolveSuccessfulConditions: false,
HideConditions: false, HideConditions: false,
Badge: &Badge{ Badge: &Badge{
ResponseTime: &ResponseTime{ ResponseTime: &ResponseTime{
+5 -5
View File
@@ -340,9 +340,9 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
done := make(chan bool, 10) done := make(chan bool, 10)
// Start 5 goroutines that read values // Start 5 goroutines that read values
for i := 0; i < 5; i++ { for i := range 5 {
go func(id int) { go func(id int) {
for j := 0; j < 100; j++ { for range 100 {
_, err := ctx.Get("counter") _, err := ctx.Get("counter")
if err != nil { if err != nil {
t.Errorf("Reader %d error: %v", id, err) t.Errorf("Reader %d error: %v", id, err)
@@ -353,9 +353,9 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
} }
// Start 5 goroutines that write values // Start 5 goroutines that write values
for i := 0; i < 5; i++ { for i := range 5 {
go func(id int) { go func(id int) {
for j := 0; j < 100; j++ { for j := range 100 {
err := ctx.Set("counter", id*1000+j) err := ctx.Set("counter", id*1000+j)
if err != nil { if err != nil {
t.Errorf("Writer %d error: %v", id, err) t.Errorf("Writer %d error: %v", id, err)
@@ -366,7 +366,7 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
} }
// Wait for all goroutines to complete // Wait for all goroutines to complete
for i := 0; i < 10; i++ { for range 10 {
<-done <-done
} }
} }
+3 -17
View File
@@ -3,6 +3,7 @@ package maintenance
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -70,13 +71,7 @@ func (c *Config) ValidateAndSetDefaults() error {
return nil return nil
} }
for _, day := range c.Every { for _, day := range c.Every {
isDayValid := false isDayValid := slices.Contains(longDayNames, day)
for _, longDayName := range longDayNames {
if day == longDayName {
isDayValid = true
break
}
}
if !isDayValid { if !isDayValid {
return errInvalidDayName return errInvalidDayName
} }
@@ -118,7 +113,7 @@ func (c *Config) IsUnderMaintenance() bool {
// Set to midnight prior to adding duration // Set to midnight prior to adding duration
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location()) dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
hasMaintenanceEveryDay := len(c.Every) == 0 hasMaintenanceEveryDay := len(c.Every) == 0
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String()) hasMaintenancePeriodScheduledToStartOnThatWeekday := slices.Contains(c.Every, dayWhereMaintenancePeriodWouldStart.Weekday().String())
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday { if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
// The day when the maintenance period would start is not scheduled // The day when the maintenance period would start is not scheduled
// to have any maintenance, so we can just return false. // to have any maintenance, so we can just return false.
@@ -129,15 +124,6 @@ func (c *Config) IsUnderMaintenance() bool {
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod) return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
} }
func (c *Config) hasDay(day string) bool {
for _, d := range c.Every {
if d == day {
return true
}
}
return false
}
func hhmmToDuration(s string) (time.Duration, error) { func hhmmToDuration(s string) (time.Duration, error) {
if len(s) != 5 { if len(s) != 5 {
return 0, errInvalidMaintenanceStartFormat return 0, errInvalidMaintenanceStartFormat
+1 -1
View File
@@ -133,7 +133,7 @@ func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
const maxRetries = 3 const maxRetries = 3
const baseDelay = 500 * time.Millisecond const baseDelay = 500 * time.Millisecond
var lastErr error var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ { for attempt := range maxRetries {
if attempt > 0 { if attempt > 0 {
// Exponential backoff: 500ms, 1s, 2s // Exponential backoff: 500ms, 1s, 2s
delay := baseDelay << (attempt - 1) delay := baseDelay << (attempt - 1)
+60 -19
View File
@@ -10,14 +10,20 @@ import (
) )
const ( const (
defaultTitle = "Health Dashboard | Gatus" 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" 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" defaultHeader = "Gatus"
defaultLogo = "" defaultDashboardHeading = "Health Dashboard"
defaultLink = "" defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
defaultCustomCSS = "" defaultLogo = ""
defaultSortBy = "name" defaultLink = ""
defaultFilterBy = "none" defaultFavicon = "/favicon.ico"
defaultFavicon16 = "/favicon-16x16.png"
defaultFavicon32 = "/favicon-32x32.png"
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
defaultLoginSubtitle = "System Monitoring Dashboard"
) )
var ( var (
@@ -30,17 +36,20 @@ var (
// Config is the configuration for the UI of Gatus // Config is the configuration for the UI of Gatus
type Config struct { type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description 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 DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
Logo string `yaml:"logo,omitempty"` // Logo to display on the page DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header Logo string `yaml:"logo,omitempty"` // Logo to display on the page
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default Favicon Favicon `yaml:"favicon,omitempty"` // Favourite icon to display in web browser tab or address bar
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health') Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable') 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')
LoginSubtitle string `yaml:"login-subtitle,omitempty"` // LoginSubtitle is the subtitle displayed on the OIDC login page
////////////////////////////////////////////// //////////////////////////////////////////////
// Non-configurable - used for UI rendering // // Non-configurable - used for UI rendering //
////////////////////////////////////////////// //////////////////////////////////////////////
@@ -68,11 +77,19 @@ func (btn *Button) Validate() error {
return nil return nil
} }
type Favicon struct {
Default string `yaml:"default,omitempty"` // URL or path to default favourite icon.
Size16x16 string `yaml:"size16x16,omitempty"` // URL or path to favourite icon for 16x16 size.
Size32x32 string `yaml:"size32x32,omitempty"` // URL or path to favourite icon for 32x32 size.
}
// GetDefaultConfig returns a Config struct with the default values // GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
return &Config{ return &Config{
Title: defaultTitle, Title: defaultTitle,
Description: defaultDescription, Description: defaultDescription,
DashboardHeading: defaultDashboardHeading,
DashboardSubheading: defaultDashboardSubheading,
Header: defaultHeader, Header: defaultHeader,
Logo: defaultLogo, Logo: defaultLogo,
Link: defaultLink, Link: defaultLink,
@@ -80,7 +97,13 @@ func GetDefaultConfig() *Config {
DarkMode: &defaultDarkMode, DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy, DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy, DefaultFilterBy: defaultFilterBy,
LoginSubtitle: defaultLoginSubtitle,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults, MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
Favicon: Favicon{
Default: defaultFavicon,
Size16x16: defaultFavicon16,
Size32x32: defaultFavicon32,
},
} }
} }
@@ -92,6 +115,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Description) == 0 { if len(cfg.Description) == 0 {
cfg.Description = defaultDescription cfg.Description = defaultDescription
} }
if len(cfg.DashboardHeading) == 0 {
cfg.DashboardHeading = defaultDashboardHeading
}
if len(cfg.DashboardSubheading) == 0 {
cfg.DashboardSubheading = defaultDashboardSubheading
}
if len(cfg.Header) == 0 { if len(cfg.Header) == 0 {
cfg.Header = defaultHeader cfg.Header = defaultHeader
} }
@@ -117,6 +146,18 @@ func (cfg *Config) ValidateAndSetDefaults() error {
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" { } else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy return ErrInvalidDefaultFilterBy
} }
if len(cfg.LoginSubtitle) == 0 {
cfg.LoginSubtitle = defaultLoginSubtitle
}
if len(cfg.Favicon.Default) == 0 {
cfg.Favicon.Default = defaultFavicon
}
if len(cfg.Favicon.Size16x16) == 0 {
cfg.Favicon.Size16x16 = defaultFavicon16
}
if len(cfg.Favicon.Size32x32) == 0 {
cfg.Favicon.Size32x32 = defaultFavicon32
}
for _, btn := range cfg.Buttons { for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil { if err := btn.Validate(); err != nil {
return err return err
+132 -25
View File
@@ -7,31 +7,129 @@ import (
) )
func TestConfig_ValidateAndSetDefaults(t *testing.T) { func TestConfig_ValidateAndSetDefaults(t *testing.T) {
cfg := &Config{ t.Run("empty-config", func(t *testing.T) {
Title: "", cfg := &Config{
Description: "", Title: "",
Header: "", Description: "",
Logo: "", DashboardHeading: "",
Link: "", DashboardSubheading: "",
} Header: "",
if err := cfg.ValidateAndSetDefaults(); err != nil { Logo: "",
t.Error("expected no error, got", err.Error()) Link: "",
} }
if cfg.Title != defaultTitle { if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title) t.Error("expected no error, got", err.Error())
} }
if cfg.Description != defaultDescription { if cfg.Title != defaultTitle {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description) t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
} }
if cfg.Header != defaultHeader { if cfg.Description != defaultDescription {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header) t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
} }
if cfg.DefaultSortBy != defaultSortBy { if cfg.DashboardHeading != defaultDashboardHeading {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy) t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
} }
if cfg.DefaultFilterBy != defaultFilterBy { if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy) 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)
}
if cfg.Favicon.Default != defaultFavicon {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon, cfg.Favicon.Default)
}
if cfg.Favicon.Size16x16 != defaultFavicon16 {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon16, cfg.Favicon.Size16x16)
}
if cfg.Favicon.Size32x32 != defaultFavicon32 {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon32, cfg.Favicon.Size32x32)
}
if cfg.LoginSubtitle != defaultLoginSubtitle {
t.Errorf("expected LoginSubtitle to be %s, got %s", defaultLoginSubtitle, cfg.LoginSubtitle)
}
})
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",
LoginSubtitle: "Welcome",
}
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)
}
if cfg.LoginSubtitle != "Welcome" {
t.Errorf("expected LoginSubtitle to be preserved, got %s", cfg.LoginSubtitle)
}
})
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)
}
if cfg.LoginSubtitle != defaultLoginSubtitle {
t.Errorf("expected LoginSubtitle to use default, got %s", cfg.LoginSubtitle)
}
})
} }
func TestButton_Validate(t *testing.T) { func TestButton_Validate(t *testing.T) {
@@ -78,6 +176,12 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Title != defaultTitle { if defaultConfig.Title != defaultTitle {
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title) 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 { if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo) t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
} }
@@ -87,6 +191,9 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.DefaultFilterBy != defaultFilterBy { if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy) t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
} }
if defaultConfig.LoginSubtitle != defaultLoginSubtitle {
t.Error("expected GetDefaultConfig() to return defaultLoginSubtitle, got", defaultConfig.LoginSubtitle)
}
} }
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) { func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
-10
View File
@@ -4,13 +4,3 @@ package config
func toPtr[T any](value T) *T { func toPtr[T any](value T) *T {
return &value return &value
} }
// contains checks if a key exists in the slice
func contains[T comparable](slice []T, key T) bool {
for _, item := range slice {
if item == key {
return true
}
}
return false
}
+63 -64
View File
@@ -1,104 +1,103 @@
module github.com/TwiN/gatus/v5 module github.com/TwiN/gatus/v5
go 1.24.4 go 1.26.3
toolchain go1.24.7
require ( require (
code.gitea.io/sdk/gitea v0.22.0 code.gitea.io/sdk/gitea v0.25.1
github.com/TwiN/deepmerge v0.2.2 github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0 github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.4.0 github.com/TwiN/gocache/v2 v2.4.0
github.com/TwiN/health v1.6.0 github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1 github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.2.0 github.com/TwiN/whois v1.3.0
github.com/aws/aws-sdk-go-v2 v1.39.2 github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.31.12 github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 github.com/aws/aws-sdk-go-v2/service/ses v1.34.24
github.com/coreos/go-oidc/v3 v3.16.0 github.com/coreos/go-oidc/v3 v3.18.0
github.com/gofiber/fiber/v2 v2.52.9 github.com/gofiber/fiber/v2 v2.52.13
github.com/google/go-github/v48 v48.2.0 github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9 github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2
github.com/miekg/dns v1.1.68 github.com/lib/pq v1.12.3
github.com/prometheus-community/pro-bing v0.7.0 github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/registrobr/rdap v1.1.8 github.com/registrobr/rdap v1.1.8
github.com/valyala/fasthttp v1.67.0 github.com/valyala/fasthttp v1.71.0
github.com/wcharczuk/go-chart/v2 v2.1.2 github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.52.0
golang.org/x/net v0.46.0 golang.org/x/oauth2 v0.36.0
golang.org/x/oauth2 v0.32.0 golang.org/x/sync v0.20.0
golang.org/x/sync v0.17.0 google.golang.org/api v0.280.0
google.golang.org/api v0.252.0 google.golang.org/grpc v1.81.1
gopkg.in/mail.v2 v2.3.1 gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.39.1 modernc.org/sqlite v1.50.1
) )
require ( require (
cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect github.com/42wim/httpsig v1.2.4 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/smithy-go v1.23.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 github.com/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.40.0 // indirect
golang.org/x/image v0.18.0 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect
google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
+154 -149
View File
@@ -1,13 +1,13 @@
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= 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/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4= 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/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ= github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
@@ -18,46 +18,50 @@ 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/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 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw= github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
github.com/TwiN/whois v1.2.0 h1:/Z22SrS3Z0FQgMl1+4bKSu9UmEQTfGx9i9J4Hn18eQk= github.com/TwiN/whois v1.3.0 h1:V2+IUh5OGim8F3axTOMSlVmxNRaBrgpyiv5U0Dvbt5I=
github.com/TwiN/whois v1.2.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE= github.com/TwiN/whois v1.3.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= 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/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8= github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 h1:NwOeuOFrWoh4xWKINrmaAK4Vh75jmmY0RAuNjQ6W5Es= github.com/aws/aws-sdk-go-v2/service/ses v1.34.24 h1:ZPCoU087iv2IWJKNQyN+MYOqZAM9lEVevS9MHWGG4wI=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5/go.mod h1:m3BsMJZD0eqjGIniBzwrNUqG9ZUPquC4hY9FyE2qNFo= github.com/aws/aws-sdk-go-v2/service/ses v1.34.24/go.mod h1:4T8OWyQ9nkdUrcqHHJkyibP5TfcWDTe4kWL40tiCOcs=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
@@ -68,116 +72,115 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.13/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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 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= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE= github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y= github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2 h1:36qep4gxKs+JgeHGWeQ040RyZdt9kQlLglL1rFVn/oQ=
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 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_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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U= github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w= github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= 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/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
@@ -185,19 +188,18 @@ 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.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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 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.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -207,33 +209,31 @@ 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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.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.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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.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.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -242,8 +242,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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -253,30 +253,33 @@ 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk=
google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI= google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU=
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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 h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -286,28 +289,30 @@ gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1 -1
View File
@@ -87,7 +87,7 @@ func extractValue(currentKey string, value interface{}) interface{} {
} }
} }
arrayIndex, err := strconv.Atoi(index) arrayIndex, err := strconv.Atoi(index)
if err != nil { if err != nil || arrayIndex < 0 {
return nil return nil
} }
currentKeyWithoutIndex := currentKey[:startOfBracket] currentKeyWithoutIndex := currentKey[:startOfBracket]
+32
View File
@@ -174,6 +174,38 @@ func TestEval(t *testing.T) {
ExpectedOutputLength: 18, ExpectedOutputLength: 18,
ExpectedError: false, ExpectedError: false,
}, },
{
Name: "negative-index-on-keyed-array",
Path: "data[-1]",
Data: `{"data": [1, 2, 3]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "negative-index-on-root-array",
Path: "[-1]",
Data: `[1, 2, 3]`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "negative-index-followed-by-key",
Path: "data[-1].name",
Data: `{"data": [{"name": "value"}]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "negative-index-nested-array",
Path: "data[0][-1]",
Data: `{"data": [[1, 2, 3]]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
+14
View File
@@ -17,6 +17,7 @@ var (
resultConnectedTotal *prometheus.CounterVec resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec resultCertificateExpirationSeconds *prometheus.GaugeVec
resultDomainExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec resultEndpointSuccess *prometheus.GaugeVec
// Suite metrics // Suite metrics
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
if resultCertificateExpirationSeconds != nil { if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds) currentRegisterer.Unregister(resultCertificateExpirationSeconds)
} }
if resultDomainExpirationSeconds != nil {
currentRegisterer.Unregister(resultDomainExpirationSeconds)
}
if resultEndpointSuccess != nil { if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess) currentRegisterer.Unregister(resultEndpointSuccess)
} }
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
}, append([]string{"key", "group", "name", "type"}, extraLabels...)) }, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds) 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{ resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace, Namespace: namespace,
Name: "results_endpoint_success", Name: "results_endpoint_success",
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
if result.CertificateExpiration != 0 { if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds()) 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 { if result.Success {
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1) resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else { } else {
+13
View File
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
if resultCertificateExpirationSeconds == nil { if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized") t.Error("resultCertificateExpirationSeconds metric not initialized")
} }
if resultDomainExpirationSeconds == nil {
t.Error("resultDomainExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil { if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized") t.Error("resultEndpointSuccess metric not initialized")
} }
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
ConditionResults: []*endpoint.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true}, {Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true}, {Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
}, },
Success: true, Success: true,
CertificateExpiration: 49 * time.Hour, CertificateExpiration: 49 * time.Hour,
DomainExpiration: 25 * time.Hour,
}, []string{}) }, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(` err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code # 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 # HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge # 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 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 # HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge # 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 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{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true}, {Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false}, {Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
}, },
Success: false, Success: false,
CertificateExpiration: 47 * time.Hour, CertificateExpiration: 47 * time.Hour,
DomainExpiration: 24 * time.Hour,
}, []string{}) }, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(` err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code # 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 # HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge # 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 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 # HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge # 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 gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
+1 -1
View File
@@ -28,7 +28,7 @@ type Config struct {
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values. // ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
func (c *Config) ValidateAndSetDefaults() bool { func (c *Config) ValidateAndSetDefaults() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults()) return (c.Basic == nil || c.Basic.isValid()) && (c.OIDC == nil || c.OIDC.ValidateAndSetDefaults())
} }
// RegisterHandlers registers all handlers required based on the security configuration // RegisterHandlers registers all handlers required based on the security configuration
+75 -5
View File
@@ -10,12 +10,82 @@ import (
) )
func TestConfig_ValidateAndSetDefaults(t *testing.T) { func TestConfig_ValidateAndSetDefaults(t *testing.T) {
c := &Config{ validBasicConfig := &BasicConfig{
Basic: nil, Username: "test",
OIDC: nil, PasswordBcryptHashBase64Encoded: "somevalue",
} }
if c.ValidateAndSetDefaults() { validOIDCConfig := &OIDCConfig{
t.Error("expected empty config to be valid") IssuerURL: "testurl",
RedirectURL: "testredirecturl/authorization-code/callback",
ClientID: "testid",
ClientSecret: "testsecret",
Scopes: []string{"testscope"},
}
type Scenario struct {
Name string
Config *Config
ExpectValid bool
}
scenarios := []Scenario{
{
Name: "empty",
Config: &Config{
Basic: nil,
OIDC: nil,
},
ExpectValid: true,
},
{
Name: "empty-basic",
Config: &Config{
Basic: &BasicConfig{},
OIDC: nil,
},
ExpectValid: false,
},
{
Name: "empty-oidc",
Config: &Config{
Basic: nil,
OIDC: &OIDCConfig{},
},
ExpectValid: false,
},
{
Name: "valid-basic-only",
Config: &Config{
Basic: validBasicConfig,
OIDC: nil,
},
ExpectValid: true,
},
{
Name: "valid-oidc-only",
Config: &Config{
Basic: nil,
OIDC: validOIDCConfig,
},
ExpectValid: true,
},
{
Name: "valid-basic-and-oidc",
Config: &Config{
Basic: validBasicConfig,
OIDC: validOIDCConfig,
},
ExpectValid: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
isValid := scenario.Config.ValidateAndSetDefaults()
if isValid && !scenario.ExpectValid {
t.Errorf("scenario %s: expected config to be invalid", scenario.Name)
} else if !isValid && scenario.ExpectValid {
t.Errorf("scenario %s: expected config to be valid", scenario.Name)
}
})
} }
} }
+2 -7
View File
@@ -1,6 +1,7 @@
package memory package memory
import ( import (
"slices"
"sort" "sort"
"sync" "sync"
"time" "time"
@@ -237,13 +238,7 @@ func (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {
func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int { func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
var keysToDelete []string var keysToDelete []string
for _, existingKey := range s.endpointCache.GetKeysByPattern("*", 0) { for _, existingKey := range s.endpointCache.GetKeysByPattern("*", 0) {
shouldDelete := true shouldDelete := !slices.Contains(keys, existingKey)
for _, k := range keys {
if existingKey == k {
shouldDelete = false
break
}
}
if shouldDelete { if shouldDelete {
keysToDelete = append(keysToDelete, existingKey) keysToDelete = append(keysToDelete, existingKey)
} }
+11 -11
View File
@@ -797,7 +797,7 @@ func TestSuiteResultOrdering(t *testing.T) {
baseTime := time.Now().Add(-5 * time.Hour) baseTime := time.Now().Add(-5 * time.Hour)
timestamps := make([]time.Time, 5) timestamps := make([]time.Time, 5)
for i := 0; i < 5; i++ { for i := range 5 {
timestamp := baseTime.Add(time.Duration(i) * time.Hour) timestamp := baseTime.Add(time.Duration(i) * time.Hour)
timestamps[i] = timestamp timestamps[i] = timestamp
result := &suite.Result{ result := &suite.Result{
@@ -878,7 +878,7 @@ func TestSuiteResultOrdering(t *testing.T) {
smallSuite := &suite.Suite{Name: "small-suite", Group: "test"} smallSuite := &suite.Suite{Name: "small-suite", Group: "test"}
// Insert 6 results, should keep only the newest 3 // Insert 6 results, should keep only the newest 3
for i := 0; i < 6; i++ { for i := range 6 {
result := &suite.Result{ result := &suite.Result{
Name: smallSuite.Name, Name: smallSuite.Name,
Group: smallSuite.Group, Group: smallSuite.Group,
@@ -925,7 +925,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
// Create endpoints for concurrent testing // Create endpoints for concurrent testing
endpoints := make([]*endpoint.Endpoint, numGoroutines) endpoints := make([]*endpoint.Endpoint, numGoroutines)
for i := 0; i < numGoroutines; i++ { for i := range numGoroutines {
endpoints[i] = &endpoint.Endpoint{ endpoints[i] = &endpoint.Endpoint{
Name: "endpoint-" + string(rune('A'+i)), Name: "endpoint-" + string(rune('A'+i)),
Group: "concurrent", Group: "concurrent",
@@ -934,12 +934,12 @@ func TestStore_ConcurrentAccess(t *testing.T) {
} }
// Concurrently insert results for different endpoints // Concurrently insert results for different endpoints
for i := 0; i < numGoroutines; i++ { for i := range numGoroutines {
wg.Add(1) wg.Add(1)
go func(endpointIndex int) { go func(endpointIndex int) {
defer wg.Done() defer wg.Done()
ep := endpoints[endpointIndex] ep := endpoints[endpointIndex]
for j := 0; j < resultsPerGoroutine; j++ { for j := range resultsPerGoroutine {
result := &endpoint.Result{ result := &endpoint.Result{
Success: j%2 == 0, Success: j%2 == 0,
Timestamp: time.Now().Add(time.Duration(j) * time.Minute), Timestamp: time.Now().Add(time.Duration(j) * time.Minute),
@@ -978,7 +978,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
// Create suites for concurrent testing // Create suites for concurrent testing
suites := make([]*suite.Suite, numGoroutines) suites := make([]*suite.Suite, numGoroutines)
for i := 0; i < numGoroutines; i++ { for i := range numGoroutines {
suites[i] = &suite.Suite{ suites[i] = &suite.Suite{
Name: "suite-" + string(rune('A'+i)), Name: "suite-" + string(rune('A'+i)),
Group: "concurrent", Group: "concurrent",
@@ -986,12 +986,12 @@ func TestStore_ConcurrentAccess(t *testing.T) {
} }
// Concurrently insert results for different suites // Concurrently insert results for different suites
for i := 0; i < numGoroutines; i++ { for i := range numGoroutines {
wg.Add(1) wg.Add(1)
go func(suiteIndex int) { go func(suiteIndex int) {
defer wg.Done() defer wg.Done()
su := suites[suiteIndex] su := suites[suiteIndex]
for j := 0; j < resultsPerGoroutine; j++ { for j := range resultsPerGoroutine {
result := &suite.Result{ result := &suite.Result{
Name: su.Name, Name: su.Name,
Group: su.Group, Group: su.Group,
@@ -1036,7 +1036,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 5; i++ { for i := range 5 {
result := &endpoint.Result{ result := &endpoint.Result{
Success: true, Success: true,
Timestamp: time.Now(), Timestamp: time.Now(),
@@ -1050,7 +1050,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 5; i++ { for i := range 5 {
result := &suite.Result{ result := &suite.Result{
Name: testSuite.Name, Name: testSuite.Name,
Group: testSuite.Group, Group: testSuite.Group,
@@ -1066,7 +1066,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 10; i++ { for range 10 {
store.GetAllEndpointStatuses(&paging.EndpointStatusParams{}) store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
store.GetAllSuiteStatuses(&paging.SuiteStatusParams{}) store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
time.Sleep(1 * time.Millisecond) time.Sleep(1 * time.Millisecond)
+2 -2
View File
@@ -11,10 +11,10 @@ import (
func BenchmarkShallowCopyEndpointStatus(b *testing.B) { func BenchmarkShallowCopyEndpointStatus(b *testing.B) {
ep := &testEndpoint ep := &testEndpoint
status := endpoint.NewStatus(ep.Group, ep.Name) status := endpoint.NewStatus(ep.Group, ep.Name)
for i := 0; i < storage.DefaultMaximumNumberOfResults; i++ { for range storage.DefaultMaximumNumberOfResults {
AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents) AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
} }
for n := 0; n < b.N; n++ { for b.Loop() {
ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20)) ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20))
} }
b.ReportAllocs() b.ReportAllocs()
+4 -5
View File
@@ -13,7 +13,7 @@ import (
func TestAddResult(t *testing.T) { func TestAddResult(t *testing.T) {
ep := &endpoint.Endpoint{Name: "name", Group: "group"} ep := &endpoint.Endpoint{Name: "name", Group: "group"}
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name) endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
for i := 0; i < (storage.DefaultMaximumNumberOfResults+storage.DefaultMaximumNumberOfEvents)*2; i++ { for i := range (storage.DefaultMaximumNumberOfResults + storage.DefaultMaximumNumberOfEvents) * 2 {
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents) AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
} }
if len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults { if len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults {
@@ -30,7 +30,7 @@ func TestShallowCopyEndpointStatus(t *testing.T) {
ep := &endpoint.Endpoint{Name: "name", Group: "group"} ep := &endpoint.Endpoint{Name: "name", Group: "group"}
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name) endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
ts := time.Now().Add(-25 * time.Hour) ts := time.Now().Add(-25 * time.Hour)
for i := 0; i < 25; i++ { for i := range 25 {
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents) AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
ts = ts.Add(time.Hour) ts = ts.Add(time.Hour)
} }
@@ -74,9 +74,9 @@ func TestShallowCopySuiteStatus(t *testing.T) {
Key: testSuite.Key(), Key: testSuite.Key(),
Results: []*suite.Result{}, Results: []*suite.Result{},
} }
ts := time.Now().Add(-25 * time.Hour) ts := time.Now().Add(-25 * time.Hour)
for i := 0; i < 25; i++ { for i := range 25 {
result := &suite.Result{ result := &suite.Result{
Name: testSuite.Name, Name: testSuite.Name,
Group: testSuite.Group, Group: testSuite.Group,
@@ -169,4 +169,3 @@ func TestShallowCopySuiteStatus(t *testing.T) {
} }
}) })
} }
+4
View File
@@ -120,5 +120,9 @@ func (s *Store) createPostgresSchema() error {
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD COLUMN IF NOT EXISTS suite_result_id BIGINT REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`) _, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD COLUMN IF NOT EXISTS suite_result_id BIGINT REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`)
// Create index for suite_result_id // Create index for suite_result_id
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`) _, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)
// Create index for endpoint_result_conditions
_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_result_conditions_endpoint_result_id ON endpoint_result_conditions (endpoint_result_id)`)
// Create index for endpoint_results
_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_results_endpoint_id ON endpoint_results (endpoint_id)`)
return err return err
} }
+21 -15
View File
@@ -1176,23 +1176,26 @@ func (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
// Collect all suite statuses first before making nested queries.
// The lib/pq driver requires the previous query to be fully closed before starting
// new queries in the same transaction, otherwise it returns "pq: unexpected Parse
// response 'C'" errors. See: https://github.com/TwiN/gatus/issues/1435
var suiteStatuses []*suite.Status var suiteStatuses []*suite.Status
suiteIDs := make(map[*suite.Status]int64) // Track suite_id for each status
for rows.Next() { for rows.Next() {
status := &suite.Status{Results: []*suite.Result{}}
var suiteID int64 var suiteID int64
var key, name, group string if err = rows.Scan(&suiteID, &status.Key, &status.Name, &status.Group); err != nil {
if err = rows.Scan(&suiteID, &key, &name, &group); err != nil { rows.Close()
return nil, err return nil, err
} }
suiteStatuses = append(suiteStatuses, status)
suiteIDs[status] = suiteID
}
rows.Close()
status := &suite.Status{ for _, status := range suiteStatuses {
Name: name,
Group: group,
Key: key,
Results: []*suite.Result{},
}
// Get suite results with pagination // Get suite results with pagination
pageSize := 20 pageSize := 20
page := 1 page := 1
@@ -1205,17 +1208,16 @@ func (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.
} }
} }
suiteID := suiteIDs[status]
status.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize) status.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)
if err != nil { if err != nil {
logr.Errorf("[sql.GetAllSuiteStatuses] Failed to retrieve results for suite_id=%d: %s", suiteID, err.Error()) logr.Errorf("[sql.GetAllSuiteStatuses] Failed to retrieve results for suite_id=%d: %s", suiteID, err.Error())
} }
// Populate Name and Group fields on each result // Populate Name and Group fields on each result
for _, result := range status.Results { for _, result := range status.Results {
result.Name = name result.Name = status.Name
result.Group = group result.Group = status.Group
} }
suiteStatuses = append(suiteStatuses, status)
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
@@ -1466,7 +1468,6 @@ func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) (
logr.Errorf("[sql.getSuiteResults] Query failed: %v", err) logr.Errorf("[sql.getSuiteResults] Query failed: %v", err)
return nil, err return nil, err
} }
defer rows.Close()
type suiteResultData struct { type suiteResultData struct {
result *suite.Result result *suite.Result
id int64 id int64
@@ -1494,6 +1495,11 @@ func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) (
id: suiteResultID, id: suiteResultID,
}) })
} }
// Close the rows before making nested queries to avoid PostgreSQL driver issues.
// The lib/pq driver requires the previous query to be fully closed before starting
// new queries in the same transaction, otherwise it returns "pq: unexpected Parse
// response 'C'" errors. See: https://github.com/TwiN/gatus/issues/1435
rows.Close()
// Reverse the results to get chronological order (oldest to newest) // Reverse the results to get chronological order (oldest to newest)
for i := len(resultsData)/2 - 1; i >= 0; i-- { for i := len(resultsData)/2 - 1; i >= 0; i-- {
+1 -1
View File
@@ -900,7 +900,7 @@ func TestEventOrderingFix(t *testing.T) {
} }
// Create many events over time // Create many events over time
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
for i := 0; i < 50; i++ { for i := range 50 {
result := &endpoint.Result{ result := &endpoint.Result{
Success: i%2 == 0, // Alternate between true/false to create events Success: i%2 == 0, // Alternate between true/false to create events
Timestamp: baseTime.Add(time.Duration(i) * time.Hour), Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
+3 -3
View File
@@ -53,11 +53,11 @@ func BenchmarkStore_GetAllEndpointStatuses(b *testing.B) {
numberOfEndpoints := []int{10, 25, 50, 100} numberOfEndpoints := []int{10, 25, 50, 100}
for _, numberOfEndpointsToCreate := range numberOfEndpoints { for _, numberOfEndpointsToCreate := range numberOfEndpoints {
// Create endpoints and insert results // Create endpoints and insert results
for i := 0; i < numberOfEndpointsToCreate; i++ { for i := range numberOfEndpointsToCreate {
ep := testEndpoint ep := testEndpoint
ep.Name = "endpoint" + strconv.Itoa(i) ep.Name = "endpoint" + strconv.Itoa(i)
// InsertEndpointResult 20 results for each endpoint // InsertEndpointResult 20 results for each endpoint
for j := 0; j < 20; j++ { for range 20 {
scenario.Store.InsertEndpointResult(&ep, &testSuccessfulResult) scenario.Store.InsertEndpointResult(&ep, &testSuccessfulResult)
} }
} }
@@ -191,7 +191,7 @@ func BenchmarkStore_GetEndpointStatusByKey(b *testing.B) {
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
for i := 0; i < 50; i++ { for range 50 {
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult) scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult) scenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
} }
+13
View File
@@ -7,6 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -506,6 +507,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
}, },
}, },
}, },
{
Name: "clickup",
AlertType: alert.TypeClickUp,
AlertingConfig: &alerting.Config{
ClickUp: &clickup.AlertProvider{
DefaultConfig: clickup.Config{
ListID: "test-list-id",
Token: "test-token",
},
},
},
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
+146 -464
View File
@@ -8,16 +8,19 @@
"name": "gatus", "name": "gatus",
"version": "4.0.0", "version": "4.0.0",
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.23", "chart.js": "^4.5.1",
"@heroicons/vue": "^2.2.0", "chartjs-adapter-date-fns": "^3.0.0",
"@vueuse/core": "^13.6.0", "chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"core-js": "^3.45.0", "core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"radix-vue": "^1.9.17", "marked": "^16.4.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
@@ -1861,42 +1864,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@hapi/hoek": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -1912,30 +1879,6 @@
"@hapi/hoek": "^9.0.0" "@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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1974,24 +1917,6 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@@ -2063,6 +1988,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@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": { "node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -2244,41 +2175,6 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==", "integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true "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": { "node_modules/@trysound/sax": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -2472,11 +2368,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/web-bluetooth": { "node_modules/@types/trusted-types": {
"version": "0.0.21", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.5.3", "version": "8.5.3",
@@ -3221,44 +3118,6 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==", "integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true "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": { "node_modules/@webassemblyjs/ast": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -3670,18 +3529,6 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/array-flatten": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
@@ -4170,6 +4017,37 @@
"node": ">=4" "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": { "node_modules/chokidar": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -5002,6 +4880,16 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -5146,12 +5034,6 @@
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==", "integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
"dev": true "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5298,6 +5180,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "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": { "node_modules/domutils": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -6237,7 +6128,8 @@
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": { "node_modules/fast-glob": {
"version": "3.2.11", "version": "3.2.11",
@@ -7798,6 +7690,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/mdn-data": {
"version": "2.0.14", "version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -9535,88 +9439,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -10821,6 +10643,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "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,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-check": { "node_modules/type-check": {
@@ -11026,30 +10849,14 @@
} }
} }
}, },
"node_modules/vue-demi": { "node_modules/vue-chartjs": {
"version": "0.14.10", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"hasInstallScript": true,
"license": "MIT", "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": { "peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1", "chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.6.0" "vue": "^3.0.0-0 || ^2.7.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
} }
}, },
"node_modules/vue-eslint-parser": { "node_modules/vue-eslint-parser": {
@@ -13323,38 +13130,6 @@
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true "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": { "@hapi/hoek": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -13370,20 +13145,6 @@
"@hapi/hoek": "^9.0.0" "@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": { "@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "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==", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true "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": { "@jridgewell/gen-mapping": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@@ -13483,6 +13228,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@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": { "@leichtgewicht/ip-codec": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -13629,27 +13379,6 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==", "integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true "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": { "@trysound/sax": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -13840,10 +13569,11 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/web-bluetooth": { "@types/trusted-types": {
"version": "0.0.21", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
}, },
"@types/ws": { "@types/ws": {
"version": "8.5.3", "version": "8.5.3",
@@ -14421,27 +14151,6 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==", "integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true "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": { "@webassemblyjs/ast": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -14776,14 +14485,6 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true "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": { "array-flatten": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
@@ -15123,6 +14824,26 @@
"supports-color": "^5.3.0" "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": { "chokidar": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "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", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "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": { "debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -15822,11 +15548,6 @@
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==", "integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
"dev": true "dev": true
}, },
"defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
},
"depd": { "depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -15936,6 +15657,14 @@
"domelementtype": "^2.2.0" "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": { "domutils": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -16615,7 +16344,8 @@
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": { "fast-glob": {
"version": "3.2.11", "version": "3.2.11",
@@ -17784,6 +17514,11 @@
"semver": "^6.0.0" "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": { "mdn-data": {
"version": "2.0.14", "version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -18970,60 +18705,6 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true "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": { "randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -19954,7 +19635,8 @@
"tslib": { "tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "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": { "type-check": {
"version": "0.4.0", "version": "0.4.0",
@@ -20092,10 +19774,10 @@
"@vue/shared": "3.5.18" "@vue/shared": "3.5.18"
} }
}, },
"vue-demi": { "vue-chartjs": {
"version": "0.14.10", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"requires": {} "requires": {}
}, },
"vue-eslint-parser": { "vue-eslint-parser": {
+8 -1
View File
@@ -8,22 +8,29 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"core-js": "^3.45.0", "core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.25.1",
"@vue/cli-plugin-babel": "^5.0.8", "@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8", "@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8", "@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-service": "^5.0.8", "@vue/cli-service": "^5.0.8",
"@vue/compiler-sfc": "^3.5.18", "@vue/compiler-sfc": "^3.5.18",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"@babel/eslint-parser": "^7.25.1",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-plugin-vue": "^9.28.0", "eslint-plugin-vue": "^9.28.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
+4 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<script type="text/javascript"> <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 }}", loginSubtitle: "{{ .UI.LoginSubtitle }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash // Initialize theme immediately to prevent flash
(function() { (function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1]; const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
@@ -19,10 +19,10 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="{{ .UI.Favicon.Size32x32 }}" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="{{ .UI.Favicon.Size16x16 }}" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="{{ .UI.Favicon.Default }}" />
<link rel="stylesheet" href="/css/custom.css" /> <link rel="stylesheet" href="/css/custom.css" />
<meta name="description" content="{{ .UI.Description }}" /> <meta name="description" content="{{ .UI.Description }}" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+16 -14
View File
@@ -17,7 +17,7 @@
:is="link ? 'a' : 'div'" :is="link ? 'a' : 'div'"
:href="link" :href="link"
target="_blank" target="_blank"
class="flex items-center gap-3 hover:opacity-80 transition-opacity" :class="['flex items-center gap-3', link && 'hover:opacity-80 transition-opacity']"
> >
<div class="w-12 h-12 flex items-center justify-center"> <div class="w-12 h-12 flex items-center justify-center">
<img <img
@@ -112,13 +112,14 @@
<div v-else id="login-container" class="flex items-center justify-center min-h-screen p-4"> <div v-else id="login-container" class="flex items-center justify-center min-h-screen p-4">
<Card class="w-full max-w-md"> <Card class="w-full max-w-md">
<CardHeader class="text-center"> <CardHeader class="text-center">
<img <div v-if="logo" class="flex items-center justify-center gap-4 mb-4">
src="./assets/logo.svg" <img :src="logo" alt="" class="w-20 h-20 object-contain" />
alt="Gatus" <div class="w-px h-12 bg-border"></div>
class="w-20 h-20 mx-auto mb-4" <img src="./assets/logo.svg" alt="Gatus" class="w-20 h-20" />
/> </div>
<CardTitle class="text-3xl">Gatus</CardTitle> <img v-else src="./assets/logo.svg" alt="Gatus" class="w-20 h-20 mx-auto mb-4" />
<p class="text-muted-foreground mt-2">System Monitoring Dashboard</p> <CardTitle class="text-3xl">{{ header }}</CardTitle>
<p class="text-muted-foreground mt-2">{{ loginSubtitle }}</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="route && route.query.error" class="mb-6"> <div v-if="route && route.query.error" class="mb-6">
@@ -133,7 +134,7 @@
</div> </div>
<a <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" 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" @click="isOidcLoading = true"
> >
@@ -153,7 +154,6 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Menu, X, LogIn } from 'lucide-vue-next' import { Menu, X, LogIn } from 'lucide-vue-next'
@@ -162,7 +162,6 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import Social from './components/Social.vue' import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue' import Tooltip from './components/Tooltip.vue'
import Loading from './components/Loading.vue' import Loading from './components/Loading.vue'
import { SERVER_URL } from '@/main'
const route = useRoute() const route = useRoute()
@@ -193,17 +192,20 @@ const buttons = computed(() => {
return window.config && window.config.buttons ? window.config.buttons : [] return window.config && window.config.buttons ? window.config.buttons : []
}) })
const loginSubtitle = computed(() => {
return window.config && window.config.loginSubtitle && window.config.loginSubtitle !== '{{ .UI.LoginSubtitle }}' ? window.config.loginSubtitle : "System Monitoring Dashboard"
})
// Methods // Methods
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' }) const response = await fetch(`/api/v1/config`, { credentials: 'include' })
retrievedConfig.value = true
if (response.status === 200) { if (response.status === 200) {
const data = await response.json() const data = await response.json()
config.value = data config.value = data
announcements.value = data.announcements || [] announcements.value = data.announcements || []
} }
retrievedConfig.value = true
} catch (error) { } catch (error) {
console.error('Failed to fetch config:', error) console.error('Failed to fetch config:', error)
retrievedConfig.value = true retrievedConfig.value = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+36 -28
View File
@@ -44,17 +44,10 @@
:key="date" :key="date"
class="relative" 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 --> <!-- Date Header -->
<div class="flex items-center gap-3 mb-2 relative"> <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"> <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) }} {{ formatDate(date) }}
</time> </time>
</div> </div>
@@ -69,38 +62,60 @@
class="relative" class="relative"
> >
<!-- Timeline Icon --> <!-- Timeline Icon -->
<div <div
:class="[ :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 getTypeClasses(announcement.type).border
]" ]"
> >
<component <component
:is="getTypeIcon(announcement.type)" :is="getTypeIcon(announcement.type)"
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]" :class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
/> />
</div> </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 --> <!-- Announcement Card -->
<div <div
:class="[ :class="[
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm', 'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
getTypeClasses(announcement.type).background getTypeClasses(announcement.type).background
]" ]"
> >
<div class="flex items-center justify-between gap-3"> <div class="flex items-center gap-3">
<div class="flex-1 min-w-0"> <time
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
</div>
<time
:class="[ :class="[
'text-xs font-mono whitespace-nowrap', 'text-sm font-mono whitespace-nowrap flex-shrink-0',
getTypeClasses(announcement.type).text getTypeClasses(announcement.type).text
]" ]"
:title="formatFullTimestamp(announcement.timestamp)" :title="formatFullTimestamp(announcement.timestamp)"
> >
{{ formatTime(announcement.timestamp) }} {{ formatTime(announcement.timestamp) }}
</time> </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> </div>
</div> </div>
@@ -116,6 +131,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next' import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
import { formatAnnouncementMessage } from '@/utils/markdown'
// Props // Props
const props = defineProps({ const props = defineProps({
@@ -220,14 +236,6 @@ const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none 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 formatDate = (dateString) => {
const date = new Date(dateString) const date = new Date(dateString)
const today = new Date() const today = new Date()
@@ -291,4 +299,4 @@ const formatFullTimestamp = (timestamp) => {
margin-left: 1.5rem; margin-left: 1.5rem;
} }
} }
</style> </style>
+4 -4
View File
@@ -62,7 +62,6 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-undef */
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
@@ -142,8 +141,8 @@ const formattedResponseTime = computed(() => {
return `~${avgMs}ms` return `~${avgMs}ms`
} else { } else {
// Show min-max range // Show min-max range
const minMs = Math.round(min) const minMs = Math.trunc(min)
const maxMs = Math.round(max) const maxMs = Math.trunc(max)
// If min and max are the same, show single value // If min and max are the same, show single value
if (minMs === maxMs) { if (minMs === maxMs) {
return `${minMs}ms` return `${minMs}ms`
@@ -154,7 +153,8 @@ const formattedResponseTime = computed(() => {
const oldestResultTime = computed(() => { const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return '' 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(() => { const newestResultTime = computed(() => {
-1
View File
@@ -29,7 +29,6 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-undef */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next' import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -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 emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => { const handleFilterChange = (value, store = true) => {
filterBy.value = value filterBy.value = value
localStorage.setItem('gatus:filter-by', value) if (store)
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first // Reset all filter states first
emit('update:showOnlyFailing', false) emit('update:showOnlyFailing', false)
@@ -80,9 +81,11 @@ const handleFilterChange = (value) => {
} }
} }
const handleSortChange = (value) => { const handleSortChange = (value, store = true) => {
sortBy.value = value sortBy.value = value
localStorage.setItem('gatus:sort-by', value) if (store)
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value) emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group') emit('update:groupByGroup', value === 'group')
@@ -93,8 +96,8 @@ const handleSortChange = (value) => {
} }
onMounted(() => { onMounted(() => {
// Apply saved filter/sort state on load // Apply saved or application wide filter/sort state on load but do not store it in localstorage
handleFilterChange(filterBy.value) handleFilterChange(filterBy.value, false)
handleSortChange(sortBy.value) handleSortChange(sortBy.value, false)
}) })
</script> </script>
-1
View File
@@ -54,7 +54,6 @@
<script setup> <script setup>
/* eslint-disable no-undef */
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { Sun, Moon, RefreshCw } from 'lucide-vue-next' import { Sun, Moon, RefreshCw } from 'lucide-vue-next'
+2 -2
View File
@@ -31,7 +31,7 @@
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<p class="text-xs text-muted-foreground">Success Rate: {{ successRate }}%</p> <p class="text-xs text-muted-foreground">Success Rate: {{ successRate }}%</p>
<p class="text-xs text-muted-foreground" v-if="averageDuration">{{ averageDuration }}ms avg</p> <p class="text-xs text-muted-foreground" v-if="averageDuration !== null">{{ averageDuration }}ms avg</p>
</div> </div>
<div class="flex gap-0.5"> <div class="flex gap-0.5">
<div <div
@@ -126,7 +126,7 @@ const averageDuration = computed(() => {
const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0) const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0)
// Convert nanoseconds to milliseconds // Convert nanoseconds to milliseconds
return Math.round((total / props.suite.results.length) / 1000000) return Math.trunc((total / props.suite.results.length) / 1000000)
}) })
const oldestResultTime = computed(() => { const oldestResultTime = computed(() => {
+2 -3
View File
@@ -46,7 +46,7 @@
{{ endpoint.success ? '✓' : '✗' }} {{ endpoint.success ? '✓' : '✗' }}
</span> </span>
<span class="truncate">{{ endpoint.name }}</span> <span class="truncate">{{ endpoint.name }}</span>
<span class="text-muted-foreground">({{ (endpoint.duration / 1000000).toFixed(0) }}ms)</span> <span class="text-muted-foreground">({{ Math.trunc(endpoint.duration / 1000000) }}ms)</span>
</div> </div>
<div v-if="result.endpointResults.length > 5" class="text-xs text-muted-foreground"> <div v-if="result.endpointResults.length > 5" class="text-xs text-muted-foreground">
... and {{ result.endpointResults.length - 5 }} more ... and {{ result.endpointResults.length - 5 }} more
@@ -60,7 +60,7 @@
{{ isSuiteResult ? 'Total Duration' : 'Response Time' }} {{ isSuiteResult ? 'Total Duration' : 'Response Time' }}
</div> </div>
<div class="font-mono text-xs"> <div class="font-mono text-xs">
{{ isSuiteResult ? (result.duration / 1000000).toFixed(0) : (result.duration / 1000000).toFixed(0) }}ms {{ Math.trunc(result.duration / 1000000) }}ms
</div> </div>
</div> </div>
@@ -95,7 +95,6 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue' import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { prettifyTimestamp } from '@/utils/time' import { prettifyTimestamp } from '@/utils/time'
+1 -2
View File
@@ -5,9 +5,8 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority' import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils' import { combineClasses } from '@/utils/misc'
defineProps({ defineProps({
variant: { variant: {
+1 -2
View File
@@ -8,9 +8,8 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority' import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils' import { combineClasses } from '@/utils/misc'
defineProps({ defineProps({
variant: { variant: {
+1 -1
View File
@@ -5,5 +5,5 @@
</template> </template>
<script setup> <script setup>
import { combineClasses } from '@/lib/utils' import { combineClasses } from '@/utils/misc'
</script> </script>
@@ -5,5 +5,5 @@
</template> </template>
<script setup> <script setup>
import { combineClasses } from '@/lib/utils' import { combineClasses } from '@/utils/misc'
</script> </script>
@@ -5,5 +5,5 @@
</template> </template>
<script setup> <script setup>
import { combineClasses } from '@/lib/utils' import { combineClasses } from '@/utils/misc'
</script> </script>

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