Compare commits

...
25 Commits
Author SHA1 Message Date
Andrej VaňoandGitHub 3e713dfee3 docs(alerting): Fix the homeassistant event structure example (#1213)
docs(Homeassistant): Fix the event structure in the example
2025-08-19 13:03:59 -04:00
TwiNandGitHub 2f99eccf5f fix(ui): Collapse groups by default (#1212) 2025-08-19 10:15:35 -04:00
TwiNandGitHub d37f71eee7 fix(ui): Move announcements above endpoints search bar (#1210) 2025-08-19 07:32:04 -04:00
6d579a4b48 docs: improve extra-labels description (#1208)
* docs: improve `extra-labels` description

* Update README.md

* Update README.md

---------

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

Fixes #1207

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

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

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

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

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

Fixes #1203

* Remove unnecessary code

* Fix new announcement test

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

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

* Remove useless garbage

* Require announcement timestamp

---------

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

* docs: Rename labels to extra-labels

* Fix domain expiration test

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

* Change ui.header default value to Gatus

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

* Fix flaky metrics test

* Add subtle green color to "Gatus"

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

* Fix collapsed group user preferences

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

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

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

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

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

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

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

* chore: update reminder-interval to repeat-interval

* chore: adapt repeat interval feature after merge

* chore: adapt repeat interval feature after merge

* RepeatInterval => MinimumRepeatInterval

* fix merge issues

(cherry picked from commit 9b2161556bddf01d385f97dafac2515857190ae5)

* rename and move MiniumRepeatInterval

* move MiniumRepeatInterval (again)

---------

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

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

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

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

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

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

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

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

* refactor: refactor pointer conversion utility and update related tests

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

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

* refactor: refactor utility functions and improve test coverage

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

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

* missing labels parameter

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

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

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

* Update main.go

* Update config/config.go

* docs: improve documentation formatting, examples, and readability

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

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

* docs: enhance custom labels support in Prometheus metrics

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

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

* refactor: rename and refactor metric labels to use ExtraLabels

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

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

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

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

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

* test: improve initialization and labeling of Prometheus metrics

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

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

* test: improve Prometheus metrics testing for extra label handling

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

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

* refactor: refactor metrics to support custom Prometheus registries

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

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

* Revert README.md to a previous version

* docs: document support for custom metric labels in endpoints

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

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

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-05 12:26:50 -04:00
TwiN 4667fdbc15 chore: Use the right logging library 2025-08-02 17:31:53 -04:00
82 changed files with 5353 additions and 2337 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

+1 -1
View File
@@ -25,6 +25,6 @@ jobs:
go-version: 1.24.1
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Benchmark
run: go test -bench=. ./storage/store
+5 -1
View File
@@ -26,10 +26,15 @@ jobs:
gh issue edit "$NUMBER" --add-label "feature"
elif [[ $TITLE == "fix"* ]]; then
gh issue edit "$NUMBER" --add-label "bug"
elif [[ $TITLE == "docs"* ]]; then
gh issue edit "$NUMBER" --add-label "documentation"
fi
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
gh issue edit "$NUMBER" --add-label "area/alerting"
fi
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
gh issue edit "$NUMBER" --add-label "area/ui"
fi
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
gh issue edit "$NUMBER" --add-label "area/storage"
fi
@@ -39,4 +44,3 @@ jobs:
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
gh issue edit "$NUMBER" --add-label "area/metrics"
fi
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
+1 -1
View File
@@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
+2 -2
View File
@@ -11,9 +11,9 @@ jobs:
publish-latest:
runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 120
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
+1 -1
View File
@@ -13,6 +13,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- run: make frontend-install-dependencies
- run: make frontend-build
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 1.24.1
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build binary to make sure it works
run: go build
- name: Test
+113 -50
View File
@@ -32,7 +32,7 @@ For more details, see [Usage](#usage)
> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).
![Gatus dashboard](.github/assets/dashboard-dark.png)
![Gatus dashboard](.github/assets/dashboard-dark.jpg)
Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
@@ -78,12 +78,14 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Announcements](#announcements)
- [Maintenance](#maintenance)
- [Security](#security)
- [Basic Authentication](#basic-authentication)
- [OIDC](#oidc)
- [TLS Encryption](#tls-encryption)
- [Metrics](#metrics)
- [Custom Labels](#custom-labels)
- [Connectivity](#connectivity)
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
- [Deployment](#deployment)
@@ -123,6 +125,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Response time (chart)](#response-time-chart)
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
- [API](#api)
- [Interacting with the API programmatically](#interacting-with-the-api-programmatically)
- [Raw Data](#raw-data)
- [Uptime](#uptime-1)
- [Response Time](#response-time-1)
@@ -160,7 +163,7 @@ The main features of Gatus are:
- **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
- **Dark mode**
![Gatus dashboard conditions](.github/assets/dashboard-conditions.png)
![Gatus dashboard conditions](.github/assets/dashboard-conditions.jpg)
## Usage
@@ -199,7 +202,7 @@ endpoints:
This example would look similar to this:
![Simple example](.github/assets/example.png)
![Simple example](.github/assets/example.jpg)
By default, the configuration file is expected to be at `config/config.yaml`.
@@ -220,34 +223,37 @@ If you want to test it locally, see [Docker](#docker).
## Configuration
| Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Health Status` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
| Parameter | Description | Default |
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `announcements` | [Announcements configuration](#announcements). | `[]` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
@@ -287,13 +293,14 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
You may use the following placeholders in the body (`endpoints[].body`):
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[LOCAL_ADDRESS]` (resolves to the local IP and port like `192.0.2.1:25` or `[2001:db8::1]:80`)
- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N)
- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N (max: 8192))
### External Endpoints
@@ -395,6 +402,38 @@ Here are some examples of conditions you can use:
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Announcements
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
| Parameter | Description | Default |
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
| `announcements` | List of announcements to display | `[]` |
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
| `announcements[].message` | The message to display to users | Required |
Types:
- **outage**: Indicates service disruptions or critical issues (red theme)
- **warning**: Indicates potential issues or important notices (yellow theme)
- **information**: General information or updates (blue theme)
- **operational**: Indicates resolved issues or normal operations (green theme)
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
Example Configuration:
```yaml
announcements:
- timestamp: 2025-08-15T14:00:00Z
type: outage
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
- timestamp: 2025-08-15T16:15:00Z
type: operational
message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-08-15T12:00:00Z
type: information
message: "New monitoring dashboard features will be deployed next week"
```
### Storage
| Parameter | Description | Default |
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
@@ -552,16 +591,17 @@ individual endpoints with configurable descriptions and thresholds.
Alerts are configured at the endpoint level like so:
| Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. | `0` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
Here's an example of what an alert configuration might look like at the endpoint level:
```yaml
@@ -1030,13 +1070,13 @@ automation:
action:
- service: notify.notify
data_template:
title: "Gatus Alert: {{ trigger.event.data.endpoint }}"
title: "Gatus Alert: {{ trigger.event.data.event_data.endpoint }}"
message: >
Status: {{ trigger.event.data.status }}
{% if trigger.event.data.description %}
Description: {{ trigger.event.data.description }}
Status: {{ trigger.event.data.event_data.status }}
{% if trigger.event.data.event_data.description %}
Description: {{ trigger.event.data.event_data.description }}
{% endif %}
{% for condition in trigger.event.data.conditions %}
{% for condition in trigger.event.data.event_data.conditions %}
{{ '✅' if condition.success else '❌' }} {{ condition.condition }}
{% endfor %}
```
@@ -1949,6 +1989,23 @@ endpoint on the same port your application is configured to run on (`web.port`).
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
#### Custom Labels
You can add custom labels to your endpoints Prometheus metrics by defining keyvalue pairs under the `extra-labels` field. For example:
```yaml
endpoints:
- name: front-end
group: core
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 150"
extra-labels:
environment: staging
```
### Connectivity
| Parameter | Description | Default |
@@ -2183,7 +2240,7 @@ This works for SCTP based application.
### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
```yaml
endpoints:
- name: example
@@ -2411,9 +2468,9 @@ endpoints:
- "[STATUS] == 200"
```
The configuration above will result in a dashboard that looks like this:
The configuration above will result in a dashboard that looks like this when sorting by group:
![Gatus Endpoint Groups](.github/assets/endpoint-groups.png)
![Gatus Endpoint Groups](.github/assets/endpoint-groups.jpg)
### Exposing Gatus on a custom path
@@ -2641,6 +2698,11 @@ Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzi
The API will return a JSON payload with the `Content-Type` response header set to `application/json`.
No such header is required to query the API.
#### Interacting with the API programmatically
See [TwiN/gatus-sdk](https://github.com/TwiN/gatus-sdk)
#### Raw Data
Gatus exposes the raw data for one of your monitored endpoints.
This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days.
@@ -2673,6 +2735,7 @@ For instance, if you want the raw response time data for the last 24 hours from
https://example.com/api/v1/endpoints/core_frontend/response-times/24h
```
### Installing as binary
You can download Gatus as a binary using the following command:
```
+4
View File
@@ -6,6 +6,7 @@ import (
"errors"
"strconv"
"strings"
"time"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
@@ -35,6 +36,9 @@ type Alert struct {
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// MinimumReminderInterval is the interval between reminders
MinimumReminderInterval time.Duration `yaml:"minimum-reminder-interval,omitempty"`
// Description of the alert. Will be included in the alert sent.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
+8 -1
View File
@@ -166,7 +166,14 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + formattedConditionResults
var extraLabels string
if len(ep.ExtraLabels) > 0 {
extraLabels = "\n\nExtra labels:\n"
for key, value := range ep.ExtraLabels {
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
}
}
return subject, message + description + extraLabels + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
+31 -1
View File
@@ -76,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider AlertProvider
Alert alert.Alert
Resolved bool
Endpoint *endpoint.Endpoint
ExpectedSubject string
ExpectedBody string
}{
@@ -84,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
@@ -92,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-no-extra-labels",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&endpoint.Endpoint{Name: "endpoint-name"},
scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
+3 -3
View File
@@ -174,21 +174,21 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: true,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"MinimumReminderInterval":0,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
},
}
-2
View File
@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
@@ -203,7 +202,6 @@ func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
// UNPROTECTED ROUTES //
////////////////////////
unprotectedAPIRouter := apiRouter.Group("/")
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
+22 -3
View File
@@ -1,14 +1,17 @@
package api
import (
"encoding/json"
"fmt"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
type ConfigHandler struct {
securityConfig *security.Config
config *config.Config
}
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
@@ -18,8 +21,24 @@ func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
hasOIDC = handler.securityConfig.OIDC != nil
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
}
// Return the config
// Prepare response with announcements
response := map[string]interface{}{
"oidc": hasOIDC,
"authenticated": isAuthenticated,
}
// Add announcements if available, otherwise use empty slice
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
response["announcements"] = handler.config.Announcements
} else {
response["announcements"] = []interface{}{}
}
// Return the config as JSON
c.Set("Content-Type", "application/json")
return c.Status(200).
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
responseBytes, err := json.Marshal(response)
if err != nil {
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
}
return c.Status(200).Send(responseBytes)
}
+2 -2
View File
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != `{"oidc":true,"authenticated":false}` {
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
}
}
+2 -1
View File
@@ -16,6 +16,7 @@ import (
)
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
extraLabels := cfg.GetUniqueExtraMetricLabels()
return func(c *fiber.Ctx) error {
// Check if the success query parameter is present
success, exists := c.Queries()["success"]
@@ -74,7 +75,7 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result)
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
// Return the result
return c.Status(200).SendString("")
+6 -6
View File
@@ -41,26 +41,26 @@ func TestGetHTTPClient(t *testing.T) {
func TestGetDomainExpiration(t *testing.T) {
t.Parallel()
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Fatalf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Make sure the refresh works when the ttl is <24 hours
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
+1 -1
View File
@@ -37,7 +37,7 @@ endpoints:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.215.14"
- "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping
+94
View File
@@ -0,0 +1,94 @@
package announcement
import (
"errors"
"sort"
"time"
)
const (
// TypeOutage represents a service outage
TypeOutage = "outage"
// TypeWarning represents a warning or potential issue
TypeWarning = "warning"
// TypeInformation represents general information
TypeInformation = "information"
// TypeOperational represents operational status or resolved issues
TypeOperational = "operational"
// TypeNone represents no specific type (default)
TypeNone = "none"
)
var (
// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified
ErrInvalidAnnouncementType = errors.New("invalid announcement type")
// ErrEmptyMessage is returned when an announcement has an empty message
ErrEmptyMessage = errors.New("announcement message cannot be empty")
// ErrMissingTimestamp is returned when an announcement has an empty timestamp
ErrMissingTimestamp = errors.New("announcement timestamp must be set")
// validTypes contains all valid announcement types
validTypes = map[string]bool{
TypeOutage: true,
TypeWarning: true,
TypeInformation: true,
TypeOperational: true,
TypeNone: true,
}
)
// Announcement represents a system-wide announcement
type Announcement struct {
// Timestamp is the UTC timestamp when the announcement was made
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
// Type is the type of announcement (outage, warning, information, operational, none)
Type string `yaml:"type" json:"type"`
// Message is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"`
}
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
func (a *Announcement) ValidateAndSetDefaults() error {
// Validate message
if a.Message == "" {
return ErrEmptyMessage
}
// Set default type if empty
if a.Type == "" {
a.Type = TypeNone
}
// Validate type
if !validTypes[a.Type] {
return ErrInvalidAnnouncementType
}
// If timestamp is zero, return an error
if a.Timestamp.IsZero() {
return ErrMissingTimestamp
}
return nil
}
// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)
func SortByTimestamp(announcements []*Announcement) {
sort.Slice(announcements, func(i, j int) bool {
return announcements[i].Timestamp.After(announcements[j].Timestamp)
})
}
// ValidateAndSetDefaults validates a slice of announcements and sets defaults
func ValidateAndSetDefaults(announcements []*Announcement) error {
for _, announcement := range announcements {
if err := announcement.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
+43 -3
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/announcement"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance"
@@ -22,7 +24,6 @@ import (
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2/log"
"gopkg.in/yaml.v3"
)
@@ -99,10 +100,35 @@ type Config struct {
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
// Announcements is the list of system-wide announcements
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
configPath string // path to the file or directory from which config was loaded
lastFileModTime time.Time // last modification time
}
// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints
// in the configuration. It iterates through each endpoint, checks if it is enabled,
// and then collects unique labels from the endpoint's labels map.
func (config *Config) GetUniqueExtraMetricLabels() []string {
labels := make([]string, 0)
for _, ep := range config.Endpoints {
if !ep.IsEnabled() {
continue
}
for label := range ep.ExtraLabels {
if contains(labels, label) {
continue
}
labels = append(labels, label)
}
}
if len(labels) > 1 {
sort.Strings(labels)
}
return labels
}
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
@@ -280,6 +306,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateConnectivityConfig(config); err != nil {
return nil, err
}
if err := validateAnnouncementsConfig(config); err != nil {
return nil, err
}
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
}
@@ -293,6 +322,17 @@ func validateConnectivityConfig(config *Config) error {
return nil
}
func validateAnnouncementsConfig(config *Config) error {
if config.Announcements != nil {
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
return err
}
// Sort announcements by timestamp (newest first) for API response
announcement.SortByTimestamp(config.Announcements)
}
return nil
}
func validateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
@@ -444,7 +484,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
}
@@ -458,7 +498,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
}
+118 -7
View File
@@ -124,7 +124,7 @@ endpoints:
name: "dir-with-two-config-files",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": `endpoints:
"config.yaml": `endpoints:
- name: one
url: https://example.com
conditions:
@@ -135,7 +135,7 @@ endpoints:
url: https://example.org
conditions:
- "len([BODY]) > 0"`,
"config.yml": `endpoints:
"config.yml": `endpoints:
- name: three
url: https://twin.sh/health
conditions:
@@ -237,7 +237,7 @@ endpoints:
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for path, content := range scenario.pathAndFiles {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
}
}
@@ -282,7 +282,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0644)
`), 0o644)
t.Run("config-file-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(configFilePath)
@@ -298,7 +298,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0644); err != nil {
- "[STATUS] == 200"`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
@@ -315,7 +315,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
@@ -713,7 +713,7 @@ func TestParseAndValidateBadConfigBytes(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
badconfig:
- asdsa: w0w
usadasdrl: asdxzczxc
usadasdrl: asdxzczxc
asdas:
- soup
`))
@@ -1943,3 +1943,114 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
})
}
}
func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
tests := []struct {
name string
config *Config
expected []string
}{
{
name: "no-endpoints",
config: &Config{
Endpoints: []*endpoint.Endpoint{},
},
expected: []string{},
},
{
name: "single-endpoint-no-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
},
},
},
expected: []string{},
},
{
name: "single-endpoint-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
},
},
expected: []string{"env", "team"},
},
{
name: "multiple-endpoints-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
"module": "auth",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "staging",
"team": "frontend",
},
},
},
},
expected: []string{"env", "team", "module"},
},
{
name: "multiple-endpoints-with-some-disabled",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(false),
ExtraLabels: map[string]string{
"module": "auth",
},
},
},
},
expected: []string{"env", "team"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
labels := tt.config.GetUniqueExtraMetricLabels()
if len(labels) != len(tt.expected) {
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
}
for _, label := range tt.expected {
if !contains(labels, label) {
t.Errorf("expected label %s to be present", label)
}
}
})
}
}
+10 -2
View File
@@ -99,6 +99,9 @@ type Endpoint struct {
// Headers of the request
Headers map[string]string `yaml:"headers,omitempty"`
// ExtraLabels are key-value pairs that can be used to metric the endpoint
ExtraLabels map[string]string `yaml:"extra-labels,omitempty"`
// Interval is the duration to wait between every status check
Interval time.Duration `yaml:"interval,omitempty"`
@@ -128,6 +131,9 @@ type Endpoint struct {
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
LastReminderSent time.Time `yaml:"-"`
}
// IsEnabled returns whether the endpoint is enabled or not
@@ -338,6 +344,9 @@ func (e *Endpoint) getParsedBody() string {
if err == nil {
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
n, _ := strconv.Atoi(match[15 : len(match)-1])
if n > 8192 {
n = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage
}
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
@@ -417,8 +426,7 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
result.Connected, result.HTTPStatus, err =
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
+27 -9
View File
@@ -12,28 +12,34 @@ import (
const (
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Health Status"
defaultHeader = "Gatus"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
)
var (
defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'")
ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'none', 'failing', or 'unstable'")
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
@@ -72,6 +78,8 @@ func GetDefaultConfig() *Config {
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
}
}
@@ -99,6 +107,16 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}
if len(cfg.DefaultSortBy) == 0 {
cfg.DefaultSortBy = defaultSortBy
} else if cfg.DefaultSortBy != "name" && cfg.DefaultSortBy != "group" && cfg.DefaultSortBy != "health" {
return ErrInvalidDefaultSortBy
}
if len(cfg.DefaultFilterBy) == 0 {
cfg.DefaultFilterBy = defaultFilterBy
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err
+117
View File
@@ -1,6 +1,7 @@
package ui
import (
"errors"
"strconv"
"testing"
)
@@ -25,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
}
func TestButton_Validate(t *testing.T) {
@@ -74,4 +81,114 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
if defaultConfig.DefaultSortBy != defaultSortBy {
t.Error("expected GetDefaultConfig() to return defaultSortBy, got", defaultConfig.DefaultSortBy)
}
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultSortBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultSortBy",
DefaultSortBy: "",
ExpectedError: nil,
ExpectedValue: defaultSortBy,
},
{
Name: "ValidDefaultSortBy_name",
DefaultSortBy: "name",
ExpectedError: nil,
ExpectedValue: "name",
},
{
Name: "ValidDefaultSortBy_group",
DefaultSortBy: "group",
ExpectedError: nil,
ExpectedValue: "group",
},
{
Name: "ValidDefaultSortBy_health",
DefaultSortBy: "health",
ExpectedError: nil,
ExpectedValue: "health",
},
{
Name: "InvalidDefaultSortBy",
DefaultSortBy: "invalid",
ExpectedError: ErrInvalidDefaultSortBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultSortBy: scenario.DefaultSortBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultSortBy != scenario.ExpectedValue {
t.Errorf("expected DefaultSortBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultSortBy)
}
})
}
}
func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultFilterBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultFilterBy",
DefaultFilterBy: "",
ExpectedError: nil,
ExpectedValue: defaultFilterBy,
},
{
Name: "ValidDefaultFilterBy_none",
DefaultFilterBy: "none",
ExpectedError: nil,
ExpectedValue: "none",
},
{
Name: "ValidDefaultFilterBy_failing",
DefaultFilterBy: "failing",
ExpectedError: nil,
ExpectedValue: "failing",
},
{
Name: "ValidDefaultFilterBy_unstable",
DefaultFilterBy: "unstable",
ExpectedError: nil,
ExpectedValue: "unstable",
},
{
Name: "InvalidDefaultFilterBy",
DefaultFilterBy: "invalid",
ExpectedError: ErrInvalidDefaultFilterBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultFilterBy != scenario.ExpectedValue {
t.Errorf("expected DefaultFilterBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultFilterBy)
}
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package config
// toPtr returns a pointer to the given value
func toPtr[T any](value T) *T {
return &value
}
// contains checks if a key exists in the slice
func contains[T comparable](slice []T, key T) bool {
for _, item := range slice {
if item == key {
return true
}
}
return false
}
+4 -4
View File
@@ -10,7 +10,7 @@ require (
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.1.11
github.com/aws/aws-sdk-go v1.55.7
github.com/aws/aws-sdk-go v1.55.8
github.com/coreos/go-oidc/v3 v3.14.1
github.com/gofiber/fiber/v2 v2.52.8
github.com/google/go-github/v48 v48.2.0
@@ -28,7 +28,7 @@ require (
google.golang.org/api v0.242.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.38.0
modernc.org/sqlite v1.38.2
)
require (
@@ -71,7 +71,7 @@ require (
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
@@ -82,7 +82,7 @@ require (
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+14 -12
View File
@@ -22,8 +22,8 @@ github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -159,8 +159,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -259,16 +259,18 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -277,8 +279,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+3
View File
@@ -9,6 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/controller"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
@@ -49,6 +50,7 @@ func main() {
func start(cfg *config.Config) {
go controller.Handle(cfg)
metrics.InitializePrometheusMetrics(cfg, nil)
watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg)
}
@@ -56,6 +58,7 @@ func start(cfg *config.Config) {
func stop(cfg *config.Config) {
watchdog.Shutdown(cfg)
controller.Shutdown()
metrics.UnregisterPrometheusMetrics()
}
func save() {
+92 -28
View File
@@ -3,82 +3,146 @@ package metrics
import (
"strconv"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const namespace = "gatus" // The prefix of the metrics
var (
initializedMetrics bool // Whether the metrics have been initialized
resultTotal *prometheus.CounterVec
resultDurationSeconds *prometheus.GaugeVec
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Track if metrics have been initialized to prevent duplicate registration
metricsInitialized bool
currentRegisterer prometheus.Registerer
)
func initializePrometheusMetrics() {
resultTotal = promauto.NewCounterVec(prometheus.CounterOpts{
// UnregisterPrometheusMetrics unregisters all previously registered metrics
func UnregisterPrometheusMetrics() {
if !metricsInitialized || currentRegisterer == nil {
return
}
// Unregister all metrics if they exist
if resultTotal != nil {
currentRegisterer.Unregister(resultTotal)
}
if resultDurationSeconds != nil {
currentRegisterer.Unregister(resultDurationSeconds)
}
if resultConnectedTotal != nil {
currentRegisterer.Unregister(resultConnectedTotal)
}
if resultCodeTotal != nil {
currentRegisterer.Unregister(resultCodeTotal)
}
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
metricsInitialized = false
currentRegisterer = nil
}
func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer) {
// If metrics are already initialized, unregister them first
if metricsInitialized {
UnregisterPrometheusMetrics()
}
if reg == nil {
reg = prometheus.DefaultRegisterer
}
// Store the registerer for later unregistration
currentRegisterer = reg
extraLabels := cfg.GetUniqueExtraMetricLabels()
resultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_total",
Help: "Number of results per endpoint",
}, []string{"key", "group", "name", "type", "success"})
resultDurationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
}, append([]string{"key", "group", "name", "type", "success"}, extraLabels...))
reg.MustRegister(resultTotal)
resultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_duration_seconds",
Help: "Duration of the request in seconds",
}, []string{"key", "group", "name", "type"})
resultConnectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDurationSeconds)
resultConnectedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_connected_total",
Help: "Total number of results in which a connection was successfully established",
}, []string{"key", "group", "name", "type"})
resultCodeTotal = promauto.NewCounterVec(prometheus.CounterOpts{
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultConnectedTotal)
resultCodeTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_code_total",
Help: "Total number of results by code",
}, []string{"key", "group", "name", "type", "code"})
resultCertificateExpirationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
}, append([]string{"key", "group", "name", "type", "code"}, extraLabels...))
reg.MustRegister(resultCodeTotal)
resultCertificateExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_certificate_expiration_seconds",
Help: "Number of seconds until the certificate expires",
}, []string{"key", "group", "name", "type"})
resultEndpointSuccess = promauto.NewGaugeVec(prometheus.GaugeOpts{
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds)
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_endpoint_success",
Help: "Displays whether or not the endpoint was a success",
}, []string{"key", "group", "name", "type"})
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultEndpointSuccess)
// Mark as initialized
metricsInitialized = true
}
// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
// These metrics will be exposed at /metrics if the metrics are enabled
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result) {
if !initializedMetrics {
initializePrometheusMetrics()
initializedMetrics = true
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, extraLabels []string) {
labelValues := []string{}
for _, label := range extraLabels {
if value, ok := ep.ExtraLabels[label]; ok {
labelValues = append(labelValues, value)
} else {
labelValues = append(labelValues, "")
}
}
endpointType := ep.Type()
resultTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc()
resultDurationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.Duration.Seconds())
resultTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)}, labelValues...)...).Inc()
resultDurationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.Duration.Seconds())
if result.Connected {
resultConnectedTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Inc()
resultConnectedTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Inc()
}
if result.DNSRCode != "" {
resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode).Inc()
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode}, labelValues...)...).Inc()
}
if result.HTTPStatus != 0 {
resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc()
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)}, labelValues...)...).Inc()
}
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds())
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
}
if result.Success {
resultEndpointSuccess.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(1)
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else {
resultEndpointSuccess.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(0)
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(0)
}
}
+111 -10
View File
@@ -5,13 +5,112 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// TestInitializePrometheusMetrics tests metrics initialization with extraLabels.
// Note: Because of the global Prometheus registry, this test can only safely verify one label set per process.
// If the function is called with a different set of labels for the same metric, a panic will occur.
func TestInitializePrometheusMetrics(t *testing.T) {
cfgWithExtras := &config.Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "TestEP",
Group: "G",
URL: "http://x/",
ExtraLabels: map[string]string{
"foo": "foo-val",
"hello": "world-val",
},
},
},
}
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(cfgWithExtras, reg)
// Metrics variables should be non-nil
if resultTotal == nil {
t.Error("resultTotal metric not initialized")
}
if resultDurationSeconds == nil {
t.Error("resultDurationSeconds metric not initialized")
}
if resultConnectedTotal == nil {
t.Error("resultConnectedTotal metric not initialized")
}
if resultCodeTotal == nil {
t.Error("resultCodeTotal metric not initialized")
}
if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized")
}
defer func() {
if r := recover(); r != nil {
t.Errorf("resultTotal.WithLabelValues panicked: %v", r)
}
}()
_ = resultTotal.WithLabelValues("k", "g", "n", "ty", "true", "fval", "hval")
}
// TestPublishMetricsForEndpoint_withExtraLabels ensures extraLabels are included in the exported metrics.
func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
// Only test one label set per process due to Prometheus registry limits.
reg := prometheus.NewRegistry()
cfg := &config.Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "ep-extra",
URL: "https://sample.com",
ExtraLabels: map[string]string{
"foo": "my-foo",
"bar": "my-bar",
},
},
},
}
InitializePrometheusMetrics(cfg, reg)
ep := &endpoint.Endpoint{
Name: "ep-extra",
Group: "g1",
URL: "https://sample.com",
ExtraLabels: map[string]string{
"foo": "my-foo",
"bar": "my-bar",
},
}
result := &endpoint.Result{
HTTPStatus: 200,
Connected: true,
Duration: 2340 * time.Millisecond,
Success: true,
}
// Get labels in sorted order as per GetUniqueExtraMetricLabels
extraLabels := cfg.GetUniqueExtraMetricLabels()
PublishMetricsForEndpoint(ep, result, extraLabels)
expected := `
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{bar="my-bar",foo="my-foo",group="g1",key="g1_ep-extra",name="ep-extra",success="true",type="HTTP"} 1
`
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expected), "gatus_results_total")
if err != nil {
t.Error("metrics export does not include extraLabels as expected:", err)
}
}
func TestPublishMetricsForEndpoint(t *testing.T) {
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(&config.Config{}, reg)
httpEndpoint := &endpoint.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"}
PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{
HTTPStatus: 200,
@@ -23,8 +122,8 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
},
Success: true,
CertificateExpiration: 49 * time.Hour,
})
err := testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
}, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
@@ -57,8 +156,8 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
},
Success: false,
CertificateExpiration: 47 * time.Hour,
})
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
@@ -82,10 +181,12 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
dnsEndpoint := &endpoint.Endpoint{Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com.",
}}
dnsEndpoint := &endpoint.Endpoint{
Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
}
PublishMetricsForEndpoint(dnsEndpoint, &endpoint.Result{
DNSRCode: "NOERROR",
Connected: true,
@@ -94,8 +195,8 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
{Condition: "[DNS_RCODE] == NOERROR", Success: true},
},
Success: true,
})
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
+19 -3
View File
@@ -2,7 +2,9 @@ package watchdog
import (
"errors"
"log"
"os"
"time"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -30,14 +32,24 @@ func handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alert
if !endpointAlert.IsEnabled() || endpointAlert.FailureThreshold > ep.NumberOfFailuresInARow {
continue
}
if endpointAlert.Triggered {
logr.Debugf("[watchdog.handleAlertsToTrigger] Alert for endpoint with key=%s with description='%s' has already been TRIGGERED, skipping", ep.Key(), endpointAlert.GetDescription())
// Determine if an initial alert should be sent
sendInitialAlert := !endpointAlert.Triggered
// Determine if a reminder should be sent
sendReminder := endpointAlert.Triggered && endpointAlert.MinimumReminderInterval > 0 && time.Since(ep.LastReminderSent) >= endpointAlert.MinimumReminderInterval
// If neither initial alert nor reminder needs to be sent, skip to the next alert
if !sendInitialAlert && !sendReminder {
logr.Debugf("[watchdog.handleAlertsToTrigger] Alert for endpoint=%s with description='%s' is not due for triggering or reminding, skipping", ep.Name, endpointAlert.GetDescription())
continue
}
alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)
if alertProvider != nil {
logr.Infof("[watchdog.handleAlertsToTrigger] Sending %s alert because alert for endpoint with key=%s with description='%s' has been TRIGGERED", endpointAlert.Type, ep.Key(), endpointAlert.GetDescription())
var err error
alertType := "reminder"
if sendInitialAlert {
alertType = "initial"
}
log.Printf("[watchdog.handleAlertsToTrigger] Sending %s %s alert because alert for endpoint=%s with description='%s' has been TRIGGERED", alertType, endpointAlert.Type, ep.Name, endpointAlert.GetDescription())
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
err = errors.New("error")
@@ -48,7 +60,11 @@ func handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alert
if err != nil {
logr.Errorf("[watchdog.handleAlertsToTrigger] Failed to send an alert for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
endpointAlert.Triggered = true
// Mark initial alert as triggered and update last reminder time
if sendInitialAlert {
endpointAlert.Triggered = true
}
ep.LastReminderSent = time.Now()
if err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil {
logr.Errorf("[watchdog.handleAlertsToTrigger] Failed to persist triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error())
}
+43
View File
@@ -3,6 +3,7 @@ package watchdog
import (
"os"
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
@@ -517,6 +518,48 @@ func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {
verify(t, ep, 0, 2, false, "")
}
func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: false,
MinimumReminderInterval: 1 * time.Second,
},
},
}
verify(t, ep, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 2, 0, true, "The alert should've triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 3, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 4, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
}
func verify(t *testing.T, ep *endpoint.Endpoint, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) {
if ep.NumberOfFailuresInARow != expectedNumberOfFailuresInARow {
t.Errorf("endpoint.NumberOfFailuresInARow should've been %d, got %d", expectedNumberOfFailuresInARow, ep.NumberOfFailuresInARow)
+12 -11
View File
@@ -27,11 +27,12 @@ var (
// Monitor loops over each endpoint and starts a goroutine to monitor each endpoint separately
func Monitor(cfg *config.Config) {
ctx, cancelFunc = context.WithCancel(context.Background())
extraLabels := cfg.GetUniqueExtraMetricLabels()
for _, endpoint := range cfg.Endpoints {
if endpoint.IsEnabled() {
// To prevent multiple requests from running at the same time, we'll wait for a little before each iteration
time.Sleep(777 * time.Millisecond)
go monitor(endpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, ctx)
go monitor(endpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, extraLabels, ctx)
}
}
for _, externalEndpoint := range cfg.ExternalEndpoints {
@@ -39,15 +40,15 @@ func Monitor(cfg *config.Config) {
// If the external endpoint does not use heartbeat, then it does not need to be monitored periodically, because
// alerting is checked every time an external endpoint is pushed to Gatus, unlike normal endpoints.
if externalEndpoint.IsEnabled() && externalEndpoint.Heartbeat.Interval > 0 {
go monitorExternalEndpointHeartbeat(externalEndpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, ctx)
go monitorExternalEndpointHeartbeat(externalEndpoint, cfg.Alerting, cfg.Maintenance, cfg.Connectivity, cfg.DisableMonitoringLock, cfg.Metrics, ctx, extraLabels)
}
}
}
// monitor a single endpoint in a loop
func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, ctx context.Context) {
func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, extraLabels []string, ctx context.Context) {
// Run it immediately on start
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics)
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, extraLabels)
// Loop for the next executions
ticker := time.NewTicker(ep.Interval)
defer ticker.Stop()
@@ -57,7 +58,7 @@ func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
logr.Warnf("[watchdog.monitor] Canceling current execution of group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key())
return
case <-ticker.C:
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics)
execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, extraLabels)
}
}
// Just in case somebody wandered all the way to here and wonders, "what about ExternalEndpoints?"
@@ -65,7 +66,7 @@ func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
// periodically like they are for normal endpoints.
}
func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool) {
func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, extraLabels []string) {
if !disableMonitoringLock {
// By placing the lock here, we prevent multiple endpoints from being monitored at the exact same time, which
// could cause performance issues and return inaccurate results
@@ -80,7 +81,7 @@ func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
logr.Debugf("[watchdog.execute] Monitoring group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key())
result := ep.EvaluateHealth()
if enabledMetrics {
metrics.PublishMetricsForEndpoint(ep, result)
metrics.PublishMetricsForEndpoint(ep, result, extraLabels)
}
UpdateEndpointStatuses(ep, result)
if logr.GetThreshold() == logr.LevelDebug && !result.Success {
@@ -104,7 +105,7 @@ func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenance
logr.Debugf("[watchdog.execute] Waiting for interval=%s before monitoring group=%s endpoint=%s (key=%s) again", ep.Interval, ep.Group, ep.Name, ep.Key())
}
func monitorExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, ctx context.Context) {
func monitorExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, ctx context.Context, extraLabels []string) {
ticker := time.NewTicker(ee.Heartbeat.Interval)
defer ticker.Stop()
for {
@@ -113,12 +114,12 @@ func monitorExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingCon
logr.Warnf("[watchdog.monitorExternalEndpointHeartbeat] Canceling current execution of group=%s; endpoint=%s; key=%s", ee.Group, ee.Name, ee.Key())
return
case <-ticker.C:
executeExternalEndpointHeartbeat(ee, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics)
executeExternalEndpointHeartbeat(ee, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, extraLabels)
}
}
}
func executeExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool) {
func executeExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock bool, enabledMetrics bool, extraLabels []string) {
if !disableMonitoringLock {
// By placing the lock here, we prevent multiple endpoints from being monitored at the exact same time, which
// could cause performance issues and return inaccurate results
@@ -152,7 +153,7 @@ func executeExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, alertingCon
Errors: []string{"heartbeat: no update received within " + ee.Heartbeat.Interval.String()},
}
if enabledMetrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result)
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
UpdateEndpointStatuses(convertedEndpoint, result)
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] Checked heartbeat for group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s", ee.Group, ee.Name, ee.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond))
+1767 -1144
View File
File diff suppressed because it is too large Load Diff
+30 -17
View File
@@ -8,23 +8,25 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@headlessui/vue": "^1.7.3",
"@heroicons/vue": "^2.0.12",
"core-js": "3.22.8",
"vue": "3.2.37",
"vue-router": "4.0.16"
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"lucide-vue-next": "^0.539.0",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "5.0.4",
"@vue/cli-plugin-eslint": "5.0.4",
"@vue/cli-plugin-router": "5.0.4",
"@vue/cli-service": "5.0.4",
"@vue/compiler-sfc": "3.2.37",
"autoprefixer": "10.4.7",
"babel-eslint": "10.1.0",
"eslint": "7.32.0",
"eslint-plugin-vue": "7.20.0",
"postcss": "8.4.14",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/compiler-sfc": "^3.5.18",
"autoprefixer": "^10.4.21",
"@babel/eslint-parser": "^7.25.1",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.28.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.1.8"
},
"eslintConfig": {
@@ -37,9 +39,20 @@
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
"parser": "@babel/eslint-parser",
"requireConfigFile": false
},
"rules": {}
"rules": {
"vue/multi-word-component-names": ["error", {
"ignores": ["Home", "Details", "Loading", "Settings", "Social", "Tooltip", "Pagination", "Button", "Badge", "Card", "Input", "Select"]
}]
},
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
}
},
"browserslist": [
"defaults",
+12 -2
View File
@@ -3,7 +3,17 @@
<head>
<meta charset="utf-8" />
<script type="text/javascript">
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<title>{{ .UI.Title }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
@@ -20,7 +30,7 @@
<meta name="application-name" content="{{ .UI.Title }}" />
<meta name="theme-color" content="#f7f9fb" />
</head>
<body class="dark:bg-gray-900">
<body>
<noscript><strong>Enable JavaScript to view this page.</strong></noscript>
<div id="app"></div>
</body>
+217 -93
View File
@@ -1,106 +1,230 @@
<template>
<Loading v-if="!retrievedConfig" class="h-64 w-64 px-4" />
<div v-else :class="[config && config.oidc && !config.authenticated ? 'hidden' : '', 'container container-xs relative mx-auto xl:rounded xl:border xl:shadow-xl xl:my-5 p-5 pb-12 xl:pb-5 text-left dark:bg-gray-800 dark:text-gray-200 dark:border-gray-500']" id="global">
<div class="mb-2">
<div class="flex flex-wrap">
<div class="w-3/4 text-left my-auto">
<div class="text-3xl xl:text-5xl lg:text-4xl font-light">{{ header }}</div>
</div>
<div class="w-1/4 flex justify-end">
<component :is="link ? 'a' : 'div'" :href="link" target="_blank" class="flex items-center justify-center" style="width:100px;min-height:100px;">
<img v-if="logo" :src="logo" alt="Gatus" class="object-scale-down" style="max-width:100px;min-width:50px;min-height:50px;" />
<img v-else src="./assets/logo.svg" alt="Gatus" class="object-scale-down" style="max-width:100px;min-width:50px;min-height:50px;" />
</component>
</div>
</div>
<div v-if="buttons" class="flex flex-wrap">
<a v-for="button in buttons" :key="button.name" :href="button.link" target="_blank" class="px-2 py-0.5 font-medium select-none text-gray-600 hover:text-gray-500 dark:text-gray-300 dark:hover:text-gray-400 hover:underline">
{{ button.name }}
</a>
</div>
<div id="global" class="bg-background text-foreground">
<!-- Loading State -->
<div v-if="!retrievedConfig" class="flex items-center justify-center min-h-screen">
<Loading size="lg" />
</div>
<router-view @showTooltip="showTooltip" />
</div>
<div v-if="config && config.oidc && !config.authenticated" class="mx-auto max-w-md pt-12">
<img src="./assets/logo.svg" alt="Gatus" class="mx-auto" style="max-width:160px; min-width:50px; min-height:50px;"/>
<h2 class="mt-4 text-center text-4xl font-extrabold text-gray-800 dark:text-gray-200">
Gatus
</h2>
<div class="py-7 px-4 rounded-sm sm:px-10">
<div v-if="$route && $route.query.error" class="text-red-500 text-center mb-5">
<div class="text-sm">
<span class="text-red-500" v-if="$route.query.error === 'access_denied'">You do not have access to this status page</span>
<span class="text-red-500" v-else>{{ $route.query.error }}</span>
<!-- Main App Container -->
<div v-else-if="!config || !config.oidc || config.authenticated" class="relative">
<!-- Header -->
<header class="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<div class="container mx-auto px-4 py-4 max-w-7xl">
<div class="flex items-center justify-between">
<!-- Logo and Title -->
<div class="flex items-center gap-4">
<component
:is="link ? 'a' : 'div'"
:href="link"
target="_blank"
class="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<div class="w-12 h-12 flex items-center justify-center">
<img
v-if="logo"
:src="logo"
alt="Gatus"
class="w-full h-full object-contain"
/>
<img
v-else
src="./assets/logo.svg"
alt="Gatus"
class="w-full h-full object-contain"
/>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">{{ header }}</h1>
<p v-if="buttons && buttons.length" class="text-sm text-muted-foreground">
System Monitoring Dashboard
</p>
</div>
</component>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-2">
<!-- Navigation Links (Desktop) -->
<nav v-if="buttons && buttons.length" class="hidden md:flex items-center gap-1">
<a
v-for="button in buttons"
:key="button.name"
:href="button.link"
target="_blank"
class="px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{{ button.name }}
</a>
</nav>
<!-- Mobile Menu Button -->
<Button
v-if="buttons && buttons.length"
variant="ghost"
size="icon"
class="md:hidden"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<Menu v-if="!mobileMenuOpen" class="h-5 w-5" />
<X v-else class="h-5 w-5" />
</Button>
</div>
</div>
<!-- Mobile Navigation -->
<nav
v-if="buttons && buttons.length && mobileMenuOpen"
class="md:hidden mt-4 pt-4 border-t space-y-1"
>
<a
v-for="button in buttons"
:key="button.name"
:href="button.link"
target="_blank"
class="block px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
@click="mobileMenuOpen = false"
>
{{ button.name }}
</a>
</nav>
</div>
</div>
<div>
<a :href="`${SERVER_URL}/oidc/login`" class="max-w-lg mx-auto w-full flex justify-center py-3 px-4 border border-green-800 rounded-md shadow-lg text-sm text-white bg-green-700 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800">
Login with OIDC
</a>
</div>
</div>
</div>
</header>
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
<Social/>
<!-- Main Content -->
<main class="relative">
<router-view @showTooltip="showTooltip" :announcements="announcements" />
</main>
<!-- Footer -->
<footer class="border-t mt-auto">
<div class="container mx-auto px-4 py-6 max-w-7xl">
<div class="flex flex-col items-center gap-4">
<div class="text-sm text-muted-foreground text-center">
Powered by <a href="https://gatus.io" target="_blank" class="font-medium text-emerald-800 hover:text-emerald-600">Gatus</a>
</div>
<Social />
</div>
</div>
</footer>
</div>
<!-- OIDC Login Screen -->
<div v-else id="login-container" class="flex items-center justify-center min-h-screen p-4">
<Card class="w-full max-w-md">
<CardHeader class="text-center">
<img
src="./assets/logo.svg"
alt="Gatus"
class="w-20 h-20 mx-auto mb-4"
/>
<CardTitle class="text-3xl">Gatus</CardTitle>
<p class="text-muted-foreground mt-2">System Monitoring Dashboard</p>
</CardHeader>
<CardContent>
<div v-if="route && route.query.error" class="mb-6">
<div class="p-3 rounded-md bg-destructive/10 border border-destructive/20">
<p class="text-sm text-destructive text-center">
<span v-if="route.query.error === 'access_denied'">
You do not have access to this status page
</span>
<span v-else>{{ route.query.error }}</span>
</p>
</div>
</div>
<a
:href="`${SERVER_URL}/oidc/login`"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full"
@click="isOidcLoading = true"
>
<Loading v-if="isOidcLoading" size="xs" />
<template v-else>
<LogIn class="mr-2 h-4 w-4" />
Login with OIDC
</template>
</a>
</CardContent>
</Card>
</div>
<!-- Tooltip -->
<Tooltip :result="tooltip.result" :event="tooltip.event" />
</div>
</template>
<script>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { Menu, X, LogIn } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue';
import {SERVER_URL} from "@/main";
import Loading from "@/components/Loading";
import Tooltip from './components/Tooltip.vue'
import Loading from './components/Loading.vue'
import { SERVER_URL } from '@/main'
export default {
name: 'App',
components: {
Loading,
Social,
Tooltip
},
methods: {
fetchConfig() {
fetch(`${SERVER_URL}/api/v1/config`, {credentials: 'include'})
.then(response => {
this.retrievedConfig = true;
if (response.status === 200) {
response.json().then(data => {
this.config = data;
})
}
});
},
showTooltip(result, event) {
this.tooltip = {result: result, event: event};
const route = useRoute()
// State
const retrievedConfig = ref(false)
const config = ref({ oidc: false, authenticated: true })
const announcements = ref([])
const tooltip = ref({})
const mobileMenuOpen = ref(false)
const isOidcLoading = ref(false)
let configInterval = null
// Computed properties
const logo = computed(() => {
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : ""
})
const header = computed(() => {
return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Gatus"
})
const link = computed(() => {
return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null
})
const buttons = computed(() => {
return window.config && window.config.buttons ? window.config.buttons : []
})
// Methods
const fetchConfig = async () => {
try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
retrievedConfig.value = true
if (response.status === 200) {
const data = await response.json()
config.value = data
announcements.value = data.announcements || []
}
},
computed: {
logo() {
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : "";
},
header() {
return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Health Status";
},
link() {
return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null;
},
buttons() {
return window.config && window.config.buttons ? window.config.buttons : [];
}
},
data() {
return {
error: '',
retrievedConfig: false,
config: { oidc: false, authenticated: true },
tooltip: {},
SERVER_URL
}
},
created() {
this.fetchConfig();
} catch (error) {
console.error('Failed to fetch config:', error)
retrievedConfig.value = true
}
}
const showTooltip = (result, event) => {
tooltip.value = { result, event }
}
// Fetch config on mount and set up interval
onMounted(() => {
fetchConfig()
// Refresh config every 10 minutes for announcements
configInterval = setInterval(fetchConfig, 600000)
})
// Clean up interval on unmount
onUnmounted(() => {
if (configInterval) {
clearInterval(configInterval)
configInterval = null
}
})
</script>
@@ -0,0 +1,294 @@
<template>
<div v-if="announcements && announcements.length" class="announcement-container mb-6">
<div
:class="[
'rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-200',
containerClasses
]"
>
<!-- Header -->
<div
:class="[
'announcement-header px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors',
isCollapsed ? 'rounded-lg' : 'rounded-t-lg border-b border-gray-200 dark:border-gray-600'
]"
@click="toggleCollapsed"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<component :is="mostRecentIcon" :class="['w-5 h-5', mostRecentIconClass]" />
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Announcements</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">
({{ announcements.length }})
</span>
</div>
<ChevronDown
:class="[
'w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200',
isCollapsed ? '-rotate-90' : 'rotate-0'
]"
/>
</div>
</div>
<!-- Timeline Content -->
<div
v-if="!isCollapsed"
class="announcement-content p-4 transition-all duration-200 rounded-b-lg"
>
<div class="relative">
<!-- Announcements -->
<div class="space-y-3">
<div
v-for="(group, date) in groupedAnnouncements"
:key="date"
class="relative"
>
<!-- Vertical line from date to last icon -->
<div
v-if="group.length > 0"
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="getTimelineHeight(group)"
></div>
<!-- Date Header -->
<div class="flex items-center gap-3 mb-2 relative">
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
<time class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ formatDate(date) }}
</time>
</div>
<div class="flex-1 border-t border-gray-200 dark:border-gray-600"></div>
</div>
<!-- Announcements for this date -->
<div class="space-y-2 ml-7 relative">
<div
v-for="(announcement, index) in group"
:key="`${date}-${index}-${announcement.timestamp}`"
class="relative"
>
<!-- Timeline Icon -->
<div
:class="[
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
getTypeClasses(announcement.type).border
]"
>
<component
:is="getTypeIcon(announcement.type)"
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
/>
</div>
<!-- Announcement Card -->
<div
:class="[
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
getTypeClasses(announcement.type).background
]"
>
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
</div>
<time
:class="[
'text-xs font-mono whitespace-nowrap',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
// Props
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
// Collapse state
const isCollapsed = ref(false)
// Methods
const toggleCollapsed = () => {
isCollapsed.value = !isCollapsed.value
}
// Type configurations
const typeConfigs = {
outage: {
icon: XCircle,
background: 'bg-red-50 border-gray-200 dark:bg-red-900/50 dark:border-gray-600',
border: 'border-red-500',
iconColor: 'text-red-600 dark:text-red-400',
text: 'text-red-700 dark:text-red-300'
},
warning: {
icon: AlertTriangle,
background: 'bg-yellow-50 border-gray-200 dark:bg-yellow-900/50 dark:border-gray-600',
border: 'border-yellow-500',
iconColor: 'text-yellow-600 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-300'
},
information: {
icon: Info,
background: 'bg-blue-50 border-gray-200 dark:bg-blue-900/50 dark:border-gray-600',
border: 'border-blue-500',
iconColor: 'text-blue-600 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-300'
},
operational: {
icon: CheckCircle,
background: 'bg-green-50 border-gray-200 dark:bg-green-900/50 dark:border-gray-600',
border: 'border-green-500',
iconColor: 'text-green-600 dark:text-green-400',
text: 'text-green-700 dark:text-green-300'
},
none: {
icon: Circle,
background: 'bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-600',
border: 'border-gray-500',
iconColor: 'text-gray-600 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-300'
}
}
// Computed properties
const mostRecentAnnouncement = computed(() => {
return props.announcements && props.announcements.length > 0 ? props.announcements[0] : null
})
const mostRecentIcon = computed(() => {
const type = mostRecentAnnouncement.value?.type || 'none'
return typeConfigs[type]?.icon || Circle
})
const mostRecentIconClass = computed(() => {
const type = mostRecentAnnouncement.value?.type || 'none'
return typeConfigs[type]?.iconColor || 'text-gray-600 dark:text-gray-400'
})
const containerClasses = computed(() => {
const type = mostRecentAnnouncement.value?.type || 'none'
const config = typeConfigs[type]
// Add a subtle left border accent to indicate announcement type
return `border-l-4 ${config.border.replace('border-', 'border-l-')}`
})
const groupedAnnouncements = computed(() => {
if (!props.announcements || props.announcements.length === 0) {
return {}
}
const groups = {}
props.announcements.forEach(announcement => {
const date = new Date(announcement.timestamp).toDateString()
if (!groups[date]) {
groups[date] = []
}
groups[date].push(announcement)
})
return groups
})
// Helper functions
const getTypeIcon = (type) => {
return typeConfigs[type]?.icon || Circle
}
const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const getTimelineHeight = (group) => {
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
return {
top: '1.5rem',
height
}
}
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Today'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday'
} else {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
const formatFullTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
})
}
</script>
<style scoped>
.announcement-container {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.announcement-container .ml-7 {
margin-left: 1.5rem;
}
}
</style>
-186
View File
@@ -1,186 +0,0 @@
<template>
<div class='endpoint px-3 py-3 border-l border-r border-t rounded-none hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-500' v-if="data">
<div class='flex flex-wrap mb-2'>
<div class='w-3/4'>
<router-link :to="generatePath()" class="font-bold hover:text-blue-800 hover:underline dark:hover:text-blue-400" title="View detailed endpoint health">
{{ data.name }}
</router-link>
<span v-if="data.results && data.results.length && data.results[data.results.length - 1].hostname" class='text-gray-500 font-light'> | {{ data.results[data.results.length - 1].hostname }}</span>
</div>
<div class='w-1/4 text-right'>
<span class='font-light overflow-x-hidden cursor-pointer select-none hover:text-gray-500' v-if="data.results && data.results.length" @click="toggleShowAverageResponseTime" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">
<slot v-if="showAverageResponseTime">
~{{ averageResponseTime }}ms
</slot>
<slot v-else>
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + '-' + maxResponseTime)) }}ms
</slot>
</span>
<!-- <span class="text-sm font-bold cursor-pointer">-->
<!-- -->
<!-- </span>-->
</div>
</div>
<div>
<div class='status-over-time flex flex-row'>
<slot v-if="data.results && data.results.length">
<slot v-if="data.results.length < maximumNumberOfResults">
<span v-for="filler in maximumNumberOfResults - data.results.length" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
<slot v-for="result in data.results" :key="result">
<span v-if="result.success" class="status status-success rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
<span v-else class="status status-failure rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
</slot>
</slot>
<slot v-else>
<span v-for="filler in maximumNumberOfResults" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
</div>
</div>
<div class='flex flex-wrap status-time-ago'>
<slot v-if="data.results && data.results.length">
<div class='w-1/2'>
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
</div>
<div class='w-1/2 text-right'>
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
</div>
</slot>
<slot v-else>
<div class='w-1/2'>
&nbsp;
</div>
</slot>
</div>
</div>
</template>
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoint',
props: {
maximumNumberOfResults: Number,
data: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
mixins: [helper],
methods: {
updateMinAndMaxResponseTimes() {
let minResponseTime = null;
let maxResponseTime = null;
let totalResponseTime = 0;
for (let i in this.data.results) {
const responseTime = parseInt((this.data.results[i].duration/1000000).toFixed(0));
totalResponseTime += responseTime;
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
if (this.minResponseTime !== minResponseTime) {
this.minResponseTime = minResponseTime;
}
if (this.maxResponseTime !== maxResponseTime) {
this.maxResponseTime = maxResponseTime;
}
if (this.data.results && this.data.results.length) {
this.averageResponseTime = (totalResponseTime/this.data.results.length).toFixed(0);
}
},
generatePath() {
if (!this.data) {
return '/';
}
return `/endpoints/${this.data.key}`;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
data: function () {
this.updateMinAndMaxResponseTimes();
}
},
created() {
this.updateMinAndMaxResponseTimes()
},
data() {
return {
minResponseTime: 0,
maxResponseTime: 0,
averageResponseTime: 0
}
}
}
</script>
<style>
.endpoint:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.endpoint:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-width: 3px;
}
.status-over-time {
overflow: auto;
}
.status-over-time > span:not(:first-child) {
margin-left: 2px;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
color: white;
width: 5%;
font-size: 75%;
font-weight: 700;
text-align: center;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status.status-success::after {
content: "✓";
}
.status.status-failure::after {
content: "X";
}
@media screen and (max-width: 600px) {
.status.status-success::after,
.status.status-failure::after {
content: " ";
white-space: pre;
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<Card class="endpoint h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700">
<CardHeader class="endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0">
<div class="flex items-start justify-between gap-2 sm:gap-3">
<div class="flex-1 min-w-0 overflow-hidden">
<CardTitle class="text-base sm:text-lg truncate">
<span
class="hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate"
@click="navigateToDetails"
@keydown.enter="navigateToDetails"
:title="endpoint.name"
role="link"
tabindex="0"
:aria-label="`View details for ${endpoint.name}`">
{{ endpoint.name }}
</span>
</CardTitle>
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
<span v-if="endpoint.group && hostname"></span>
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
</div>
</div>
<div class="flex-shrink-0 ml-2">
<StatusBadge :status="currentStatus" />
</div>
</div>
</CardHeader>
<CardContent class="endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2">
<div class="space-y-2">
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex-1"></div>
<p class="text-xs text-muted-foreground" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">{{ formattedResponseTime }}</p>
</div>
<div class="flex gap-0.5">
<div
v-for="(result, index) in displayResults"
:key="index"
:class="[
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
]"
@mouseenter="result && emit('showTooltip', result, $event)"
@mouseleave="result && emit('showTooltip', null, $event)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{{ oldestResultTime }}</span>
<span>{{ newestResultTime }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup>
/* eslint-disable no-undef */
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import { helper } from '@/mixins/helper'
const router = useRouter()
const props = defineProps({
endpoint: {
type: Object,
required: true
},
maxResults: {
type: Number,
default: 50
},
showAverageResponseTime: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['showTooltip'])
const latestResult = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return null
}
return props.endpoint.results[props.endpoint.results.length - 1]
})
const currentStatus = computed(() => {
if (!latestResult.value) return 'unknown'
return latestResult.value.success ? 'healthy' : 'unhealthy'
})
const hostname = computed(() => {
return latestResult.value?.hostname || null
})
const displayResults = computed(() => {
const results = [...(props.endpoint.results || [])]
while (results.length < props.maxResults) {
results.unshift(null)
}
return results.slice(-props.maxResults)
})
const formattedResponseTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return 'N/A'
}
let total = 0
let count = 0
let min = Infinity
let max = 0
for (const result of props.endpoint.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
total += durationMs
count++
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
}
}
if (count === 0) return 'N/A'
if (props.showAverageResponseTime) {
const avgMs = Math.round(total / count)
return `~${avgMs}ms`
} else {
// Show min-max range
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
}
})
const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
})
const newestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)
})
const navigateToDetails = () => {
router.push(`/endpoints/${props.endpoint.key}`)
}
</script>
-99
View File
@@ -1,99 +0,0 @@
<template>
<div :class="endpoints.length === 0 ? 'mt-3' : 'mt-4'">
<slot v-if="name !== 'undefined'">
<div class="endpoint-group pt-2 border dark:bg-gray-800 dark:border-gray-500" @click="toggleGroup">
<h5 class="font-mono text-gray-400 text-xl font-medium pb-2 px-3 dark:text-gray-200 dark:hover:text-gray-500 dark:border-gray-500">
<span class="endpoint-group-arrow mr-2">
{{ collapsed ? '&#9660;' : '&#9650;' }}
</span>
{{ name }}
<span v-if="unhealthyCount" class="rounded-xl bg-red-600 text-white px-2 font-bold leading-6 float-right h-6 text-center hover:scale-110 text-sm" title="Partial Outage">{{unhealthyCount}}</span>
<span v-else class="float-right text-green-600 w-7 hover:scale-110" title="Operational">
<CheckCircleIcon />
</span>
</h5>
</div>
</slot>
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'endpoint-group-content'">
<slot v-for="(endpoint, idx) in endpoints" :key="idx">
<Endpoint
:data="endpoint"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
/>
</slot>
</div>
</div>
</template>
<script>
import Endpoint from './Endpoint.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
export default {
name: 'EndpointGroup',
components: {
Endpoint,
CheckCircleIcon
},
props: {
name: String,
endpoints: Array,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
healthCheck() {
let unhealthyCount = 0
if (this.endpoints) {
for (let i in this.endpoints) {
if (this.endpoints[i].results && this.endpoints[i].results.length > 0) {
if (!this.endpoints[i].results[this.endpoints[i].results.length-1].success) {
unhealthyCount++
}
}
}
}
this.unhealthyCount = unhealthyCount;
},
toggleGroup() {
this.collapsed = !this.collapsed;
localStorage.setItem(`gatus:endpoint-group:${this.name}:collapsed`, this.collapsed);
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpoints: function () {
this.healthCheck();
}
},
created() {
this.healthCheck();
},
data() {
return {
unhealthyCount: 0,
collapsed: localStorage.getItem(`gatus:endpoint-group:${this.name}:collapsed`) === "true"
}
}
}
</script>
<style>
.endpoint-group {
cursor: pointer;
user-select: none;
}
.endpoint-group h5:hover {
color: #1b1e21;
}
</style>
-74
View File
@@ -1,74 +0,0 @@
<template>
<div id="results">
<slot v-for="endpointGroup in endpointGroups" :key="endpointGroup">
<EndpointGroup :endpoints="endpointGroup.endpoints" :name="endpointGroup.name" @showTooltip="showTooltip" @toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime" />
</slot>
</div>
</template>
<script>
import EndpointGroup from './EndpointGroup.vue';
export default {
name: 'Endpoints',
components: {
EndpointGroup
},
props: {
showStatusOnHover: Boolean,
endpointStatuses: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
process() {
let outputByGroup = {};
for (let endpointStatusIndex in this.endpointStatuses) {
let endpointStatus = this.endpointStatuses[endpointStatusIndex];
// create an empty entry if this group is new
if (!outputByGroup[endpointStatus.group] || outputByGroup[endpointStatus.group].length === 0) {
outputByGroup[endpointStatus.group] = [];
}
outputByGroup[endpointStatus.group].push(endpointStatus);
}
let endpointGroups = [];
for (let name in outputByGroup) {
if (name !== 'undefined') {
endpointGroups.push({name: name, endpoints: outputByGroup[name]})
}
}
// Add all endpoints that don't have a group at the end
if (outputByGroup['undefined']) {
endpointGroups.push({name: 'undefined', endpoints: outputByGroup['undefined']})
}
this.endpointGroups = endpointGroups;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpointStatuses: function () {
this.process();
}
},
data() {
return {
userClickedStatus: false,
endpointGroups: []
}
}
}
</script>
<style>
.endpoint-group-content > div:nth-child(1) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>
+30 -6
View File
@@ -1,11 +1,35 @@
<template>
<div class="flex justify-center items-center mx-auto">
<img :class="`animate-spin opacity-60 rounded-full`" src="../assets/logo.svg" alt="Gatus logo" />
<div class="flex justify-center items-center">
<img
:class="[
'animate-spin rounded-full opacity-60 grayscale',
sizeClass,
]"
src="../assets/logo.svg"
alt="Gatus logo"
/>
</div>
</template>
<script>
export default {
<script setup>
import { computed } from 'vue'
}
</script>
const props = defineProps({
size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value)
},
})
const sizeClass = computed(() => {
const sizes = {
xs: 'w-4 h-4',
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12',
xl: 'w-16 h-16'
}
return sizes[props.size] || sizes.md
})
</script>
+64 -33
View File
@@ -1,42 +1,73 @@
<template>
<div class="mt-3 flex">
<div class="flex-1">
<button v-if="currentPage < maxPages" @click="nextPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&lt;</button>
</div>
<div class="flex-1 text-right">
<button v-if="currentPage > 1" @click="previousPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&gt;</button>
</div>
<div class="flex items-center justify-between">
<Button
variant="outline"
size="sm"
:disabled="currentPage >= maxPages"
@click="previousPage"
class="flex items-center gap-1"
>
<ChevronLeft class="h-4 w-4" />
Previous
</Button>
<span class="text-sm text-muted-foreground">
Page {{ currentPage }} of {{ maxPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="currentPage <= 1"
@click="nextPage"
class="flex items-center gap-1"
>
Next
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
<script>
export default {
name: 'Pagination',
props: {
numberOfResultsPerPage: Number,
},
components: {},
emits: ['page'],
methods: {
nextPage() {
this.currentPage++;
this.$emit('page', this.currentPage);
},
previousPage() {
this.currentPage--;
this.$emit('page', this.currentPage);
}
},
computed: {
maxPages() {
return Math.ceil(parseInt(window.config.maximumNumberOfResults) / this.numberOfResultsPerPage)
}
},
data() {
return {
currentPage: 1,
const props = defineProps({
numberOfResultsPerPage: Number,
currentPageProp: {
type: Number,
default: 1
}
})
const emit = defineEmits(['page'])
const currentPage = ref(props.currentPageProp)
const maxPages = computed(() => {
// Use maximumNumberOfResults from config if available, otherwise default to 100
let maxResults = 100 // Default value
// Check if window.config exists and has maximumNumberOfResults
if (typeof window !== 'undefined' && window.config && window.config.maximumNumberOfResults) {
const parsed = parseInt(window.config.maximumNumberOfResults)
if (!isNaN(parsed)) {
maxResults = parsed
}
}
return Math.ceil(maxResults / props.numberOfResultsPerPage)
})
const nextPage = () => {
// "Next" should show newer data (lower page numbers)
currentPage.value--
emit('page', currentPage.value)
}
const previousPage = () => {
// "Previous" should show older data (higher page numbers)
currentPage.value++
emit('page', currentPage.value)
}
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<label for="search-input" class="sr-only">Search endpoints</label>
<Input
id="search-input"
v-model="searchQuery"
type="text"
placeholder="Search endpoints..."
class="pl-10 text-sm sm:text-base"
@input="$emit('search', searchQuery)"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Filter by:</label>
<Select
v-model="filterBy"
:options="filterOptions"
placeholder="None"
class="flex-1 sm:w-[140px] md:w-[160px]"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Sort by:</label>
<Select
v-model="sortBy"
:options="sortOptions"
placeholder="Name"
class="flex-1 sm:w-[90px] md:w-[100px]"
@update:model-value="handleSortChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Search } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
const searchQuery = ref('')
const filterBy = ref(localStorage.getItem('gatus:filter-by') || (typeof window !== 'undefined' && window.config?.defaultFilterBy) || 'none')
const sortBy = ref(localStorage.getItem('gatus:sort-by') || (typeof window !== 'undefined' && window.config?.defaultSortBy) || 'name')
const filterOptions = [
{ label: 'None', value: 'none' },
{ label: 'Failing', value: 'failing' },
{ label: 'Unstable', value: 'unstable' }
]
const sortOptions = [
{ label: 'Name', value: 'name' },
{ label: 'Group', value: 'group' },
{ label: 'Health', value: 'health' }
]
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => {
filterBy.value = value
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first
emit('update:showOnlyFailing', false)
emit('update:showRecentFailures', false)
// Apply the selected filter
if (value === 'failing') {
emit('update:showOnlyFailing', true)
} else if (value === 'unstable') {
emit('update:showRecentFailures', true)
}
}
const handleSortChange = (value) => {
sortBy.value = value
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group')
// When switching to group view, initialize collapsed groups
if (value === 'group') {
emit('initializeCollapsedGroups')
}
}
onMounted(() => {
// Apply saved filter/sort state on load
handleFilterChange(filterBy.value)
handleSortChange(sortBy.value)
})
</script>
+172 -86
View File
@@ -1,104 +1,190 @@
<template>
<div id="settings" class="flex bg-gray-200 border-gray-300 rounded border shadow dark:text-gray-200 dark:bg-gray-800 dark:border-gray-500">
<div class="text-xs text-gray-600 rounded-xl py-1.5 px-1.5 dark:text-gray-200">
<ArrowPathIcon class="w-3"/>
<div id="settings" class="fixed bottom-4 left-4 z-50">
<div class="flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1">
<!-- Refresh Rate -->
<button
@click="showRefreshMenu = !showRefreshMenu"
:aria-label="`Refresh interval: ${formatRefreshInterval(refreshIntervalValue)}`"
:aria-expanded="showRefreshMenu"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-accent transition-colors relative"
>
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium">{{ formatRefreshInterval(refreshIntervalValue) }}</span>
<!-- Refresh Rate Dropdown -->
<div
v-if="showRefreshMenu"
@click.stop
class="absolute bottom-full left-0 mb-2 bg-popover border rounded-lg shadow-lg overflow-hidden"
>
<button
v-for="interval in REFRESH_INTERVALS"
:key="interval.value"
@click="selectRefreshInterval(interval.value)"
:class="[
'block w-full px-4 py-2 text-xs text-left hover:bg-accent transition-colors',
refreshIntervalValue === interval.value && 'bg-accent'
]"
>
{{ interval.label }}
</button>
</div>
</button>
<!-- Divider -->
<div class="h-5 w-px bg-border/50" />
<!-- Theme Toggle -->
<button
@click="toggleDarkMode"
:aria-label="darkMode ? 'Switch to light mode' : 'Switch to dark mode'"
class="p-1.5 rounded-full hover:bg-accent transition-colors group relative"
>
<Sun v-if="darkMode" class="h-3.5 w-3.5 transition-all" />
<Moon v-else class="h-3.5 w-3.5 transition-all" />
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded-md shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap">
{{ darkMode ? 'Light mode' : 'Dark mode' }}
</div>
</button>
</div>
<select class="text-center text-gray-500 text-xs dark:text-gray-200 dark:bg-gray-800 border-r border-l border-gray-300 dark:border-gray-500 pl-1" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
<option value="10" :selected="refreshInterval === 10">10s</option>
<option value="30" :selected="refreshInterval === 30">30s</option>
<option value="60" :selected="refreshInterval === 60">1m</option>
<option value="120" :selected="refreshInterval === 120">2m</option>
<option value="300" :selected="refreshInterval === 300">5m</option>
<option value="600" :selected="refreshInterval === 600">10m</option>
</select>
<button @click="toggleDarkMode" class="text-xs p-1">
<slot v-if="darkMode"><SunIcon class="w-4"/></slot>
<slot v-else><MoonIcon class="w-4 text-gray-500"/></slot>
</button>
</div>
</template>
<script>
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid'
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
<script setup>
/* eslint-disable no-undef */
import { ref, onMounted, onUnmounted } from 'vue'
import { Sun, Moon, RefreshCw } from 'lucide-vue-next'
const emit = defineEmits(['refreshData'])
// Constants
const REFRESH_INTERVALS = [
{ value: '10', label: '10s' },
{ value: '30', label: '30s' },
{ value: '60', label: '1m' },
{ value: '120', label: '2m' },
{ value: '300', label: '5m' },
{ value: '600', label: '10m' }
]
const DEFAULT_REFRESH_INTERVAL = '300'
const THEME_COOKIE_NAME = 'theme'
const THEME_COOKIE_MAX_AGE = 31536000 // 1 year
const STORAGE_KEYS = {
REFRESH_INTERVAL: 'gatus:refresh-interval'
}
// Helper functions
function wantsDarkMode() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
return themeFromCookie === 'dark' || !themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark"));
const themeFromCookie = document.cookie.match(new RegExp(`${THEME_COOKIE_NAME}=(dark|light);?`))?.[1]
return themeFromCookie === 'dark' || (!themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark")))
}
export default {
name: 'Settings',
components: {
ArrowPathIcon,
MoonIcon,
SunIcon
},
props: {},
methods: {
setRefreshInterval(seconds) {
localStorage.setItem('gatus:refresh-interval', seconds);
let that = this;
this.refreshIntervalHandler = setInterval(function () {
that.refreshData();
}, seconds * 1000);
},
refreshData() {
this.$emit('refreshData');
},
handleChangeRefreshInterval() {
this.refreshData();
clearInterval(this.refreshIntervalHandler);
this.setRefreshInterval(this.$refs.refreshInterval.value);
},
toggleDarkMode() {
if (wantsDarkMode()) {
document.cookie = `theme=light; path=/; max-age=31536000; samesite=strict`;
} else {
document.cookie = `theme=dark; path=/; max-age=31536000; samesite=strict`;
}
this.applyTheme();
},
applyTheme() {
if (wantsDarkMode()) {
this.darkMode = true;
document.documentElement.classList.add('dark');
} else {
this.darkMode = false;
document.documentElement.classList.remove('dark');
}
}
},
created() {
if (this.refreshInterval !== 10 && this.refreshInterval !== 30 && this.refreshInterval !== 60 && this.refreshInterval !== 120 && this.refreshInterval !== 300 && this.refreshInterval !== 600) {
this.refreshInterval = 300;
}
this.setRefreshInterval(this.refreshInterval);
this.applyTheme();
},
unmounted() {
clearInterval(this.refreshIntervalHandler);
},
data() {
return {
refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')),
refreshIntervalHandler: 0,
darkMode: wantsDarkMode()
}
},
function getStoredRefreshInterval() {
const stored = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL)
const parsedValue = stored && parseInt(stored)
const isValid = parsedValue && parsedValue >= 10 && REFRESH_INTERVALS.some(i => i.value === stored)
return isValid ? stored : DEFAULT_REFRESH_INTERVAL
}
// State
const refreshIntervalValue = ref(getStoredRefreshInterval())
const darkMode = ref(wantsDarkMode())
const showRefreshMenu = ref(false)
let refreshIntervalHandler = null
// Methods
const formatRefreshInterval = (value) => {
const interval = REFRESH_INTERVALS.find(i => i.value === value)
return interval ? interval.label : `${value}s`
}
const setRefreshInterval = (seconds) => {
localStorage.setItem(STORAGE_KEYS.REFRESH_INTERVAL, seconds)
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
refreshIntervalHandler = setInterval(() => {
refreshData()
}, seconds * 1000)
}
const refreshData = () => {
emit('refreshData')
}
const selectRefreshInterval = (value) => {
refreshIntervalValue.value = value
showRefreshMenu.value = false
refreshData()
setRefreshInterval(value)
}
// Close menu when clicking outside
const handleClickOutside = (event) => {
const settings = document.getElementById('settings')
if (settings && !settings.contains(event.target)) {
showRefreshMenu.value = false
}
}
const setThemeCookie = (theme) => {
document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=strict`
}
const toggleDarkMode = () => {
const newTheme = wantsDarkMode() ? 'light' : 'dark'
setThemeCookie(newTheme)
applyTheme()
}
const applyTheme = () => {
const isDark = wantsDarkMode()
darkMode.value = isDark
document.documentElement.classList.toggle('dark', isDark)
}
// Lifecycle
onMounted(() => {
setRefreshInterval(refreshIntervalValue.value)
applyTheme()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
document.removeEventListener('click', handleClickOutside)
})
</script>
<style>
#settings {
position: fixed;
left: 10px;
bottom: 10px;
<style scoped>
/* Animations for smooth transitions */
@keyframes slideIn {
from {
transform: translateX(-20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
#settings select:focus {
box-shadow: none;
#settings {
animation: slideIn 0.3s ease-out;
}
#settings > div {
transition: all 0.2s ease;
}
#settings > div:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
</style>
+2 -7
View File
@@ -8,14 +8,9 @@
</div>
</template>
<script>
export default {
name: 'Social'
}
<script setup>
</script>
<style scoped>
#social {
position: fixed;
@@ -33,4 +28,4 @@ export default {
#social img:hover {
opacity: 1;
}
</style>
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<Badge :variant="variant" class="flex items-center gap-1">
<span :class="['w-2 h-2 rounded-full', dotClass]"></span>
{{ label }}
</Badge>
</template>
<script setup>
import { computed } from 'vue'
import { Badge } from '@/components/ui/badge'
const props = defineProps({
status: {
type: String,
required: true,
validator: (value) => ['healthy', 'unhealthy', 'degraded', 'unknown'].includes(value)
}
})
const variant = computed(() => {
switch (props.status) {
case 'healthy':
return 'success'
case 'unhealthy':
return 'destructive'
case 'degraded':
return 'warning'
default:
return 'secondary'
}
})
const label = computed(() => {
switch (props.status) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'degraded':
return 'Degraded'
default:
return 'Unknown'
}
})
const dotClass = computed(() => {
switch (props.status) {
case 'healthy':
return 'bg-green-400'
case 'unhealthy':
return 'bg-red-400'
case 'degraded':
return 'bg-yellow-400'
default:
return 'bg-gray-400'
}
})
</script>
+142 -114
View File
@@ -1,130 +1,158 @@
<template>
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
<slot v-if="result">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
<slot v-if="result.conditionResults && result.conditionResults.length">
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
{{ conditionResult.success ? "&#10003;" : "X" }} ~ {{ conditionResult.condition }}<br/>
</slot>
</code>
</slot>
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">
<slot v-for="error in result.errors" :key="error">
- {{ error }}<br/>
</slot>
</code>
<div
id="tooltip"
ref="tooltip"
:class="[
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
'bg-popover text-popover-foreground border-border',
hidden ? 'invisible opacity-0' : 'visible opacity-100'
]"
:style="`top: ${top}px; left: ${left}px;`"
>
<div v-if="result" class="space-y-2">
<!-- Timestamp -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timestamp</div>
<div class="font-mono text-xs">{{ prettifyTimestamp(result.timestamp) }}</div>
</div>
</slot>
<!-- Response Time -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Response Time</div>
<div class="font-mono text-xs">{{ (result.duration / 1000000).toFixed(0) }}ms</div>
</div>
<!-- Conditions -->
<div v-if="result.conditionResults && result.conditionResults.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Conditions</div>
<div class="font-mono text-xs space-y-0.5">
<div
v-for="(conditionResult, index) in result.conditionResults"
:key="index"
class="flex items-start gap-1"
>
<span :class="conditionResult.success ? 'text-green-500' : 'text-red-500'">
{{ conditionResult.success ? '✓' : '✗' }}
</span>
<span class="break-all">{{ conditionResult.condition }}</span>
</div>
</div>
</div>
<!-- Errors -->
<div v-if="result.errors && result.errors.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Errors</div>
<div class="font-mono text-xs space-y-0.5">
<div v-for="(error, index) in result.errors" :key="index" class="text-red-500">
{{ error }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick } from 'vue'
import { helper } from '@/mixins/helper'
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoints',
props: {
event: Event,
result: Object
const props = defineProps({
event: {
type: [Event, Object],
default: null
},
mixins: [helper],
methods: {
htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
},
reposition() {
if (this.event && this.event.type) {
if (this.event.type === 'mouseenter') {
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
}
}
this.top = targetTopPosition;
this.left = targetLeftPosition;
} else if (this.event.type === 'mouseleave') {
this.hidden = true;
result: {
type: Object,
default: null
}
})
// State
const hidden = ref(true)
const top = ref(0)
const left = ref(0)
const tooltip = ref(null)
// Methods from helper mixin
const { prettifyTimestamp } = helper.methods
const reposition = async () => {
if (!props.event || !props.event.type) return
await nextTick()
if (props.event.type === 'mouseenter' && tooltip.value) {
const target = props.event.target
const targetRect = target.getBoundingClientRect()
// First, position tooltip to get its dimensions
hidden.value = false
await nextTick()
const tooltipRect = tooltip.value.getBoundingClientRect()
// Since tooltip uses position: fixed, we work with viewport coordinates
// getBoundingClientRect() already gives us viewport-relative positions
// Default position: below the target
let newTop = targetRect.bottom + 8
let newLeft = targetRect.left
// Check if tooltip would overflow the viewport bottom
const spaceBelow = window.innerHeight - targetRect.bottom
const spaceAbove = targetRect.top
if (spaceBelow < tooltipRect.height + 20) {
// Not enough space below, try above
if (spaceAbove > tooltipRect.height + 20) {
// Position above
newTop = targetRect.top - tooltipRect.height - 8
} else {
// Not enough space above either, position at the best spot
if (spaceAbove > spaceBelow) {
// More space above
newTop = 10
} else {
// More space below or equal, keep below but adjust
newTop = window.innerHeight - tooltipRect.height - 10
}
}
}
},
watch: {
event: function (value) {
if (value && value.type) {
if (value.type === 'mouseenter') {
this.hidden = false;
} else if (value.type === 'mouseleave') {
this.hidden = true;
}
// Adjust horizontal position if tooltip would overflow right edge
const spaceRight = window.innerWidth - targetRect.left
if (spaceRight < tooltipRect.width + 20) {
// Align right edge of tooltip with right edge of target
newLeft = targetRect.right - tooltipRect.width
// Make sure it doesn't go off the left edge
if (newLeft < 10) {
newLeft = 10
}
}
},
updated() {
this.reposition();
},
created() {
this.reposition();
},
data() {
return {
hidden: true,
top: 0,
left: 0
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
} else if (props.event.type === 'mouseleave') {
hidden.value = true
}
}
</script>
// Watchers
watch(() => props.event, (newEvent) => {
if (newEvent && newEvent.type) {
if (newEvent.type === 'mouseenter') {
hidden.value = false
nextTick(() => reposition())
} else if (newEvent.type === 'mouseleave') {
hidden.value = true
}
}
}, { immediate: true })
<style>
#tooltip {
position: fixed;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip > .tooltip-title:first-child {
margin-top: 0;
}
</style>
watch(() => props.result, () => {
if (!hidden.value) {
nextTick(() => reposition())
}
})
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div :class="combineClasses(badgeVariants({ variant }), $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
})
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white',
warning: 'border-transparent bg-yellow-500 text-white',
},
},
defaultVariants: {
variant: 'default',
},
}
)
</script>
+1
View File
@@ -0,0 +1 @@
export { default as Badge } from './Badge.vue'
@@ -0,0 +1,55 @@
<template>
<button
:class="combineClasses(buttonVariants({ variant, size }), $attrs.class ?? '')"
:disabled="disabled"
>
<slot />
</button>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
size: {
type: String,
default: 'default',
},
disabled: {
type: Boolean,
default: false,
},
})
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
</script>
@@ -0,0 +1 @@
export { default as Button } from './Button.vue'
+9
View File
@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>
@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('p-6 pt-0', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>
@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('flex flex-col space-y-1.5 p-6', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>
@@ -0,0 +1,9 @@
<template>
<h3 :class="combineClasses('text-2xl font-semibold leading-none tracking-tight', $attrs.class ?? '')">
<slot />
</h3>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>
+4
View File
@@ -0,0 +1,4 @@
export { default as Card } from './Card.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as CardContent } from './CardContent.vue'
+24
View File
@@ -0,0 +1,24 @@
<template>
<input
:class="combineClasses(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
$attrs.class ?? ''
)"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
})
defineEmits(['update:modelValue'])
</script>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+127
View File
@@ -0,0 +1,127 @@
<template>
<div ref="selectRef" class="relative" :class="props.class">
<button
@click="toggleDropdown"
@keydown="handleKeyDown"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="selectedOption.label || props.placeholder"
class="flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span class="truncate">{{ selectedOption.label }}</span>
<ChevronDown class="h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1" />
</button>
<div
v-if="isOpen"
role="listbox"
class="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
>
<div class="p-1">
<div
v-for="(option, index) in options"
:key="option.value"
@click="selectOption(option)"
:class="[
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-6 sm:pl-8 pr-2 text-xs sm:text-sm outline-none hover:bg-accent hover:text-accent-foreground',
index === focusedIndex && 'bg-accent text-accent-foreground'
]"
role="option"
:aria-selected="modelValue === option.value"
>
<span class="absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check v-if="modelValue === option.value" class="h-3 w-3 sm:h-4 sm:w-4" />
</span>
{{ option.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, required: true },
placeholder: { type: String, default: 'Select...' },
class: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const isOpen = ref(false)
const selectRef = ref(null)
const focusedIndex = ref(-1)
const selectedOption = computed(() => {
return props.options.find(option => option.value === props.modelValue) || { label: props.placeholder, value: '' }
})
const selectOption = (option) => {
emit('update:modelValue', option.value)
isOpen.value = false
}
const toggleDropdown = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
// Set initial focus to selected option or first option
const selectedIdx = props.options.findIndex(opt => opt.value === props.modelValue)
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
} else {
focusedIndex.value = -1
}
}
const handleClickOutside = (event) => {
if (selectRef.value && !selectRef.value.contains(event.target)) {
isOpen.value = false
focusedIndex.value = -1
}
}
const handleKeyDown = (event) => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
toggleDropdown()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusedIndex.value = Math.min(focusedIndex.value + 1, props.options.length - 1)
break
case 'ArrowUp':
event.preventDefault()
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0 && focusedIndex.value < props.options.length) {
selectOption(props.options[focusedIndex.value])
}
break
case 'Escape':
event.preventDefault()
isOpen.value = false
focusedIndex.value = -1
break
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
@@ -0,0 +1 @@
export { default as Select } from './Select.vue'
+56 -16
View File
@@ -2,38 +2,78 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
:root.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.bg-success {
background-color: #28a745;
}
html:not(.dark) body {
background-color: #f7f9fb;
}
html {
height: 100%;
}
body {
padding-top: 20px;
padding-bottom: 20px;
min-height: 100vh;
}
#global {
margin-top: 0 !important;
}
#global, #results {
max-width: 1280px;
}
@media screen and (max-width: 1279px) {
body {
padding-top: 0;
padding-bottom: 0;
}
#global {
min-height: 100vh;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function combineClasses(...inputs) {
return twMerge(clsx(inputs))
}
+384 -216
View File
@@ -1,231 +1,399 @@
<template>
<router-link to="../"
class="absolute top-2 left-5 inline-block px-2 pb-0.5 text-sm text-black bg-gray-100 rounded hover:bg-gray-200 focus:outline-none border border-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">
&larr;
</router-link>
<div>
<slot v-if="endpointStatus">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RECENT CHECKS</h1>
<hr class="mb-4"/>
<Endpoint
:data="endpointStatus"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
:showAverageResponseTime="showAverageResponseTime"
/>
<Pagination @page="changePage" :numberOfResultsPerPage="20" />
</slot>
<div v-if="endpointStatus && endpointStatus.key" class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
<hr/>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
<img :src="generateUptimeBadgeImageURL('30d')" alt="30d uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateUptimeBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateUptimeBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key && showResponseTimeChartAndBadges" class="mt-12">
<div class="flex items-center justify-between">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RESPONSE TIME</h1>
<select v-model="selectedChartDuration" class="text-sm bg-gray-400 text-white border border-gray-600 rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="response time chart" class="mt-6"/>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
<img :src="generateResponseTimeBadgeImageURL('30d')" alt="7d response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
<img :src="generateResponseTimeBadgeImageURL('7d')" alt="7d response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateResponseTimeBadgeImageURL('24h')" alt="24h response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateResponseTimeBadgeImageURL('1h')" alt="1h response time badge" class="mx-auto mt-2"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">CURRENT HEALTH</h1>
<hr />
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">EVENTS</h1>
<hr />
<ul role="list" class="px-0 xl:px-24 divide-y divide-gray-200 dark:divide-gray-600">
<li v-for="event in events" :key="event" class="p-3 my-4">
<h2 class="text-sm sm:text-lg">
<ArrowUpCircleIcon v-if="event.type === 'HEALTHY'" class="w-8 inline mr-2 text-green-600" />
<ArrowDownCircleIcon v-else-if="event.type === 'UNHEALTHY'" class="w-8 inline mr-2 text-red-500" />
<PlayCircleIcon v-else-if="event.type === 'START'" class="w-8 inline mr-2 text-gray-400 dark:text-gray-100" />
{{ event.fancyText }}
</h2>
<div class="flex mt-1 text-xs sm:text-sm text-gray-400">
<div class="flex-2 text-left pl-12">
{{ prettifyTimestamp(event.timestamp) }}
<div class="dashboard-container bg-background">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<div class="mb-6">
<Button variant="ghost" class="mb-4" @click="goBack">
<ArrowLeft class="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
<div v-if="endpointStatus && endpointStatus.name" class="space-y-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-4xl font-bold tracking-tight">{{ endpointStatus.name }}</h1>
<div class="flex items-center gap-3 text-muted-foreground mt-2">
<span v-if="endpointStatus.group">Group: {{ endpointStatus.group }}</span>
<span v-if="endpointStatus.group && hostname"></span>
<span v-if="hostname">{{ hostname }}</span>
</div>
</div>
<div class="flex-1 text-right">
{{ event.fancyTimeAgo }}
<StatusBadge :status="currentHealthStatus" />
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Current Status</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ currentHealthStatus === 'healthy' ? 'Operational' : 'Issues Detected' }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Response Time Range</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageResponseTimeRange }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Last Check</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ lastCheckTime }}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Recent Checks</CardTitle>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
@click="showAverageResponseTime = !showAverageResponseTime"
:title="showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'"
>
<Activity v-if="showAverageResponseTime" class="h-5 w-5" />
<Timer v-else class="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
@click="fetchData"
title="Refresh data"
:disabled="isRefreshing"
>
<RefreshCw :class="['h-4 w-4', isRefreshing && 'animate-spin']" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div class="space-y-4">
<EndpointCard
v-if="endpointStatus"
:endpoint="endpointStatus"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
class="border-0 shadow-none bg-transparent p-0"
/>
<div v-if="endpointStatus && endpointStatus.key" class="pt-4 border-t">
<Pagination @page="changePage" :numberOfResultsPerPage="50" :currentPageProp="currentPage" />
</div>
</div>
</CardContent>
</Card>
<div v-if="showResponseTimeChartAndBadges" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Response Time Trend</CardTitle>
<select
v-model="selectedChartDuration"
class="text-sm bg-background border rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
</CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card v-for="period in ['30d', '7d', '24h', '1h']" :key="period">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground text-center">
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
</CardTitle>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeBadgeImageURL(period)" :alt="`${period} response time`" class="mx-auto mt-2" />
</CardContent>
</Card>
</div>
</div>
</li>
</ul>
<Card>
<CardHeader>
<CardTitle>Uptime Statistics</CardTitle>
</CardHeader>
<CardContent>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div v-for="period in ['30d', '7d', '24h', '1h']" :key="period" class="text-center">
<p class="text-sm text-muted-foreground mb-2">
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
</p>
<img :src="generateUptimeBadgeImageURL(period)" :alt="`${period} uptime`" class="mx-auto" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current Health</CardTitle>
</CardHeader>
<CardContent>
<div class="text-center">
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto" />
</div>
</CardContent>
</Card>
<Card v-if="events && events.length > 0">
<CardHeader>
<CardTitle>Events</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div v-for="event in events" :key="event.timestamp" class="flex items-start gap-4 pb-4 border-b last:border-0">
<div class="mt-1">
<ArrowUpCircle v-if="event.type === 'HEALTHY'" class="h-5 w-5 text-green-500" />
<ArrowDownCircle v-else-if="event.type === 'UNHEALTHY'" class="h-5 w-5 text-red-500" />
<PlayCircle v-else class="h-5 w-5 text-muted-foreground" />
</div>
<div class="flex-1">
<p class="font-medium">{{ event.fancyText }}</p>
<p class="text-sm text-muted-foreground">{{ prettifyTimestamp(event.timestamp) }} {{ event.fancyTimeAgo }}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div v-else class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
</div>
</div>
<Settings @refreshData="fetchData" />
</div>
<Settings @refreshData="fetchData"/>
</template>
<script>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, ArrowUpCircle, ArrowDownCircle, PlayCircle, Activity, Timer } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import Settings from '@/components/Settings.vue'
import Endpoint from '@/components/Endpoint.vue';
import {SERVER_URL} from "@/main.js";
import {helper} from "@/mixins/helper.js";
import Pagination from "@/components/Pagination";
import { ArrowDownCircleIcon, ArrowUpCircleIcon, PlayCircleIcon } from '@heroicons/vue/20/solid'
import Pagination from '@/components/Pagination.vue'
import Loading from '@/components/Loading.vue'
import { SERVER_URL } from '@/main.js'
import { helper } from '@/mixins/helper'
export default {
name: 'Details',
components: {
Pagination,
Endpoint,
Settings,
ArrowDownCircleIcon,
ArrowUpCircleIcon,
PlayCircleIcon
},
emits: ['showTooltip'],
mixins: [helper],
methods: {
fetchData() {
//console.log("[Details][fetchData] Fetching data");
fetch(`${this.serverUrl}/api/v1/endpoints/${this.$route.params.key}/statuses?page=${this.currentPage}`, {credentials: 'include'})
.then(response => {
if (response.status === 200) {
response.json().then(data => {
if (JSON.stringify(this.endpointStatus) !== JSON.stringify(data)) {
this.endpointStatus = data;
let events = [];
for (let i = data.events.length - 1; i >= 0; i--) {
let event = data.events[i];
if (i === data.events.length - 1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Endpoint is unhealthy';
} else if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint is healthy';
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
} else {
let nextEvent = data.events[i + 1];
if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint became healthy';
} else if (event.type === 'UNHEALTHY') {
if (nextEvent) {
event.fancyText = 'Endpoint was unhealthy for ' + this.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp);
} else {
event.fancyText = 'Endpoint became unhealthy';
}
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
}
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
events.push(event);
}
this.events = events;
// Check if there's any non-0 response time data
// If there isn't, it's likely an external endpoint, which means we should
// hide the response time chart and badges
for (let i = 0; i < data.results.length; i++) {
if (data.results[i].duration > 0) {
this.showResponseTimeChartAndBadges = true;
break;
}
}
}
});
} else {
response.text().then(text => {
console.log(`[Details][fetchData] Error: ${text}`);
});
}
});
},
generateHealthBadgeImageURL() {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/health/badge.svg`;
},
generateUptimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
},
generateResponseTimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/badge.svg`;
},
generateResponseTimeChartImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/chart.svg`;
},
changePage(page) {
this.currentPage = page;
this.fetchData();
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.showAverageResponseTime = !this.showAverageResponseTime;
},
},
data() {
return {
endpointStatus: {},
events: [],
hourlyAverageResponseTime: {},
selectedChartDuration: '24h',
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
currentPage: 1,
showAverageResponseTime: true,
showResponseTimeChartAndBadges: false,
chartLabels: [],
chartValues: [],
const router = useRouter()
const route = useRoute()
const emit = defineEmits(['showTooltip'])
const endpointStatus = ref(null) // For paginated historical data
const currentStatus = ref(null) // For current/latest status (always page 1)
const events = ref([])
const currentPage = ref(1)
const showResponseTimeChartAndBadges = ref(false)
const showAverageResponseTime = ref(false)
const selectedChartDuration = ref('24h')
const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
const isRefreshing = ref(false)
const latestResult = computed(() => {
// Use currentStatus for the actual latest result
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
return null
}
return currentStatus.value.results[currentStatus.value.results.length - 1]
})
const currentHealthStatus = computed(() => {
if (!latestResult.value) return 'unknown'
return latestResult.value.success ? 'healthy' : 'unhealthy'
})
const hostname = computed(() => {
return latestResult.value?.hostname || null
})
const pageAverageResponseTime = computed(() => {
// Use endpointStatus for current page's average response time
if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
return 'N/A'
}
let total = 0
let count = 0
for (const result of endpointStatus.value.results) {
if (result.duration) {
total += result.duration
count++
}
},
created() {
this.fetchData();
}
if (count === 0) return 'N/A'
return Math.round(total / count / 1000000)
})
const pageResponseTimeRange = computed(() => {
// Use endpointStatus for current page's response time range
if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
return 'N/A'
}
let min = Infinity
let max = 0
let hasData = false
for (const result of endpointStatus.value.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
hasData = true
}
}
if (!hasData) return 'N/A'
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
})
const lastCheckTime = computed(() => {
// Use currentStatus for real-time last check time
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
return 'Never'
}
return helper.methods.generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
})
const fetchData = async () => {
isRefreshing.value = true
try {
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
endpointStatus.value = data
// Always update currentStatus when on page 1 (including when returning to it)
if (currentPage.value === 1) {
currentStatus.value = data
}
let processedEvents = []
if (data.events && data.events.length > 0) {
for (let i = data.events.length - 1; i >= 0; i--) {
let event = data.events[i]
if (i === data.events.length - 1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Endpoint is unhealthy'
} else if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint is healthy'
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started'
}
} else {
let nextEvent = data.events[i + 1]
if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint became healthy'
} else if (event.type === 'UNHEALTHY') {
if (nextEvent) {
event.fancyText = 'Endpoint was unhealthy for ' + helper.methods.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
} else {
event.fancyText = 'Endpoint became unhealthy'
}
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started'
}
}
event.fancyTimeAgo = helper.methods.generatePrettyTimeAgo(event.timestamp)
processedEvents.push(event)
}
}
events.value = processedEvents
if (data.results && data.results.length > 0) {
for (let i = 0; i < data.results.length; i++) {
if (data.results[i].duration > 0) {
showResponseTimeChartAndBadges.value = true
break
}
}
}
} else {
console.error('[Details][fetchData] Error:', await response.text())
}
} catch (error) {
console.error('[Details][fetchData] Error:', error)
} finally {
isRefreshing.value = false
}
}
</script>
<style scoped>
.endpoint {
border-radius: 3px;
border-bottom-width: 3px;
const goBack = () => {
router.push('/')
}
</style>
const changePage = (page) => {
currentPage.value = page
fetchData()
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
}
const prettifyTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString()
}
const generateHealthBadgeImageURL = () => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
}
const generateUptimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
}
const generateResponseTimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
const generateResponseTimeChartImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
}
onMounted(() => {
fetchData()
})
</script>
+377 -65
View File
@@ -1,76 +1,388 @@
<template>
<Loading v-if="!retrievedData" class="h-64 w-64 px-4 my-24"/>
<slot>
<Endpoints
v-show="retrievedData"
:endpointStatuses="endpointStatuses"
:showStatusOnHover="true"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
:showAverageResponseTime="showAverageResponseTime"
/>
<Pagination v-show="retrievedData" @page="changePage" :numberOfResultsPerPage="20" />
</slot>
<Settings @refreshData="fetchData"/>
<div class="dashboard-container bg-background">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<div class="mb-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
<p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
</div>
<div class="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
@click="toggleShowAverageResponseTime"
:title="showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'"
>
<Activity v-if="showAverageResponseTime" class="h-5 w-5" />
<Timer v-else class="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" @click="refreshData" title="Refresh data">
<RefreshCw class="h-5 w-5" />
</Button>
</div>
</div>
<!-- Announcement Banner -->
<AnnouncementBanner :announcements="props.announcements" />
<!-- Search bar -->
<SearchBar
@search="handleSearch"
@update:showOnlyFailing="showOnlyFailing = $event"
@update:showRecentFailures="showRecentFailures = $event"
@update:groupByGroup="groupByGroup = $event"
@update:sortBy="sortBy = $event"
@initializeCollapsedGroups="initializeCollapsedGroups"
/>
</div>
<div v-if="loading" class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
<div v-else-if="filteredEndpoints.length === 0" class="text-center py-20">
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-semibold mb-2">No endpoints found</h3>
<p class="text-muted-foreground">
{{ searchQuery || showOnlyFailing || showRecentFailures
? 'Try adjusting your filters'
: 'No endpoints are configured' }}
</p>
</div>
<div v-else>
<!-- Grouped view -->
<div v-if="groupByGroup" class="space-y-6">
<div v-for="(endpoints, group) in paginatedEndpoints" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
<!-- Group Header -->
<div
@click="toggleGroupCollapse(group)"
class="endpoint-group-header flex items-center justify-between p-4 bg-card border-b cursor-pointer hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-3">
<ChevronDown v-if="uncollapsedGroups.has(group)" class="h-5 w-5 text-muted-foreground" />
<ChevronUp v-else class="h-5 w-5 text-muted-foreground" />
<h2 class="text-xl font-semibold text-foreground">{{ group }}</h2>
</div>
<div class="flex items-center gap-2">
<span v-if="calculateUnhealthyCount(endpoints) > 0"
class="bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium">
{{ calculateUnhealthyCount(endpoints) }}
</span>
<CheckCircle v-else class="h-6 w-6 text-green-600" />
</div>
</div>
<!-- Group Content -->
<div v-if="uncollapsedGroups.has(group)" class="endpoint-group-content p-4">
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<EndpointCard
v-for="endpoint in endpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
</div>
</div>
</div>
</div>
<!-- Regular view -->
<div v-else class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<EndpointCard
v-for="endpoint in paginatedEndpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
</div>
<div v-if="!groupByGroup && totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="icon"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="flex gap-1">
<Button
v-for="page in visiblePages"
:key="page"
:variant="page === currentPage ? 'default' : 'outline'"
size="sm"
@click="goToPage(page)"
>
{{ page }}
</Button>
</div>
<Button
variant="outline"
size="icon"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Settings @refreshData="fetchData" />
</div>
</template>
<script>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import EndpointCard from '@/components/EndpointCard.vue'
import SearchBar from '@/components/SearchBar.vue'
import Settings from '@/components/Settings.vue'
import Endpoints from '@/components/Endpoints.vue';
import Pagination from "@/components/Pagination";
import Loading from "@/components/Loading";
import {SERVER_URL} from "@/main.js";
import Loading from '@/components/Loading.vue'
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
import { SERVER_URL } from '@/main.js'
export default {
name: 'Home',
components: {
Loading,
Pagination,
Endpoints,
Settings,
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
fetchData() {
fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=${this.currentPage}`, {credentials: 'include'})
.then(response => {
this.retrievedData = true;
if (response.status === 200) {
response.json().then(data => {
if (JSON.stringify(this.endpointStatuses) !== JSON.stringify(data)) {
this.endpointStatuses = data;
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['showTooltip'])
const endpointStatuses = ref([])
const loading = ref(false)
const currentPage = ref(1)
const itemsPerPage = 96
const searchQuery = ref('')
const showOnlyFailing = ref(false)
const showRecentFailures = ref(false)
const showAverageResponseTime = ref(true)
const groupByGroup = ref(false)
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
const uncollapsedGroups = ref(new Set())
const filteredEndpoints = computed(() => {
let filtered = [...endpointStatuses.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(endpoint =>
endpoint.name.toLowerCase().includes(query) ||
(endpoint.group && endpoint.group.toLowerCase().includes(query))
)
}
if (showOnlyFailing.value) {
filtered = filtered.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
const latestResult = endpoint.results[endpoint.results.length - 1]
return !latestResult.success
})
}
if (showRecentFailures.value) {
filtered = filtered.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
return endpoint.results.some(result => !result.success)
})
}
// Sort by health if selected
if (sortBy.value === 'health') {
filtered.sort((a, b) => {
const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success
const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success
// Unhealthy first
if (!aHealthy && bHealthy) return -1
if (aHealthy && !bHealthy) return 1
// Then sort by name
return a.name.localeCompare(b.name)
})
}
return filtered
})
const totalPages = computed(() => {
return Math.ceil(filteredEndpoints.value.length / itemsPerPage)
})
const groupedEndpoints = computed(() => {
if (!groupByGroup.value) {
return null
}
const grouped = {}
filteredEndpoints.value.forEach(endpoint => {
const group = endpoint.group || 'No Group'
if (!grouped[group]) {
grouped[group] = []
}
grouped[group].push(endpoint)
})
// Sort groups alphabetically, with 'No Group' at the end
const sortedGroups = Object.keys(grouped).sort((a, b) => {
if (a === 'No Group') return 1
if (b === 'No Group') return -1
return a.localeCompare(b)
})
const result = {}
sortedGroups.forEach(group => {
result[group] = grouped[group]
})
return result
})
const paginatedEndpoints = computed(() => {
if (groupByGroup.value) {
// When grouping, we don't paginate
return groupedEndpoints.value
}
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredEndpoints.value.slice(start, end)
})
const visiblePages = computed(() => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const fetchData = async () => {
// Don't show loading state on refresh to prevent UI flicker
const isInitialLoad = endpointStatuses.value.length === 0
if (isInitialLoad) {
loading.value = true
}
try {
const response = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
// If this is the initial load, just set the data
if (isInitialLoad) {
endpointStatuses.value = data
} else {
// Check if endpoints have been added or removed
const currentKeys = new Set(endpointStatuses.value.map(ep => ep.key))
const newKeys = new Set(data.map(ep => ep.key))
const hasAdditions = data.some(ep => !currentKeys.has(ep.key))
const hasRemovals = endpointStatuses.value.some(ep => !newKeys.has(ep.key))
if (hasAdditions || hasRemovals) {
// Endpoints have changed, reset the array to maintain proper order
endpointStatuses.value = data
} else {
// Only statuses/results have changed, update in place to preserve scroll
const endpointMap = new Map(data.map(ep => [ep.key, ep]))
endpointStatuses.value.forEach((endpoint, index) => {
const updated = endpointMap.get(endpoint.key)
if (updated) {
// Update in place to preserve Vue's reactivity and scroll position
Object.assign(endpointStatuses.value[index], updated)
}
});
} else {
response.text().then(text => {
console.log(`[Home][fetchData] Error: ${text}`);
});
})
}
});
},
changePage(page) {
this.retrievedData = false; // Show loading only on page change or on initial load
this.currentPage = page;
this.fetchData();
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.showAverageResponseTime = !this.showAverageResponseTime;
},
},
data() {
return {
endpointStatuses: [],
currentPage: 1,
showAverageResponseTime: true,
retrievedData: false,
}
} else {
console.error('[Home][fetchData] Error:', await response.text())
}
} catch (error) {
console.error('[Home][fetchData] Error:', error)
} finally {
if (isInitialLoad) {
loading.value = false
}
},
created() {
this.retrievedData = false; // Show loading only on page change or on initial load
this.fetchData();
}
}
const refreshData = () => {
endpointStatuses.value = [];
fetchData()
}
const handleSearch = (query) => {
searchQuery.value = query
currentPage.value = 1
}
const goToPage = (page) => {
currentPage.value = page
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const toggleShowAverageResponseTime = () => {
showAverageResponseTime.value = !showAverageResponseTime.value
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
}
const calculateUnhealthyCount = (endpoints) => {
return endpoints.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
const latestResult = endpoint.results[endpoint.results.length - 1]
return !latestResult.success
}).length
}
const toggleGroupCollapse = (groupName) => {
if (uncollapsedGroups.value.has(groupName)) {
uncollapsedGroups.value.delete(groupName)
} else {
uncollapsedGroups.value.add(groupName)
}
// Save to localStorage
const uncollapsed = Array.from(uncollapsedGroups.value)
localStorage.setItem('gatus:uncollapsed-groups', JSON.stringify(uncollapsed))
localStorage.removeItem('gatus:collapsed-groups') // Remove old key if it exists
}
const initializeCollapsedGroups = () => {
// Get saved uncollapsed groups from localStorage
try {
const saved = localStorage.getItem('gatus:uncollapsed-groups')
if (saved) {
uncollapsedGroups.value = new Set(JSON.parse(saved))
}
// If no saved state, uncollapsedGroups stays empty (all collapsed by default)
} catch (e) {
console.warn('Failed to parse saved uncollapsed groups:', e)
localStorage.removeItem('gatus:uncollapsed-groups')
// On error, uncollapsedGroups stays empty (all collapsed by default)
}
}
onMounted(() => {
fetchData()
})
</script>
+58 -2
View File
@@ -6,9 +6,65 @@ module.exports = {
darkMode: 'class', // or 'media' or 'class'
theme: {
fontFamily: {
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace']
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace'],
'sans': ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif']
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
"accordion-down": {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
"accordion-up": {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
extend: {},
},
variants: {
extend: {},
+4
View File
@@ -1,3 +1,7 @@
// Note: The fs.Stats deprecation warning is from Vue CLI's webpack dependencies
// which are not yet compatible with Node.js v23. This is suppressed in the build
// script. All user dependencies have been updated to their latest versions.
// Consider migrating to Vite for better Node.js v23+ compatibility.
module.exports = {
filenameHashing: false,
productionSourceMap: false,
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -1 +1,11 @@
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}</script><title>{{ .UI.Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,initial-scale=1"/><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="16x16" href="/favicon-16x16.png"/><link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/><link rel="shortcut icon" href="/favicon.ico"/><link rel="stylesheet" href="/css/custom.css"/><meta name="description" content="{{ .UI.Description }}"/><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/><meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}"/><meta name="application-name" content="{{ .UI.Title }}"/><meta name="theme-color" content="#f7f9fb"/><script defer="defer" src="/js/chunk-vendors.js"></script><script defer="defer" src="/js/app.js"></script><link href="/css/app.css" rel="stylesheet"></head><body class="dark:bg-gray-900"><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html>
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();</script><title>{{ .UI.Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,initial-scale=1"/><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="16x16" href="/favicon-16x16.png"/><link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/><link rel="shortcut icon" href="/favicon.ico"/><link rel="stylesheet" href="/css/custom.css"/><meta name="description" content="{{ .UI.Description }}"/><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/><meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}"/><meta name="application-name" content="{{ .UI.Title }}"/><meta name="theme-color" content="#f7f9fb"/><script defer="defer" src="/js/chunk-vendors.js"></script><script defer="defer" src="/js/app.js"></script><link href="/css/app.css" rel="stylesheet"></head><body><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html>
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long