diff --git a/.examples/docker-compose-grafana-prometheus/docker-compose.yml b/.examples/docker-compose-grafana-prometheus/compose.yaml similarity index 100% rename from .examples/docker-compose-grafana-prometheus/docker-compose.yml rename to .examples/docker-compose-grafana-prometheus/compose.yaml diff --git a/.examples/docker-compose-mattermost/docker-compose.yml b/.examples/docker-compose-mattermost/compose.yaml similarity index 96% rename from .examples/docker-compose-mattermost/docker-compose.yml rename to .examples/docker-compose-mattermost/compose.yaml index 10622c1b..67a590d1 100644 --- a/.examples/docker-compose-mattermost/docker-compose.yml +++ b/.examples/docker-compose-mattermost/compose.yaml @@ -1,4 +1,3 @@ -version: "3.9" services: gatus: container_name: gatus diff --git a/.examples/docker-compose-mtls/docker-compose.yml b/.examples/docker-compose-mtls/compose.yaml similarity index 96% rename from .examples/docker-compose-mtls/docker-compose.yml rename to .examples/docker-compose-mtls/compose.yaml index 9ea21f50..666c27d2 100644 --- a/.examples/docker-compose-mtls/docker-compose.yml +++ b/.examples/docker-compose-mtls/compose.yaml @@ -1,4 +1,3 @@ -version: "3.9" services: nginx: image: nginx:stable diff --git a/.examples/docker-compose-multiple-config-files/docker-compose.yml b/.examples/docker-compose-multiple-config-files/compose.yaml similarity index 92% rename from .examples/docker-compose-multiple-config-files/docker-compose.yml rename to .examples/docker-compose-multiple-config-files/compose.yaml index 514cd558..fa3105f9 100644 --- a/.examples/docker-compose-multiple-config-files/docker-compose.yml +++ b/.examples/docker-compose-multiple-config-files/compose.yaml @@ -1,4 +1,3 @@ -version: "3.8" services: gatus: image: twinproduction/gatus:latest diff --git a/.examples/docker-compose-postgres-storage/docker-compose.yml b/.examples/docker-compose-postgres-storage/compose.yaml similarity index 97% rename from .examples/docker-compose-postgres-storage/docker-compose.yml rename to .examples/docker-compose-postgres-storage/compose.yaml index a1d76cbc..3abfd744 100644 --- a/.examples/docker-compose-postgres-storage/docker-compose.yml +++ b/.examples/docker-compose-postgres-storage/compose.yaml @@ -1,4 +1,3 @@ -version: "3.9" services: postgres: image: postgres diff --git a/.examples/docker-compose-sqlite-storage/docker-compose.yml b/.examples/docker-compose-sqlite-storage/compose.yaml similarity index 90% rename from .examples/docker-compose-sqlite-storage/docker-compose.yml rename to .examples/docker-compose-sqlite-storage/compose.yaml index 35a4a9a2..6b0186ae 100644 --- a/.examples/docker-compose-sqlite-storage/docker-compose.yml +++ b/.examples/docker-compose-sqlite-storage/compose.yaml @@ -1,4 +1,3 @@ -version: "3.9" services: gatus: image: twinproduction/gatus:latest diff --git a/.examples/docker-compose/docker-compose.yml b/.examples/docker-compose/compose.yaml similarity index 89% rename from .examples/docker-compose/docker-compose.yml rename to .examples/docker-compose/compose.yaml index 185ba525..b835d081 100644 --- a/.examples/docker-compose/docker-compose.yml +++ b/.examples/docker-compose/compose.yaml @@ -1,4 +1,3 @@ -version: "3.8" services: gatus: image: twinproduction/gatus:latest diff --git a/.github/workflows/publish-custom.yml b/.github/workflows/publish-custom.yml index 02426ec4..cb5370bc 100644 --- a/.github/workflows/publish-custom.yml +++ b/.github/workflows/publish-custom.yml @@ -5,6 +5,15 @@ on: inputs: tag: description: Custom tag to publish + platforms: + description: Platforms to publish to (comma separated list) + default: linux/amd64 + type: choice + options: + - linux/amd64 + - linux/arm/v7 + - linux/arm64 + jobs: publish-custom: runs-on: ubuntu-latest @@ -33,7 +42,7 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v6 with: - platforms: linux/amd64 + platforms: ${{ inputs.platforms }} pull: true push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/regenerate-static-assets.yml b/.github/workflows/regenerate-static-assets.yml new file mode 100644 index 00000000..aa313555 --- /dev/null +++ b/.github/workflows/regenerate-static-assets.yml @@ -0,0 +1,107 @@ +name: regenerate-static-assets +on: + issue_comment: + types: [created] + +jobs: + check-command: + runs-on: ubuntu-latest + if: ${{ github.event.issue.pull_request }} + permissions: + pull-requests: write # required for adding reactions to command comments on PRs + checks: read # required to check if all ci checks have passed + outputs: + continue: ${{ steps.command.outputs.continue }} + steps: + - name: Check command trigger + id: command + uses: github/command@v2 + with: + command: "/regenerate-static-assets" + permissions: "write,admin" # The allowed permission levels to invoke this command + allow_forks: true + allow_drafts: true + skip_ci: true + skip_completing: true + + regenerate-static-assets: + runs-on: ubuntu-latest + needs: check-command + if: ${{ needs.check-command.outputs.continue == 'true' }} + permissions: + contents: write + outputs: + status: ${{ steps.commit.outputs.status }} + steps: + - name: Get PR branch + id: pr + uses: actions/github-script@v8 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.data.head.ref); + core.setOutput('repo', pr.data.head.repo.full_name); + - name: Checkout PR branch + uses: actions/checkout@v6 + with: + repository: ${{ steps.pr.outputs.repo }} + ref: ${{ steps.pr.outputs.ref }} + - name: Regenerate static assets + run: | + make frontend-install-dependencies + make frontend-build + - name: Commit and push changes + id: commit + run: | + echo "Checking for changes..." + if git diff --quiet; then + echo "No changes detected." + echo "status=no_changes" >> $GITHUB_OUTPUT + exit 0 + fi + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + echo "Changes detected. Committing and pushing..." + git add . + git commit -m "chore(ui): Regenerate static assets" + git push origin ${{ steps.pr.outputs.ref }} + echo "status=success" >> $GITHUB_OUTPUT + + create-response-comment: + runs-on: ubuntu-latest + needs: [check-command, regenerate-static-assets] + if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }} + permissions: + pull-requests: write + steps: + - name: Create response comment + uses: actions/github-script@v8 + with: + script: | + const status = '${{ needs.regenerate-static-assets.outputs.status }}'; + let reaction = ''; + if (status === 'success') { + reaction = 'hooray'; + } else if (status === 'no_changes') { + reaction = '+1'; + } else { + reaction = '-1'; + var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + var body = '⚠️ There was an issue regenerating static assets. Please check the [workflow run logs](' + workflowUrl + ') for more details.'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: reaction + }); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de74fc2e..09d89d21 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: # was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable) run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic - name: Codecov - uses: codecov/codecov-action@v5.5.1 + uses: codecov/codecov-action@v5.5.2 with: files: ./coverage.txt token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 8e55ef6b..76b80012 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM golang:alpine AS builder RUN apk --update add ca-certificates WORKDIR /app COPY . ./ -RUN go mod tidy +RUN go mod tidy -diff RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus . # Run Tests inside docker image if you don't have a configured go environment diff --git a/README.md b/README.md index 2578500f..e5bb2ed1 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,15 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Conditions](#conditions) - [Placeholders](#placeholders) - [Functions](#functions) + - [Web](#web) + - [UI](#ui) - [Announcements](#announcements) - [Storage](#storage) - [Client configuration](#client-configuration) - [Tunneling](#tunneling) - [Alerting](#alerting) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts) + - [Configuring ClickUp alerts](#configuring-clickup-alerts) - [Configuring Datadog alerts](#configuring-datadog-alerts) - [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Email alerts](#configuring-email-alerts) @@ -133,6 +136,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [How do I sort by group by default?](#how-do-i-sort-by-group-by-default) - [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path) - [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port) + - [Use environment variables in config files](#use-environment-variables-in-config-files) - [Configuring a startup delay](#configuring-a-startup-delay) - [Keeping your configuration small](#keeping-your-configuration-small) - [Proxy client configuration](#proxy-client-configuration) @@ -221,6 +225,9 @@ This example would look similar to this: ![Simple example](.github/assets/example.jpg) +If you want to test it locally, see [Docker](#docker). + +## Configuration By default, the configuration file is expected to be at `config/config.yaml`. You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable. @@ -234,48 +241,29 @@ subdirectories are merged like so: > 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`) > -> See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example. +> ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`. > -> When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`. +> See [Use environment variables in config files](#use-environment-variables-in-config-files) or [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for examples. If you want to test it locally, see [Docker](#docker). ## Configuration -| 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). | `{}` | -| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` | -| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` | -| `skip-invalid-config-update` | Whether to ignore invalid configuration update.
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.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` | -| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` | -| `ui.header` | Header at the top of the dashboard. | `Gatus` | -| `ui.logo` | URL to the logo to display. | `""` | -| `ui.link` | Link to open when the logo is clicked. | `""` | -| `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). | `{}` | +| 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). | `{}` | +| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` | +| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` | +| `skip-invalid-config-update` | Whether to ignore invalid configuration update.
See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | +| `web` | [Web configuration](#web). | `{}` | +| `ui` | [UI configuration](#ui). | `{}` | +| `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`. @@ -525,6 +513,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`. +### Web +Allows you to configure how and where the dashboard is being served. + +| Parameter | Description | Default | +|:---------------------------|:--------------------------------------------------------------------------------------------|:----------| +| `web` | Web configuration | `{}` | +| `web.address` | Address to listen on. | `0.0.0.0` | +| `web.port` | Port to listen on. | `8080` | +| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` | +| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` | +| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` | + +### UI +Allows you to configure the application wide defaults for the dashboard's UI. Some of these parameters can be overridden locally by users using the local storage of their browser. + +| Parameter | Description | Default | +|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------| +| `ui` | UI configuration | `{}` | +| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` | +| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. | +| `ui.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` | +| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` | +| `ui.header` | Header at the top of the dashboard. | `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` | ### 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. You can use markdown to format your announcements. @@ -812,6 +832,7 @@ endpoints: | Parameter | Description | Default | |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------| | `alerting.awsses` | Configuration for alerts of type `awsses`.
See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` | +| `alerting.clickup` | Configuration for alerts of type `clickup`.
See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts.
See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` | | `alerting.datadog` | Configuration for alerts of type `datadog`.
See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` | | `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | @@ -863,6 +884,9 @@ endpoints: | `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` | | `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` | | `alerting.aws-ses.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.aws-ses.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.aws-ses.overrides[].*` | See `alerting.aws-ses.*` parameters | `{}` | ```yaml alerting: @@ -893,6 +917,72 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b Make sure you have the ability to use `ses:SendEmail`. +#### Configuring ClickUp alerts + +| Parameter | Description | Default | +| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ | +| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` | +| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` | +| `alerting.clickup.token` | ClickUp API token | Required `""` | +| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` | +| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` | +| `alerting.clickup.status` | Initial status for created tasks | `""` | +| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` | +| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` | +| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` | +| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` | +| `alerting.clickup.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` | + +The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved. + +The following placeholders are supported in `name` and `content`: + +- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group` +- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name` +- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description` +- `[RESULT_ERRORS]` - Resolved from the health evaluation errors + +```yaml +alerting: + clickup: + list-id: "123456789" + token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + assignees: + - "12345" + - "67890" + status: "in progress" + priority: high + name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]" + content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: clickup + send-on-resolved: true +``` + +To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to) + +To find your List ID: + +1. Open the ClickUp list where you want tasks to be created +2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}` + +To find Assignee IDs: + +1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people` +2. Hover over a team member +3. Click the 3 dots (overflow menu) +3. Click `Copy member ID` + #### Configuring Datadog alerts > ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. @@ -2008,8 +2098,6 @@ Here's an example of what the notifications look like: #### Configuring Splunk alerts -> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. - | Parameter | Description | Default | |:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------| | `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` | @@ -2193,7 +2281,7 @@ Here's an example of what the notifications look like: |:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------| | `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | | `alerting.telegram.token` | Telegram Bot Token | Required `""` | -| `alerting.telegram.id` | Telegram User ID | Required `""` | +| `alerting.telegram.id` | Telegram Chat ID | Required `""` | | `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` | | `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` | | `alerting.telegram.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | @@ -2405,15 +2493,18 @@ endpoints: #### Configuring custom alerts -| Parameter | Description | Default | -|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------| -| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` | -| `alerting.custom.url` | Custom alerting request url | Required `""` | -| `alerting.custom.method` | Request method | `GET` | -| `alerting.custom.body` | Custom alerting request body. | `""` | -| `alerting.custom.headers` | Custom alerting request headers | `{}` | -| `alerting.custom.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | -| `alerting.custom.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| Parameter | Description | Default | +|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` | +| `alerting.custom.url` | Custom alerting request url | Required `""` | +| `alerting.custom.method` | Request method | `GET` | +| `alerting.custom.body` | Custom alerting request body. | `""` | +| `alerting.custom.headers` | Custom alerting request headers | `{}` | +| `alerting.custom.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | +| `alerting.custom.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.custom.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.custom.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.custom.overrides[].*` | See `alerting.custom.*` parameters | `{}` | While they're called alerts, you can use this feature to call anything. @@ -3284,12 +3375,19 @@ web: ``` If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment -variable instead, you can use that environment variable directly in the configuration file: +variable instead see [Use environment variables in config files](#use-environment-variables-in-config-files). + +### Use environment variables in config files + +You can use environment variables directly in the configuration file which will be substituted from the environment: ```yaml web: port: ${PORT} -``` +ui: + title: $TITLE +``` +⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`. ### Configuring a startup delay If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup. diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 43bd2487..6a575977 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -8,6 +8,9 @@ const ( // TypeAWSSES is the Type for the awsses alerting provider TypeAWSSES Type = "aws-ses" + // TypeClickUp is the Type for the clickup alerting provider + TypeClickUp Type = "clickup" + // TypeCustom is the Type for the custom alerting provider TypeCustom Type = "custom" diff --git a/alerting/config.go b/alerting/config.go index 65150396..923d6cbd 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -7,6 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -54,6 +55,9 @@ type Config struct { // AWSSimpleEmailService is the configuration for the aws-ses alerting provider AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"` + // ClickUp is the configuration for the clickup alerting provider + ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"` + // Custom is the configuration for the custom alerting provider Custom *custom.AlertProvider `yaml:"custom,omitempty"` diff --git a/alerting/provider/clickup/clickup.go b/alerting/provider/clickup/clickup.go new file mode 100644 index 00000000..2552c21b --- /dev/null +++ b/alerting/provider/clickup/clickup.go @@ -0,0 +1,285 @@ +package clickup + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrListIDNotSet = errors.New("list-id not set") + ErrTokenNotSet = errors.New("token not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") + ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none") +) + +var priorityMap = map[string]int{ + "urgent": 1, + "high": 2, + "normal": 3, + "low": 4, + "none": 0, +} + +type Config struct { + APIURL string `yaml:"api-url"` + ListID string `yaml:"list-id"` + Token string `yaml:"token"` + Assignees []string `yaml:"assignees"` + Status string `yaml:"status"` + Priority string `yaml:"priority"` + NotifyAll *bool `yaml:"notify-all,omitempty"` + Name string `yaml:"name,omitempty"` + MarkdownContent string `yaml:"content,omitempty"` +} + +func (cfg *Config) Validate() error { + if cfg.ListID == "" { + return ErrListIDNotSet + } + if cfg.Token == "" { + return ErrTokenNotSet + } + if cfg.Priority == "" { + cfg.Priority = "normal" + } + if _, ok := priorityMap[cfg.Priority]; !ok { + return ErrInvalidPriority + } + if cfg.NotifyAll == nil { + defaultNotifyAll := true + cfg.NotifyAll = &defaultNotifyAll + } + if cfg.APIURL == "" { + cfg.APIURL = "https://api.clickup.com/api/v2" + } + if cfg.Name == "" { + cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]" + } + if cfg.MarkdownContent == "" { + cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]" + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if override.APIURL != "" { + cfg.APIURL = override.APIURL + } + if override.ListID != "" { + cfg.ListID = override.ListID + } + if override.Token != "" { + cfg.Token = override.Token + } + if override.Status != "" { + cfg.Status = override.Status + } + if override.Priority != "" { + cfg.Priority = override.Priority + } + if override.NotifyAll != nil { + cfg.NotifyAll = override.NotifyAll + } + if len(override.Assignees) > 0 { + cfg.Assignees = override.Assignees + } + if override.Name != "" { + cfg.Name = override.Name + } + if override.MarkdownContent != "" { + cfg.MarkdownContent = override.MarkdownContent + } +} + +// AlertProvider is the configuration necessary for sending an alert using ClickUp +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default configuration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + if resolved { + return provider.CloseTask(cfg, ep) + } + // Replace placeholders + name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group) + name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name) + markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group) + markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name) + markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription()) + markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", ")) + body := map[string]interface{}{ + "name": name, + "markdown_content": markdownContent, + "assignees": cfg.Assignees, + "status": cfg.Status, + "notify_all": *cfg.NotifyAll, + } + if cfg.Priority != "none" { + body["priority"] = priorityMap[cfg.Priority] + } + return provider.CreateTask(cfg, body) +} + +func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error { + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID) + req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", cfg.Token) + httpClient := client.GetHTTPClient(nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to create task, status: %d", resp.StatusCode) + } + return nil +} + +func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error { + fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID) + req, err := http.NewRequest("GET", fetchURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", cfg.Token) + httpClient := client.GetHTTPClient(nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode) + } + var fetchResponse struct { + Tasks []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"tasks"` + } + if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil { + return err + } + var matchingTaskIDs []string + for _, task := range fetchResponse.Tasks { + if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) { + matchingTaskIDs = append(matchingTaskIDs, task.ID) + } + } + if len(matchingTaskIDs) == 0 { + return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name) + } + for _, taskID := range matchingTaskIDs { + if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil { + return fmt.Errorf("failed to close task %s: %v", taskID, err) + } + } + return nil +} + +func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error { + updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID) + body := map[string]interface{}{"status": status} + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", cfg.Token) + httpClient := client.GetHTTPClient(nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode) + } + return nil +} + +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/clickup/clickup_test.go b/alerting/provider/clickup/clickup_test.go new file mode 100644 index 00000000..0f2683b4 --- /dev/null +++ b/alerting/provider/clickup/clickup_test.go @@ -0,0 +1,310 @@ +package clickup + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}} + if err := invalidProviderNoListID.Validate(); err == nil { + t.Error("provider shouldn't have been valid without list-id") + } + invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}} + if err := invalidProviderNoToken.Validate(); err == nil { + t.Error("provider shouldn't have been valid without token") + } + invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}} + if err := invalidProviderBadPriority.Validate(); err == nil { + t.Error("provider shouldn't have been valid with invalid priority") + } + validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}} + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } + if validProvider.DefaultConfig.Priority != "normal" { + t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority) + } + validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}} + if err := validProviderWithAPIURL.Validate(); err != nil { + t.Error("provider should've been valid") + } + validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}} + if err := validProviderWithPriority.Validate(); err != nil { + t.Error("provider should've been valid with priority 'urgent'") + } + validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}} + if err := validProviderWithNone.Validate(); err != nil { + t.Error("provider should've been valid with priority 'none'") + } +} + +func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) { + provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}} + if err := provider.Validate(); err != nil { + t.Error("provider should've been valid") + } + if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" { + t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Endpoint endpoint.Endpoint + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + } + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Method == "GET" { + // Mock fetch tasks response + tasksResponse := map[string]interface{}{ + "tasks": []map[string]interface{}{ + { + "id": "task-123", + "name": "Health Check: endpoint-group:endpoint-name", + }, + }, + } + body, _ := json.Marshal(tasksResponse) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + } + } + if r.Method == "PUT" { + // Mock update task status response + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + } + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "resolved-no-matching-tasks", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Method == "GET" { + // Mock fetch tasks response with no matching tasks + tasksResponse := map[string]interface{}{ + "tasks": []map[string]interface{}{}, + } + body, _ := json.Marshal(tasksResponse) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + } + } + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved-error-fetching-tasks", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &scenario.Endpoint, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + Errors: []string{"error1", "error2"}, + }, + scenario.Resolved, + ) + if scenario.ExpectedError && err == nil { + t.Error("expected error, got none") + } + if !scenario.ExpectedError && err != nil { + t.Error("expected no error, got", err.Error()) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"}, + }, + { + Name: "provider-with-alert-override-should-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "list-id": "override-list-id", + "token": "override-token", + }}, + ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"}, + }, + { + Name: "provider-with-partial-alert-override-should-merge", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "status": "closed", + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"}, + }, + { + Name: "provider-with-assignees-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "assignees": []string{"user1", "user2"}, + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"}, + }, + { + Name: "provider-with-priority-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "priority": "urgent", + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}, + }, + { + Name: "provider-with-none-priority", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "priority": "none", + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}, + }, + { + Name: "provider-with-group-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + Overrides: []Override{ + {Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}}, + }, + }, + InputGroup: "core", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"}, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got.ListID != scenario.ExpectedOutput.ListID { + t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID) + } + if got.Token != scenario.ExpectedOutput.Token { + t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token) + } + if got.Status != scenario.ExpectedOutput.Status { + t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status) + } + if got.Priority != scenario.ExpectedOutput.Priority { + t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority) + } + if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) { + t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees)) + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 64084e20..35beb92c 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -3,6 +3,7 @@ package provider import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider/awsses" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -92,6 +93,7 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl var ( // Validate provider interface implementation on compile _ AlertProvider = (*awsses.AlertProvider)(nil) + _ AlertProvider = (*clickup.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*datadog.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil) @@ -133,6 +135,7 @@ var ( // Validate config interface implementation on compile _ Config[awsses.Config] = (*awsses.Config)(nil) + _ Config[clickup.Config] = (*clickup.Config)(nil) _ Config[custom.Config] = (*custom.Config)(nil) _ Config[datadog.Config] = (*datadog.Config)(nil) _ Config[discord.Config] = (*discord.Config)(nil) diff --git a/client/client.go b/client/client.go index b7abf175..825cab42 100644 --- a/client/client.go +++ b/client/client.go @@ -21,13 +21,13 @@ import ( "github.com/TwiN/gocache/v2" "github.com/TwiN/logr" "github.com/TwiN/whois" + "github.com/gorilla/websocket" "github.com/ishidawataru/sctp" "github.com/miekg/dns" ping "github.com/prometheus-community/pro-bing" "github.com/registrobr/rdap" "github.com/registrobr/rdap/protocol" "golang.org/x/crypto/ssh" - "golang.org/x/net/websocket" ) const ( @@ -394,48 +394,53 @@ func ShouldRunPingerAsPrivileged() bool { // QueryWebSocket opens a websocket connection, write `body` and return a message from the server func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) { const ( - Origin = "http://localhost/" - MaximumMessageSize = 1024 // in bytes + Origin = "http://localhost/" ) - wsConfig, err := websocket.NewConfig(address, Origin) - if err != nil { - return false, nil, fmt.Errorf("error configuring websocket connection: %w", err) - } - if headers != nil { - if wsConfig.Header == nil { - wsConfig.Header = make(http.Header) - } - for name, value := range headers { - wsConfig.Header.Set(name, value) + var ( + dialer = websocket.Dialer{ + EnableCompression: true, } + wsHeaders = make(http.Header) + ) + + wsHeaders.Set("Origin", Origin) + for name, value := range headers { + wsHeaders.Set(name, value) } + + ctx := context.Background() if config != nil { - wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout} - wsConfig.TlsConfig = &tls.Config{ + if config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + dialer.TLSClientConfig = &tls.Config{ InsecureSkipVerify: config.Insecure, } if config.HasTLSConfig() && config.TLS.isValid() == nil { - wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS) + dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS) } } // Dial URL - ws, err := websocket.DialConfig(wsConfig) + ws, _, err := dialer.DialContext(ctx, address, wsHeaders) if err != nil { return false, nil, fmt.Errorf("error dialing websocket: %w", err) } defer ws.Close() body = parseLocalAddressPlaceholder(body, ws.LocalAddr()) // Write message - if _, err := ws.Write([]byte(body)); err != nil { + if err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil { return false, nil, fmt.Errorf("error writing websocket body: %w", err) } // Read message - var n int - msg := make([]byte, MaximumMessageSize) - if n, err = ws.Read(msg); err != nil { + msgType, msg, err := ws.ReadMessage() + if err != nil { return false, nil, fmt.Errorf("error reading websocket message: %w", err) + } else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage { + return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage) } - return true, msg[:n], nil + return true, msg, nil } func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) { diff --git a/client/client_test.go b/client/client_test.go index f041bfe5..aac17183 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -17,6 +17,7 @@ import ( ) func TestGetHTTPClient(t *testing.T) { + t.Parallel() cfg := &Config{ Insecure: false, IgnoreRedirect: false, @@ -42,6 +43,7 @@ func TestGetHTTPClient(t *testing.T) { } func TestRdapQuery(t *testing.T) { + t.Parallel() if _, err := rdapQuery("1.1.1.1"); err == nil { t.Error("expected an error due to the invalid domain type") } @@ -157,7 +159,6 @@ func TestShouldRunPingerAsPrivileged(t *testing.T) { } } - func TestCanPerformStartTLS(t *testing.T) { type args struct { address string @@ -289,6 +290,7 @@ func TestCanPerformTLS(t *testing.T) { } func TestCanCreateConnection(t *testing.T) { + t.Parallel() connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second}) if connected { t.Error("should've failed, because there's no port in the address") @@ -303,6 +305,7 @@ func TestCanCreateConnection(t *testing.T) { // 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) { + t.Parallel() defer InjectHTTPClient(nil) oAuth2Config := &OAuth2Config{ ClientID: "00000000-0000-0000-0000-000000000000", @@ -358,6 +361,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) { } func TestQueryWebSocket(t *testing.T) { + t.Parallel() _, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second}) if err == nil { t.Error("expected an error due to the address being invalid") @@ -369,6 +373,7 @@ func TestQueryWebSocket(t *testing.T) { } func TestTlsRenegotiation(t *testing.T) { + t.Parallel() scenarios := []struct { name string cfg TLSConfig @@ -412,6 +417,7 @@ func TestTlsRenegotiation(t *testing.T) { } func TestQueryDNS(t *testing.T) { + t.Parallel() scenarios := []struct { name string inputDNS dns.Config @@ -468,7 +474,7 @@ func TestQueryDNS(t *testing.T) { }, inputURL: "8.8.8.8", expectedDNSCode: "NOERROR", - expectedBody: "*.iana-servers.net.", + expectedBody: "*.ns.cloudflare.com.", }, { name: "test Config with type PTR", @@ -541,6 +547,7 @@ func TestQueryDNS(t *testing.T) { } func TestCheckSSHBanner(t *testing.T) { + t.Parallel() cfg := &Config{Timeout: 3} t.Run("no-auth-ssh", func(t *testing.T) { connected, status, err := CheckSSHBanner("tty.sdf.org", cfg) diff --git a/config/config.go b/config/config.go index 4d9724b5..1597e9ce 100644 --- a/config/config.go +++ b/config/config.go @@ -594,6 +594,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi } alertTypes := []alert.Type{ alert.TypeAWSSES, + alert.TypeClickUp, alert.TypeCustom, alert.TypeDatadog, alert.TypeDiscord, diff --git a/config/config_test.go b/config/config_test.go index 811f0ed1..216cd4ec 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -12,6 +12,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -1854,6 +1855,7 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) { func TestGetAlertingProviderByAlertType(t *testing.T) { alertingConfig := &alerting.Config{ AWSSimpleEmailService: &awsses.AlertProvider{}, + ClickUp: &clickup.AlertProvider{}, Custom: &custom.AlertProvider{}, Datadog: &datadog.AlertProvider{}, Discord: &discord.AlertProvider{}, @@ -1898,6 +1900,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { expected provider.AlertProvider }{ {alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService}, + {alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp}, {alertType: alert.TypeCustom, expected: alertingConfig.Custom}, {alertType: alert.TypeDatadog, expected: alertingConfig.Datadog}, {alertType: alert.TypeDiscord, expected: alertingConfig.Discord}, diff --git a/go.mod b/go.mod index a58f1b85..66435d93 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.4 toolchain go1.24.7 require ( - code.gitea.io/sdk/gitea v0.22.0 + code.gitea.io/sdk/gitea v0.22.1 github.com/TwiN/deepmerge v0.2.2 github.com/TwiN/g8/v2 v2.0.0 github.com/TwiN/gocache/v2 v2.4.0 @@ -20,6 +20,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.9 github.com/google/go-github/v48 v48.2.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 github.com/lib/pq v1.10.9 github.com/miekg/dns v1.1.68 @@ -29,7 +30,6 @@ require ( github.com/valyala/fasthttp v1.67.0 github.com/wcharczuk/go-chart/v2 v2.1.2 golang.org/x/crypto v0.45.0 - golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.32.0 golang.org/x/sync v0.18.0 google.golang.org/api v0.252.0 @@ -93,6 +93,7 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.18.0 // indirect golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.38.0 // indirect diff --git a/go.sum b/go.sum index 1d6dbecb..b3d5d309 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= -code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= +code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4= @@ -101,6 +101,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo= diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go index 2c9ccd92..7c0fbad5 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -7,6 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -506,6 +507,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "clickup", + AlertType: alert.TypeClickUp, + AlertingConfig: &alerting.Config{ + ClickUp: &clickup.AlertProvider{ + DefaultConfig: clickup.Config{ + ListID: "test-list-id", + Token: "test-token", + }, + }, + }, + }, } for _, scenario := range scenarios { diff --git a/web/app/src/App.vue b/web/app/src/App.vue index ab0cc962..1a63c872 100644 --- a/web/app/src/App.vue +++ b/web/app/src/App.vue @@ -133,7 +133,7 @@ @@ -153,7 +153,6 @@ \ No newline at end of file diff --git a/web/app/src/components/Settings.vue b/web/app/src/components/Settings.vue index f7b5470d..4357732c 100644 --- a/web/app/src/components/Settings.vue +++ b/web/app/src/components/Settings.vue @@ -54,7 +54,6 @@