Compare commits

..
28 Commits
Author SHA1 Message Date
TwiN 86d5dabf90 security: Pin dependency versions
Too many cases of open source projects in the JS ecosystem just going wild lately
2022-03-22 19:34:55 -04:00
Bo-Yi WuandGitHub a81c81e42c feat(alert): Add group-specific to email list (#264)
* feat(alert): Add group-specific to email list

Add group-specific to list for email alert

https://github.com/TwiN/gatus/issues/96

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

* docs: update

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

* Update README.md

* Update README.md

* Update README.md

* chore: update

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

* Update README.md
2022-03-20 21:54:20 -04:00
Bo-Yi WuandGitHub bec2820969 docs(example): move config.yaml to config folder (#265)
ref: https://github.com/TwiN/gatus/issues/151#issuecomment-912932934

update all exmaple in docker-compose file.

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2022-03-20 00:04:13 -04:00
TwiN 0bf2271a73 test: Improve coverage for endpoint health evaluation edge cases (#262) 2022-03-15 20:53:03 -04:00
TwiN bd4b91bbbd fix: Display "<redacted>" instead of "host" in errors (#262) 2022-03-15 20:51:59 -04:00
Shashank DandGitHub fdec317df0 fix(config): replace hostname in error string if opted (#262) 2022-03-15 20:17:57 -04:00
TwiN 8970ad5ad5 refactor: Align new code from #259 with existing code 2022-03-09 21:05:57 -05:00
Andre BindewaldandGitHub c4255e65bc feat(client): OAuth2 Client credential support (#259)
* Initial implementation

* Added OAuth2 support to `client` config

* Revert "Initial implementation"

This reverts commit 7f2f3a603a.

* Restore vendored clientcredentials

* configureOAuth2 is now a func (including tests)

* README update

* Use the same OAuth2Config in all related tests

* Cleanup & comments
2022-03-09 20:53:51 -05:00
JonahandGitHub fcf046cbe8 feat(alerting): Add support for custom Telegram API URL (#257) 2022-03-05 15:44:11 -05:00
TwiN 6932edc6d0 docs: Fix Google Chat alerting configuration example 2022-02-14 20:03:08 -05:00
TwiN 3f961a7408 fix(ui): Prettify event timestamps
Closes #243
2022-02-03 20:16:13 -05:00
TwiN 4d0f3b6997 chore: Update Vue dependencies 2022-02-03 20:08:48 -05:00
TwiN 5a06599d96 chore: Update front-end dependencies 2022-01-30 18:10:39 -05:00
AzariaandGitHub d2a73a3590 chore: Fix grammatical issues in README (#241) 2022-01-23 20:06:55 -05:00
TwiN 932ecc436a test(security): Replace password-sha512 by password-bcrypt-base64 for test case 2022-01-17 11:55:05 -05:00
TwiN 1613274cb0 style(ui): Improve login UI design 2022-01-17 10:37:09 -05:00
TwiN 0b4720d94b build(gha): Increase timeout from 30 to 45 minutes 2022-01-16 23:26:24 -05:00
TwiN 16df341581 refactor: Remove unused function prettifyUptime 2022-01-16 22:02:58 -05:00
TwiN a848776a34 refactor(alerting): Sort alert types alphabetically 2022-01-16 00:07:19 -05:00
TwiN 681b1c63f1 docs: Fix broken Google Chat references 2022-01-16 00:06:03 -05:00
Kostiantyn PolischukandGitHub 51a4b63fb5 feat(alerting): Add Google Chat alerting provider (#234) 2022-01-14 21:00:00 -05:00
Khinshan KhanandGitHub 3a7977d086 build(docker): support all platforms that publish release supports (#238) 2022-01-13 21:37:25 -05:00
TwiN c682520dd9 fix(security): Use LRU eviction policy for OIDC sessions 2022-01-13 18:42:19 -05:00
TwiN 24b7258338 docs: Re-order parameters in Opsgenie and PagerDuty 2022-01-11 20:22:44 -05:00
TwiN 89e6e4abd8 fix(alerting): Omit nil structs within alerting provider struct 2022-01-11 20:13:37 -05:00
TwiN 4700f54798 docs: Remove outdated comment 2022-01-11 20:11:25 -05:00
TwiN 9ca4442e6a docs: Add missing section "Configuring Opsgenie alerts" 2022-01-11 20:10:06 -05:00
ce6f58f403 feat(alerting): Allow specifying a different username for email provider (#231)
* Update email alerting provider to supply a username, maintaining backwards compatibility with from

* Update README.md

Co-authored-by: Tom Moitié <tomm@gendius.co.uk>
Co-authored-by: TwiN <twin@twinnation.org>
2022-01-11 20:07:25 -05:00
49 changed files with 5758 additions and 4534 deletions
@@ -7,7 +7,7 @@ services:
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
- ./config:/config
networks:
- metrics
@@ -6,7 +6,7 @@ services:
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
- ./config:/config
networks:
- default
@@ -19,11 +19,11 @@ services:
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
- ./config:/config
networks:
- web
depends_on:
- postgres
networks:
web:
web:
@@ -5,5 +5,5 @@ services:
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
- ./data:/data/
- ./config:/config
- ./data:/data/
+1 -1
View File
@@ -5,4 +5,4 @@ services:
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml
- ./config:/config
+2 -2
View File
@@ -9,7 +9,7 @@ jobs:
name: Publish latest
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 30
timeout-minutes: 45
steps:
- name: Check out code
uses: actions/checkout@v2
@@ -27,7 +27,7 @@ jobs:
- name: Build and push docker image
uses: docker/build-push-action@v2
with:
platforms: linux/amd64
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
publish-release:
name: Publish release
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
steps:
- name: Check out code
uses: actions/checkout@v2
+120 -46
View File
@@ -41,8 +41,10 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Alerting](#alerting)
- [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts)
- [Configuring Teams alerts](#configuring-teams-alerts)
@@ -93,7 +95,7 @@ monitor these features and potentially alert you before any clients are impacted
A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer
was to go down right now. Will any of your existing alerts be triggered? Your metrics wont report an increase in errors
if theres no traffic that makes it to your applications. This puts you in a situation where your clients are the ones
if no traffic makes it to your applications. This puts you in a situation where your clients are the ones
that will notify you about the degradation of your services rather than you reassuring them that you're working on
fixing the issue before they even know about it.
@@ -103,7 +105,7 @@ The main features of Gatus are:
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio, Google chat and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Metrics**
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
- **[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)
@@ -162,7 +164,7 @@ If you want to test it locally, see [Docker](#docker).
| `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` |
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` |
| `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `email`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
| `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `email`, `googlechat`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `false` |
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
@@ -271,11 +273,16 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
the client used to send the request.
| Parameter | Description | Default |
|:-------------------------|:------------------------------------------------------------------------|:--------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
| Parameter | Description | Default |
|:------------------------------|:---------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
| `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
@@ -302,6 +309,20 @@ endpoints:
- "[STATUS] == 200"
```
This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
```yaml
endpoints:
- name: website
url: "https://your.health.api/getHealth"
client:
oauth2:
token-url: https://your-token-server/token
client-id: 00000000-0000-0000-0000-000000000000
client-secret: your-client-secret
scopes: ['https://your.health.api/.default']
conditions:
- "[STATUS] == 200"
```
### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
@@ -314,6 +335,7 @@ ignored.
|:-----------------------|:-----------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
@@ -354,24 +376,34 @@ endpoints:
#### Configuring Email alerts
| Parameter | Description | Default |
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.email` | Configuration for alerts of type `email` | `{}` |
| `alerting.email.from` | Email used to send the alert | Required `""` |
| `alerting.email.password` | Password of the email used to send the alert | Required `""` |
| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` |
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
| `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:---------------------------------- |:------------------------------------------------------------------------------------------ |:------------- |
| `alerting.email` | Configuration for alerts of type `email` | `{}` |
| `alerting.email.from` | Email used to send the alert | Required `""` |
| `alerting.email.username` | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`. | `""` |
| `alerting.email.password` | Password of the SMTP server used to send the alert | Required `""` |
| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` |
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
| `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.email.overrides[].to` | Email(s) to send the alerts to | `""` |
```yaml
alerting:
email:
from: "from@example.com"
username: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
to: "recipient3@example.com,recipient4@example.com"
endpoints:
- name: website
@@ -386,10 +418,50 @@ endpoints:
enabled: true
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: email
enabled: true
description: "healthcheck failed"
send-on-resolved: true
```
**NOTE:** Some mail servers are painfully slow.
#### Configuring Google Chat alerts
| Parameter | Description | Default |
|:------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
```yaml
alerting:
googlechat:
webhook-url: "https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: googlechat
enabled: true
description: "healthcheck failed"
send-on-resolved: true
```
#### Configuring Mattermost alerts
| Parameter | Description | Default |
@@ -425,7 +497,6 @@ Here's an example of what the notifications look like:
![Mattermost notifications](.github/assets/mattermost-alerts.png)
#### Configuring Messagebird alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
@@ -459,16 +530,18 @@ endpoints:
description: "healthcheck failed"
```
#### Configuring Opsgenie alerts
| Parameter | Description | Default |
|:----------------------------------|:--------------------------------------------|:---------------------|
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` |
| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` |
| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` |
| `alerting.opsgenie.source` | Source field of the alert. | `gatus` |
| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` |
| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` |
| `alerting.opsgenie.tags` | Tags of alert. | `[]` |
| Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` |
| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` |
| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` |
| `alerting.opsgenie.source` | Source field of the alert. | `gatus` |
| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` |
| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` |
| `alerting.opsgenie.tags` | Tags of alert. | `[]` |
| `alerting.opsgenie.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
Opsgenie provider will automatically open and close alerts.
@@ -478,26 +551,26 @@ alerting:
api-key: "00000000-0000-0000-0000-000000000000"
```
#### Configuring PagerDuty alerts
| Parameter | Description | Default |
|:-------------------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
parameter to `true` will not create another incident, but mark the incident as resolved on
parameter to `true` will not create another incident but mark the incident as resolved on
PagerDuty instead.
Behavior:
- By default, `alerting.pagerduty.integration-key` is used as the integration key
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.pagerduty.overrides[].group`, the provider will use that override's integration key instead of `alerting.pagerduty.integration-key`'s
```yaml
alerting:
pagerduty:
@@ -505,8 +578,8 @@ alerting:
# You can also add group-specific integration keys, which will
# override the integration key above for the specified groups
overrides:
- group: "core"
integration-key: "********************************"
- group: "core"
integration-key: "********************************"
endpoints:
- name: website
@@ -610,12 +683,13 @@ Here's an example of what the notifications look like:
![Teams notifications](.github/assets/teams-alerts.png)
#### Configuring Telegram alerts
| Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -1008,7 +1082,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
> tells Gatus to only evaluate one endpoint at a time.
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one endpoint at a time
In other words, even if you have multiple endpoints with the exact same interval, they will not execute at the same time.
In other words, even if you have multiple endpoints with the same interval, they will not execute at the same time.
You can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval,
such as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on
@@ -1026,10 +1100,10 @@ to respect the configured interval, for instance:
- Endpoint B has an interval of 5s, and takes 1ms to complete
- Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval
To sum it up, while Gatus can really handle any interval you throw at it, you're better off having slow requests with
To sum it up, while Gatus can handle any interval you throw at it, you're better off having slow requests with
higher interval.
As a rule of the thumb, I personally set interval for more complex health checks to `5m` (5 minutes) and
As a rule of thumb, I personally set the interval for more complex health checks to `5m` (5 minutes) and
simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
@@ -1141,7 +1215,7 @@ There are three main reasons why you might want to disable the monitoring lock:
- You're using Gatus for load testing (each endpoint are periodically evaluated on a different goroutine, so
technically, if you create 100 endpoints with a 1 seconds interval, Gatus will send 100 requests per second)
- You have a _lot_ of endpoints to monitor
- You want to test multiple endpoints at very short interval (< 5s)
- You want to test multiple endpoints at very short intervals (< 5s)
### Reloading configuration on the fly
@@ -1234,8 +1308,8 @@ web:
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg)
Gatus can automatically generate a SVG badge for one of your monitored endpoints.
This allows you to put badges in your individual applications' README or even create your own status page, if you
Gatus can automatically generate an SVG badge for one of your monitored endpoints.
This allows you to put badges in your individual applications' README or even create your own status page if you
desire.
The path to generate a badge is the following:
@@ -1259,7 +1333,7 @@ Example:
```
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
```
If you'd like to see a visual example of each badges available, you can simply navigate to the endpoint's detail page.
If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
### Response time
@@ -1277,7 +1351,7 @@ Where:
### API
Gatus provides a simple read-only API which can be queried in order to programmatically determine endpoint status and history.
Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.
All endpoints are available via a GET request to the following endpoint:
```
+3
View File
@@ -14,6 +14,9 @@ const (
// TypeEmail is the Type for the email alerting provider
TypeEmail Type = "email"
// TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat"
// TypeMattermost is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost"
+10
View File
@@ -6,6 +6,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/opsgenie"
@@ -21,6 +22,9 @@ type Config struct {
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// googlechat is the configuration for the Google chat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
@@ -61,6 +65,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil
}
return config.Custom
case alert.TypeGoogleChat:
if config.GoogleChat == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.GoogleChat
case alert.TypeDiscord:
if config.Discord == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
+2 -2
View File
@@ -22,10 +22,10 @@ type AlertProvider struct {
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
+1 -1
View File
@@ -16,7 +16,7 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
+40 -2
View File
@@ -13,6 +13,7 @@ import (
// AlertProvider is the configuration necessary for sending an alert using SMTP
type AlertProvider struct {
From string `yaml:"from"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Host string `yaml:"host"`
Port int `yaml:"port"`
@@ -20,22 +21,47 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
var username string
if len(provider.Username) > 0 {
username = provider.Username
} else {
username = provider.From
}
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
m := gomail.NewMessage()
m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.To, ",")...)
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
d := gomail.NewDialer(provider.Host, provider.Port, provider.From, provider.Password)
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
return d.DialAndSend(m)
}
@@ -65,6 +91,18 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoin
return subject, message + description + "\n\nCondition results:\n" + results
}
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
+105 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
@@ -18,6 +18,47 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
@@ -77,3 +118,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "to01@example.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+124
View File
@@ -0,0 +1,124 @@
package googlechat
import (
"bytes"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Google chat
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.WebhookURL) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
if resolved {
color = "#36A64F"
message = fmt.Sprintf("<font color='%s'>An alert has been resolved after passing successfully %d time(s) in a row</font>", color, alert.SuccessThreshold)
} else {
color = "#DD0000"
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":: " + alertDescription
}
return fmt.Sprintf(`{
"cards": [
{
"sections": [
{
"widgets": [
{
"keyValue": {
"topLabel": "%s [%s]",
"content": "%s",
"contentMultiline": "true",
"bottomLabel": "%s",
"icon": "BOOKMARK"
}
},
{
"keyValue": {
"topLabel": "Condition results",
"content": "%s",
"contentMultiline": "true",
"icon": "DESCRIPTION"
}
},
{
"buttons": [
{
"textButton": {
"text": "URL",
"onClick": {
"openLink": {
"url": "%s"
}
}
}
}
]
}
]
}
]
}
]
}`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
@@ -0,0 +1,160 @@
package googlechat
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-1\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-2\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
b, _ := json.Marshal(body)
e, _ := json.Marshal(scenario.ExpectedBody)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", e, b)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
+2 -2
View File
@@ -16,10 +16,10 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
+2
View File
@@ -5,6 +5,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
@@ -54,6 +55,7 @@ var (
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
+1 -1
View File
@@ -16,7 +16,7 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
+1 -1
View File
@@ -16,7 +16,7 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
+11 -4
View File
@@ -11,13 +11,16 @@ import (
"github.com/TwiN/gatus/v3/core"
)
const defaultAPIURL = "https://api.telegram.org"
// AlertProvider is the configuration necessary for sending an alert using Telegram
type AlertProvider struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
Token string `yaml:"token"`
ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
@@ -28,7 +31,11 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), buffer)
apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
if err != nil {
return err
}
+1 -1
View File
@@ -21,7 +21,7 @@ type AlertProvider struct {
To string `yaml:"to"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
+79
View File
@@ -1,8 +1,13 @@
package client
import (
"bytes"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/TwiN/gatus/v3/test"
)
func TestGetHTTPClient(t *testing.T) {
@@ -10,6 +15,12 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false,
IgnoreRedirect: false,
Timeout: 0,
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
},
}
cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil {
@@ -146,3 +157,71 @@ func TestCanCreateTCPConnection(t *testing.T) {
t.Error("should've failed, because there's no port in the address")
}
}
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
// header to all outgoing HTTP calls.
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
defer InjectHTTPClient(nil)
oAuth2Config := &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
}
mockHttpClient := &http.Client{
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
// if the mock HTTP client tries to get a token from the `token-server`
// we provide the expected token response
if r.Host == "token-server.local" {
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewReader(
[]byte(
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
),
)),
}
}
// to verify the headers were sent as expected, we echo them back in the
// `X-Org-Authorization` header and check if the token value matches our
// mocked `token-server` response
return &http.Response{
StatusCode: http.StatusOK,
Header: map[string][]string{
"X-Org-Authorization": {r.Header.Get("Authorization")},
},
Body: http.NoBody,
}
}),
}
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
InjectHTTPClient(mockHttpClientWithOAuth)
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
if err != nil {
t.Error("expected no error, got", err.Error())
}
response, err := mockHttpClientWithOAuth.Do(request)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if response.Header == nil {
t.Error("expected response headers, but got nil")
}
// the mock response echos the Authorization header used in the request back
// to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
}
}
+54 -4
View File
@@ -1,9 +1,14 @@
package client
import (
"context"
"crypto/tls"
"errors"
"net/http"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
const (
@@ -11,7 +16,8 @@ const (
)
var (
// DefaultConfig is the default client configuration
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
defaultConfig = Config{
Insecure: false,
IgnoreRedirect: false,
@@ -28,22 +34,50 @@ func GetDefaultConfig() *Config {
// Config is the configuration for clients
type Config struct {
// Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure"`
Insecure bool `yaml:"insecure,omitempty"`
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
IgnoreRedirect bool `yaml:"ignore-redirect"`
IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
// Timeout for the client
Timeout time.Duration `yaml:"timeout"`
// OAuth2Config is the OAuth2 configuration used for the client.
//
// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
// See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
httpClient *http.Client
}
// OAuth2Config is the configuration for the OAuth2 client credentials flow
type OAuth2Config struct {
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() {
func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second
}
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
return nil
}
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
}
// isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
// GetHTTPClient return an HTTP client matching the Config's parameters.
@@ -68,6 +102,22 @@ func (c *Config) getHTTPClient() *http.Client {
return nil
},
}
if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
}
}
return c.httpClient
}
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
oauth2cfg := clientcredentials.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: c.Scopes,
TokenURL: c.TokenURL,
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
return oauth2cfg.Client(ctx)
}
+4 -4
View File
@@ -1172,12 +1172,12 @@ endpoints:
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
const expectedUsername = "admin"
const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22"
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
security:
basic:
username: "%s"
password-sha512: "%s"
password-bcrypt-base64: "%s"
endpoints:
- name: website
url: https://twin.sh/health
@@ -1202,8 +1202,8 @@ endpoints:
if config.Security.Basic.Username != expectedUsername {
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
}
if config.Security.Basic.PasswordSha512Hash != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordSha512Hash should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash)
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash)
}
}
+6 -1
View File
@@ -111,7 +111,9 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
if endpoint.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig()
} else {
endpoint.ClientConfig.ValidateAndSetDefaults()
if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig()
@@ -195,6 +197,9 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
result.body = nil
// Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
}
result.Hostname = ""
}
return result
+51 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
)
func TestEndpoint_IsEnabled(t *testing.T) {
@@ -270,6 +271,9 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
if result.Hostname != "twin.sh" {
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
}
}
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
@@ -288,7 +292,53 @@ func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if result.Success {
t.Error("Because one of the conditions failed, success should have been false")
t.Error("Because one of the conditions failed, result.Success should have been false")
}
}
func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
condition := Condition("[STATUS] invalid 200")
endpoint := Endpoint{
Name: "invalid-condition",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
// XXX: Should this really not return an error? After all, the condition is not valid and conditions are part of the endpoint...
t.Error("endpoint validation should've been successful, but wasn't")
}
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
}
func TestIntegrationEvaluateHealthWithError(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "invalid-host",
URL: "http://invalid/health",
Conditions: []*Condition{&condition},
UIConfig: &ui.Config{
HideHostname: true,
},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "<redacted>") {
t.Error("result.Errors[0] should've had the hostname redacted because ui.hide-hostname is set to true")
}
if result.Hostname != "" {
t.Error("result.Hostname should've been empty because ui.hide-hostname is set to true")
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ package security
import "github.com/TwiN/gocache/v2"
var sessions = gocache.NewCache() // TODO: Move this to storage
var sessions = gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed) // TODO: Move this to storage
-2
View File
@@ -16,8 +16,6 @@ type Config struct {
// Path is the path used by the store to achieve persistence
// If blank, persistence is disabled.
// Note that not all Type support persistence
//
// XXX: Rename to path for v4.0.0
Path string `yaml:"path"`
// File is the path of the file to use for persistence
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package clientcredentials implements the OAuth2.0 "client credentials" token flow,
// also known as the "two-legged OAuth 2.0".
//
// This should be used when the client is acting on its own behalf or when the client
// is the resource owner. It may also be used when requesting access to protected
// resources based on an authorization previously arranged with the authorization
// server.
//
// See https://tools.ietf.org/html/rfc6749#section-4.4
package clientcredentials // import "golang.org/x/oauth2/clientcredentials"
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"golang.org/x/oauth2"
"golang.org/x/oauth2/internal"
)
// Config describes a 2-legged OAuth2 flow, with both the
// client application information and the server's endpoint URLs.
type Config struct {
// ClientID is the application's ID.
ClientID string
// ClientSecret is the application's secret.
ClientSecret string
// TokenURL is the resource server's token endpoint
// URL. This is a constant specific to each server.
TokenURL string
// Scope specifies optional requested permissions.
Scopes []string
// EndpointParams specifies additional parameters for requests to the token endpoint.
EndpointParams url.Values
// AuthStyle optionally specifies how the endpoint wants the
// client ID & client secret sent. The zero value means to
// auto-detect.
AuthStyle oauth2.AuthStyle
}
// Token uses client credentials to retrieve a token.
//
// The provided context optionally controls which HTTP client is used. See the oauth2.HTTPClient variable.
func (c *Config) Token(ctx context.Context) (*oauth2.Token, error) {
return c.TokenSource(ctx).Token()
}
// Client returns an HTTP client using the provided token.
// The token will auto-refresh as necessary.
//
// The provided context optionally controls which HTTP client
// is returned. See the oauth2.HTTPClient variable.
//
// The returned Client and its Transport should not be modified.
func (c *Config) Client(ctx context.Context) *http.Client {
return oauth2.NewClient(ctx, c.TokenSource(ctx))
}
// TokenSource returns a TokenSource that returns t until t expires,
// automatically refreshing it as necessary using the provided context and the
// client ID and client secret.
//
// Most users will use Config.Client instead.
func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
source := &tokenSource{
ctx: ctx,
conf: c,
}
return oauth2.ReuseTokenSource(nil, source)
}
type tokenSource struct {
ctx context.Context
conf *Config
}
// Token refreshes the token by using a new client credentials request.
// tokens received this way do not include a refresh token
func (c *tokenSource) Token() (*oauth2.Token, error) {
v := url.Values{
"grant_type": {"client_credentials"},
}
if len(c.conf.Scopes) > 0 {
v.Set("scope", strings.Join(c.conf.Scopes, " "))
}
for k, p := range c.conf.EndpointParams {
// Allow grant_type to be overridden to allow interoperability with
// non-compliant implementations.
if _, ok := v[k]; ok && k != "grant_type" {
return nil, fmt.Errorf("oauth2: cannot overwrite parameter %q", k)
}
v[k] = p
}
tk, err := internal.RetrieveToken(c.ctx, c.conf.ClientID, c.conf.ClientSecret, c.conf.TokenURL, v, internal.AuthStyle(c.conf.AuthStyle))
if err != nil {
if rErr, ok := err.(*internal.RetrieveError); ok {
return nil, (*oauth2.RetrieveError)(rErr)
}
return nil, err
}
t := &oauth2.Token{
AccessToken: tk.AccessToken,
TokenType: tk.TokenType,
RefreshToken: tk.RefreshToken,
Expiry: tk.Expiry,
}
return t.WithExtra(tk.Raw), nil
}
+1
View File
@@ -116,6 +116,7 @@ golang.org/x/net/ipv6
# golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c
## explicit; go 1.11
golang.org/x/oauth2
golang.org/x/oauth2/clientcredentials
golang.org/x/oauth2/internal
# golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
## explicit
+4746 -4386
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "gatus",
"version": "3.4.0",
"version": "3.6.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode development",
@@ -8,22 +8,22 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.19.1",
"core-js": "3.21.0",
"vue": "3.2.21",
"vue-router": "^4.0.11"
"vue-router": "4.0.12"
},
"devDependencies": {
"@vue/cli-plugin-babel": "5.0.0-beta.6",
"@vue/cli-plugin-eslint": "5.0.0-beta.6",
"@vue/cli-plugin-router": "5.0.0-beta.6",
"@vue/cli-service": "5.0.0-beta.6",
"@vue/compiler-sfc": "3.2.21",
"autoprefixer": "^10.4.1",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^7.17.0",
"postcss": "^8.4.5",
"tailwindcss": "^3.0.8"
"@vue/cli-plugin-babel": "5.0.0-rc.2",
"@vue/cli-plugin-eslint": "5.0.0-rc.2",
"@vue/cli-plugin-router": "5.0.0-rc.2",
"@vue/cli-service": "5.0.0-rc.2",
"@vue/compiler-sfc": "3.2.29",
"autoprefixer": "10.4.2",
"babel-eslint": "10.1.0",
"eslint": "7.32.0",
"eslint-plugin-vue": "7.20.0",
"postcss": "8.4.6",
"tailwindcss": "3.0.18"
},
"eslintConfig": {
"root": true,
+31 -13
View File
@@ -1,5 +1,6 @@
<template>
<div v-if="retrievedConfig" class="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">
<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">
@@ -7,25 +8,40 @@
</div>
<div class="w-1/4 flex justify-end">
<a :href="link" target="_blank" style="width: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;"/>
<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;" />
</a>
</div>
</div>
</div>
<div v-if="$route && $route.query.error" class="text-red-500 text-center my-2">
<div class="text-xl">
<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>
<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="mt-8 py-7 px-4 rounded-sm sm:bg-gray-100 sm:border sm:border-gray-300 sm:shadow-2xl sm:px-10">
<div class="sm:mx-auto sm:w-full">
<h2 class="mb-4 text-center text-xl font-bold text-gray-600 dark:text-gray-200 dark:sm:text-gray-600 ">
Sign in
</h2>
</div>
<div v-if="$route && $route.query.error" class="text-red-500 text-center my-2">
<div class="text-xl">
<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>
</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 hover:bg-green-800">
Login with OIDC
</a>
</div>
</div>
<div v-if="config && config.oidc && !config.authenticated">
<a :href="`${SERVER_URL}/oidc/login`" class="max-w-lg mx-auto w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-lg text-white bg-green-700 hover:bg-green-800">
Login with OIDC
</a>
</div>
<router-view @showTooltip="showTooltip"/>
</div>
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
<Social/>
</template>
@@ -35,10 +51,12 @@
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue';
import {SERVER_URL} from "@/main";
import Loading from "@/components/Loading";
export default {
name: 'App',
components: {
Loading,
Social,
Tooltip
},
+11
View File
@@ -0,0 +1,11 @@
<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>
</template>
<script>
export default {
}
</script>
+3 -10
View File
@@ -25,23 +25,16 @@
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoints',
props: {
event: Event,
result: Object
},
mixins: [helper],
methods: {
prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
},
htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
+10
View File
@@ -16,5 +16,15 @@ export const helper = {
}
return (differenceInMs / 1000).toFixed(0) + " seconds ago";
},
prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
},
}
}
+17 -16
View File
@@ -22,34 +22,34 @@
<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 7 days</h2>
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto" />
<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" />
<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" />
<img :src="generateUptimeBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key" class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RESPONSE TIME</h1>
<hr/>
<img :src="generateResponseTimeChartImageURL()" alt="response time chart" class="mt-6" />
<img :src="generateResponseTimeChartImageURL()" 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 7 days</h2>
<img :src="generateResponseTimeBadgeImageURL('7d')" alt="7d response time badge" class="mx-auto mt-2" />
<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" />
<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" />
<img :src="generateResponseTimeBadgeImageURL('1h')" alt="1h response time badge" class="mx-auto mt-2"/>
</div>
</div>
</div>
@@ -70,7 +70,7 @@
</h2>
<div class="flex mt-1 text-sm text-gray-400">
<div class="flex-1 text-left pl-10">
{{ new Date(event.timestamp).toISOString() }}
{{ prettifyTimestamp(event.timestamp) }}
</div>
<div class="flex-1 text-right">
{{ event.fancyTimeAgo }}
@@ -105,8 +105,9 @@ export default {
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 => response.json())
.then(data => {
.then(response => {
if (response.status === 200) {
response.json().then(data => {
if (JSON.stringify(this.endpointStatus) !== JSON.stringify(data)) {
this.endpointStatus = data;
this.uptime = data.uptime;
@@ -141,6 +142,12 @@ export default {
this.events = events;
}
});
} else {
response.text().then(text => {
console.log(`[Details][fetchData] Error: ${text}`);
});
}
});
},
generateUptimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
@@ -151,12 +158,6 @@ export default {
generateResponseTimeChartImageURL() {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/24h/chart.svg`;
},
prettifyUptime(uptime) {
if (!uptime) {
return '0%';
}
return (uptime * 100).toFixed(2) + '%'
},
prettifyTimeDifference(start, end) {
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
return minutes + (minutes === 1 ? ' minute' : ' minutes');
+9 -3
View File
@@ -25,14 +25,20 @@ export default {
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
fetchData() {
//console.log("[Home][fetchData] Fetching data");
fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=${this.currentPage}`, {credentials: 'include'})
.then(response => response.json())
.then(data => {
.then(response => {
if (response.status === 200) {
response.json().then(data => {
if (JSON.stringify(this.endpointStatuses) !== JSON.stringify(data)) {
this.endpointStatuses = data;
}
});
} else {
response.text().then(text => {
console.log(`[Home][fetchData] Error: ${text}`);
});
}
});
},
changePage(page) {
this.currentPage = page;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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
File diff suppressed because one or more lines are too long