Compare commits

..

26 Commits

Author SHA1 Message Date
TheBinaryGuy
2d5f0a5927 feat(alerting): ClickUp alerting provider (#1462)
* feat(alerting): Add ClickUp alerting provider

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

* docs: Update ClickUp API token instructions in README

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

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

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

* Update alerting/provider/clickup/clickup.go

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

* fix(alerting): add priority validation

* fix(alerting): set default priority to 3

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

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

* docs: add instructions for finding Assignee IDs for ClickUp

* refactor(alerting): simplified ClickUp configurations example

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

---------

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

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.22.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-21 20:54:35 -05:00
PythonGermany
138f5bfb76 ci: Add workflow to regenerate static assets (#1457)
* feat(ci): Add workflow to regenerate static assets

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

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

* fix(ci): Only run for pr comments

* refactor: Update command trigger text

* refactor: Explicitly list permission levels allowed

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

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

---------

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

* refactor(ui): Use util function

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-18 18:22:40 -05:00
TwiN
d0cca91043 test: Update expectedBody for DNS test 2025-12-17 18:33:57 -05:00
PythonGermany
239d1f5118 chore(ui): Remove unnecessary eslint rule disables (#1422)
cleanup(ui): Remove unnecessary eslint rule disables
2025-12-16 16:03:24 -05:00
Glib Shpychka
47bc78dc25 docs: Update Telegram User ID to Chat ID in README (#1434)
fix(docs): Update Telegram User ID to Chat ID in README
2025-12-16 16:00:04 -05:00
PythonGermany
1df0801a61 ci: Add platform input for custom action workflow (#1437) 2025-12-11 16:17:28 -05:00
TwiN
d42c5f899e chore(ui): Regenerate static assets 2025-12-10 20:45:06 -05:00
PythonGermany
5f4c26e5fe fix(ui): Do not store config locally on load (#1432)
* fix(ui): Do not store config locally on load

* chore(ui): Regenerate static assets

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-10 16:54:33 -05:00
PythonGermany
a55eb7da40 refactor(ui): Improve dev environment (#1429) 2025-12-10 16:29:46 -05:00
PythonGermany
b0629773e5 build(docker): Non tidy go mods fail image build (#1418)
fix(docker): Non tidy go mods fail image build
2025-12-06 17:48:39 -05:00
PythonGermany
c5f7e5b82b refactor(docker): Update compose files (#1409)
* refactor(docker): Rename compose files

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-28 20:28:12 -05:00
dependabot[bot]
1e4c440f01 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ses from 1.34.5 to 1.34.7 (#1365)
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ses

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-11-28 20:16:53 -05:00
TwiN
844847bb05 chore(ui): Regenerate static assets 2025-11-28 19:57:06 -05:00
PythonGermany
0c3231713f fix(ui): Show correct avg response time for N/A value (#1407)
* fix(ui): Show correct avg response time not applicable value

* refactor(ui): Convert to milliseconds after loop

---------

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

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

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

---------

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

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

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

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

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

Fixes #1257

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

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-19 16:36:36 -05:00
48 changed files with 1143 additions and 256 deletions

View File

@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus

View File

@@ -1,4 +1,3 @@
version: "3.9"
services:
nginx:
image: nginx:stable

View File

@@ -1,4 +1,3 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest

View File

@@ -1,4 +1,3 @@
version: "3.9"
services:
postgres:
image: postgres

View File

@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
image: twinproduction/gatus:latest

View File

@@ -1,4 +1,3 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest

View File

@@ -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 }}

View File

@@ -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
});

View File

@@ -28,7 +28,7 @@ jobs:
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v5.5.1
uses: codecov/codecov-action@v5.5.2
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN go mod tidy
RUN go mod tidy -diff
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment

201
README.md
View File

@@ -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)
@@ -242,40 +245,21 @@ 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. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.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. <br />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`.
@@ -374,7 +358,7 @@ Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
- Using the example configuration above, the key would be `core_ext-ep-test`.
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful.
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, this will be ignored.
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
You must also pass the token as a `Bearer` token in the `Authorization` header.
@@ -525,6 +509,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 +828,7 @@ endpoints:
| Parameter | Description | Default |
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
| `alerting.clickup` | Configuration for alerts of type `clickup`. <br />See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.datadog` | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
@@ -863,6 +880,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. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.aws-ses.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.aws-ses.overrides[].*` | See `alerting.aws-ses.*` parameters | `{}` |
```yaml
alerting:
@@ -893,6 +913,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. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` |
The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved.
The following placeholders are supported in `name` and `content`:
- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group`
- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name`
- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description`
- `[RESULT_ERRORS]` - Resolved from the health evaluation errors
```yaml
alerting:
clickup:
list-id: "123456789"
token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
assignees:
- "12345"
- "67890"
status: "in progress"
priority: high
name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]"
content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: clickup
send-on-resolved: true
```
To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to)
To find your List ID:
1. Open the ClickUp list where you want tasks to be created
2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}`
To find Assignee IDs:
1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people`
2. Hover over a team member
3. Click the 3 dots (overflow menu)
3. Click `Copy member ID`
#### Configuring Datadog alerts
> ⚠️ **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.
@@ -2193,7 +2279,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. <br />See [Client configuration](#client-configuration). | `{}` |
@@ -2405,15 +2491,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. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| 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. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.custom.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.
@@ -3048,7 +3137,8 @@ There are two placeholders that can be used in the conditions for endpoints of t
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
```yaml
endpoints:
- name: ssh-example
# Password-based SSH example
- name: ssh-example-password
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: "username"
@@ -3062,10 +3152,24 @@ endpoints:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY].memory.used > 500"
# Key-based SSH example
- name: ssh-example-key
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: "username"
private-key: |
-----BEGIN RSA PRIVATE KEY-----
TESTRSAKEY...
-----END RSA PRIVATE KEY-----
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
```
you can also use no authentication to monitor the endpoint by not specifying the username
and password fields.
you can also use no authentication to monitor the endpoint by not specifying the username,
password and private key fields.
```yaml
endpoints:
@@ -3074,6 +3178,7 @@ endpoints:
ssh:
username: ""
password: ""
private-key: ""
interval: 1m
conditions:

View File

@@ -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"

View File

@@ -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"`

View File

@@ -0,0 +1,285 @@
package clickup
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrListIDNotSet = errors.New("list-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
)
var priorityMap = map[string]int{
"urgent": 1,
"high": 2,
"normal": 3,
"low": 4,
"none": 0,
}
type Config struct {
APIURL string `yaml:"api-url"`
ListID string `yaml:"list-id"`
Token string `yaml:"token"`
Assignees []string `yaml:"assignees"`
Status string `yaml:"status"`
Priority string `yaml:"priority"`
NotifyAll *bool `yaml:"notify-all,omitempty"`
Name string `yaml:"name,omitempty"`
MarkdownContent string `yaml:"content,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.ListID == "" {
return ErrListIDNotSet
}
if cfg.Token == "" {
return ErrTokenNotSet
}
if cfg.Priority == "" {
cfg.Priority = "normal"
}
if _, ok := priorityMap[cfg.Priority]; !ok {
return ErrInvalidPriority
}
if cfg.NotifyAll == nil {
defaultNotifyAll := true
cfg.NotifyAll = &defaultNotifyAll
}
if cfg.APIURL == "" {
cfg.APIURL = "https://api.clickup.com/api/v2"
}
if cfg.Name == "" {
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
}
if cfg.MarkdownContent == "" {
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.APIURL != "" {
cfg.APIURL = override.APIURL
}
if override.ListID != "" {
cfg.ListID = override.ListID
}
if override.Token != "" {
cfg.Token = override.Token
}
if override.Status != "" {
cfg.Status = override.Status
}
if override.Priority != "" {
cfg.Priority = override.Priority
}
if override.NotifyAll != nil {
cfg.NotifyAll = override.NotifyAll
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
if override.Name != "" {
cfg.Name = override.Name
}
if override.MarkdownContent != "" {
cfg.MarkdownContent = override.MarkdownContent
}
}
// AlertProvider is the configuration necessary for sending an alert using ClickUp
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default configuration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
if resolved {
return provider.CloseTask(cfg, ep)
}
// Replace placeholders
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
body := map[string]interface{}{
"name": name,
"markdown_content": markdownContent,
"assignees": cfg.Assignees,
"status": cfg.Status,
"notify_all": *cfg.NotifyAll,
}
if cfg.Priority != "none" {
body["priority"] = priorityMap[cfg.Priority]
}
return provider.CreateTask(cfg, body)
}
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
}
var fetchResponse struct {
Tasks []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
return err
}
var matchingTaskIDs []string
for _, task := range fetchResponse.Tasks {
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
matchingTaskIDs = append(matchingTaskIDs, task.ID)
}
}
if len(matchingTaskIDs) == 0 {
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
}
for _, taskID := range matchingTaskIDs {
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
return fmt.Errorf("failed to close task %s: %v", taskID, err)
}
}
return nil
}
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
body := map[string]interface{}{"status": status}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}

View File

@@ -0,0 +1,310 @@
package clickup
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
if err := invalidProviderNoListID.Validate(); err == nil {
t.Error("provider shouldn't have been valid without list-id")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid without token")
}
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
if err := invalidProviderBadPriority.Validate(); err == nil {
t.Error("provider shouldn't have been valid with invalid priority")
}
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if validProvider.DefaultConfig.Priority != "normal" {
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
}
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
if err := validProviderWithAPIURL.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
if err := validProviderWithPriority.Validate(); err != nil {
t.Error("provider should've been valid with priority 'urgent'")
}
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
if err := validProviderWithNone.Validate(); err != nil {
t.Error("provider should've been valid with priority 'none'")
}
}
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := provider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{
{
"id": "task-123",
"name": "Health Check: endpoint-group:endpoint-name",
},
},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
if r.Method == "PUT" {
// Mock update task status response
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-no-matching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response with no matching tasks
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved-error-fetching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
Errors: []string{"error1", "error2"},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
},
{
Name: "provider-with-alert-override-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"list-id": "override-list-id",
"token": "override-token",
}},
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
},
{
Name: "provider-with-partial-alert-override-should-merge",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"status": "closed",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
},
{
Name: "provider-with-assignees-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"assignees": []string{"user1", "user2"},
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
},
{
Name: "provider-with-priority-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "urgent",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
},
{
Name: "provider-with-none-priority",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "none",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
Overrides: []Override{
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
},
},
InputGroup: "core",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ListID != scenario.ExpectedOutput.ListID {
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Status != scenario.ExpectedOutput.Status {
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@@ -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)

View File

@@ -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 (
@@ -248,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
// using the SSH protocol.
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
@@ -260,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
} else {
port = "22"
}
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
// Build auth methods: prefer parsed private key if provided, fall back to password.
var authMethods []ssh.AuthMethod
if len(privateKey) > 0 {
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
} else {
return false, nil, fmt.Errorf("invalid private key: %w", err)
}
}
if len(password) > 0 {
authMethods = append(authMethods, ssh.Password(password))
}
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
Timeout: config.Timeout,
Auth: authMethods,
Timeout: config.Timeout,
})
if err != nil {
return false, nil, err
@@ -382,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) {

View File

@@ -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)

View File

@@ -594,6 +594,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
}
alertTypes := []alert.Type{
alert.TypeAWSSES,
alert.TypeClickUp,
alert.TypeCustom,
alert.TypeDatadog,
alert.TypeDiscord,

View File

@@ -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},

View File

@@ -244,9 +244,7 @@ func formatDuration(d time.Duration) string {
if strings.HasSuffix(s, "0s") {
s = strings.TrimSuffix(s, "0s")
// Remove trailing "0m" if present after removing "0s"
if strings.HasSuffix(s, "0m") {
s = strings.TrimSuffix(s, "0m")
}
s = strings.TrimSuffix(s, "0m")
}
return s
}

View File

@@ -503,8 +503,8 @@ func (e *Endpoint) call(result *Result) {
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0) {
// If there's no username, password or private key specified, attempt to validate just the SSH banner
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
@@ -515,7 +515,7 @@ func (e *Endpoint) call(result *Result) {
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return

View File

@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
name string
username string
password string
privateKey string
expectedErr error
}{
{
name: "fail when has no user",
name: "fail when has no user but has password",
username: "",
password: "password",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "fail when has no password",
username: "username",
password: "",
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
name: "fail when has no user but has private key",
username: "",
privateKey: "-----BEGIN",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "success when all fields are set",
name: "fail when has no password or private key",
username: "username",
password: "",
privateKey: "",
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
},
{
name: "success when username and password are set",
username: "username",
password: "password",
expectedErr: nil,
},
{
name: "success when username and private key are set",
username: "username",
privateKey: "-----BEGIN",
expectedErr: nil,
},
}
for _, scenario := range scenarios {
@@ -539,8 +553,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
Name: "ssh-test",
URL: "https://example.com",
SSHConfig: &ssh.Config{
Username: scenario.username,
Password: scenario.password,
Username: scenario.username,
Password: scenario.password,
PrivateKey: scenario.privateKey,
},
Conditions: []Condition{Condition("[STATUS] == 0")},
}
@@ -1605,7 +1620,7 @@ func TestEndpoint_HideUIFeatures(t *testing.T) {
}
}
if tt.checkConditions {
hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
hasConditions := len(result.ConditionResults) > 0
if hasConditions != tt.expectConditions {
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
}

View File

@@ -8,26 +8,29 @@ var (
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
)
type Config struct {
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
PrivateKey string `yaml:"private-key,omitempty"`
}
// Validate the SSH configuration
func (cfg *Config) Validate() error {
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return nil
}
// If any authentication method is provided (password or private key), a username is required
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}
if len(cfg.Password) == 0 {
return ErrEndpointWithoutSSHPassword
// If a username is provided, require at least a password or a private key
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return ErrEndpointWithoutSSHAuth
}
return nil
}

View File

@@ -5,7 +5,7 @@ import (
"testing"
)
func TestSSH_validate(t *testing.T) {
func TestSSH_validatePasswordCfg(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error")
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
cfg.Username = "username"
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
}
cfg.Password = "password"
if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err)
}
}
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
t.Run("fail when username missing but private key provided", func(t *testing.T) {
cfg := &Config{PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
}
})
t.Run("success when username with private key", func(t *testing.T) {
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}

46
go.mod
View File

@@ -5,21 +5,22 @@ 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
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.2.0
github.com/aws/aws-sdk-go-v2 v1.39.2
github.com/aws/aws-sdk-go-v2/config v1.31.12
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7
github.com/coreos/go-oidc/v3 v3.16.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/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
@@ -28,10 +29,9 @@ require (
github.com/registrobr/rdap v1.1.8
github.com/valyala/fasthttp v1.67.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.17.0
golang.org/x/sync v0.18.0
google.golang.org/api v0.252.0
google.golang.org/grpc v1.75.1
gopkg.in/mail.v2 v2.3.1
@@ -45,16 +45,17 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
@@ -91,10 +92,11 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect

96
go.sum
View File

@@ -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=
@@ -24,34 +24,36 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8=
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 h1:NwOeuOFrWoh4xWKINrmaAK4Vh75jmmY0RAuNjQ6W5Es=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5/go.mod h1:m3BsMJZD0eqjGIniBzwrNUqG9ZUPquC4hY9FyE2qNFo=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7 h1:As+tSgow5R48+hFwkgtEBOFYFtvSEqMal2QSMRccWgw=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7/go.mod h1:DgSdE3UxodNau1977aGbt4YixHYESTNAEStFo9qJ2TQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -99,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=
@@ -185,8 +189,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
@@ -196,8 +200,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -207,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -217,8 +221,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -232,8 +236,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -242,8 +246,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -253,8 +257,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -263,8 +267,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=

View File

@@ -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 {

View File

@@ -133,7 +133,7 @@
</div>
<a
:href="`${SERVER_URL}/oidc/login`"
:href="`/oidc/login`"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full"
@click="isOidcLoading = true"
>
@@ -153,7 +153,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { Menu, X, LogIn } from 'lucide-vue-next'
@@ -162,7 +161,6 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue'
import Loading from './components/Loading.vue'
import { SERVER_URL } from '@/main'
const route = useRoute()
@@ -196,7 +194,7 @@ const buttons = computed(() => {
// Methods
const fetchConfig = async () => {
try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
const response = await fetch(`/api/v1/config`, { credentials: 'include' })
if (response.status === 200) {
const data = await response.json()
config.value = data

View File

@@ -62,7 +62,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
@@ -142,8 +141,8 @@ const formattedResponseTime = computed(() => {
return `~${avgMs}ms`
} else {
// Show min-max range
const minMs = Math.round(min)
const maxMs = Math.round(max)
const minMs = Math.trunc(min)
const maxMs = Math.trunc(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
@@ -154,7 +153,8 @@ const formattedResponseTime = computed(() => {
const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
const oldestResultIndex = Math.max(0, props.endpoint.results.length - props.maxResults)
return generatePrettyTimeAgo(props.endpoint.results[oldestResultIndex].timestamp)
})
const newestResultTime = computed(() => {

View File

@@ -29,7 +29,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'

View File

@@ -64,9 +64,10 @@ const sortOptions = [
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => {
const handleFilterChange = (value, store = true) => {
filterBy.value = value
localStorage.setItem('gatus:filter-by', value)
if (store)
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first
emit('update:showOnlyFailing', false)
@@ -80,9 +81,11 @@ const handleFilterChange = (value) => {
}
}
const handleSortChange = (value) => {
const handleSortChange = (value, store = true) => {
sortBy.value = value
localStorage.setItem('gatus:sort-by', value)
if (store)
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group')
@@ -93,8 +96,8 @@ const handleSortChange = (value) => {
}
onMounted(() => {
// Apply saved filter/sort state on load
handleFilterChange(filterBy.value)
handleSortChange(sortBy.value)
// Apply saved or application wide filter/sort state on load but do not store it in localstorage
handleFilterChange(filterBy.value, false)
handleSortChange(sortBy.value, false)
})
</script>

View File

@@ -54,7 +54,6 @@
<script setup>
/* eslint-disable no-undef */
import { ref, onMounted, onUnmounted } from 'vue'
import { Sun, Moon, RefreshCw } from 'lucide-vue-next'

View File

@@ -31,7 +31,7 @@
<div>
<div class="flex items-center justify-between mb-1">
<p class="text-xs text-muted-foreground">Success Rate: {{ successRate }}%</p>
<p class="text-xs text-muted-foreground" v-if="averageDuration">{{ averageDuration }}ms avg</p>
<p class="text-xs text-muted-foreground" v-if="averageDuration !== null">{{ averageDuration }}ms avg</p>
</div>
<div class="flex gap-0.5">
<div
@@ -126,7 +126,7 @@ const averageDuration = computed(() => {
const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0)
// Convert nanoseconds to milliseconds
return Math.round((total / props.suite.results.length) / 1000000)
return Math.trunc((total / props.suite.results.length) / 1000000)
})
const oldestResultTime = computed(() => {

View File

@@ -46,7 +46,7 @@
{{ endpoint.success ? '✓' : '✗' }}
</span>
<span class="truncate">{{ endpoint.name }}</span>
<span class="text-muted-foreground">({{ (endpoint.duration / 1000000).toFixed(0) }}ms)</span>
<span class="text-muted-foreground">({{ Math.trunc(endpoint.duration / 1000000) }}ms)</span>
</div>
<div v-if="result.endpointResults.length > 5" class="text-xs text-muted-foreground">
... and {{ result.endpointResults.length - 5 }} more
@@ -60,7 +60,7 @@
{{ isSuiteResult ? 'Total Duration' : 'Response Time' }}
</div>
<div class="font-mono text-xs">
{{ isSuiteResult ? (result.duration / 1000000).toFixed(0) : (result.duration / 1000000).toFixed(0) }}ms
{{ Math.trunc(result.duration / 1000000) }}ms
</div>
</div>
@@ -95,7 +95,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { prettifyTimestamp } from '@/utils/time'

View File

@@ -5,7 +5,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/utils/misc'

View File

@@ -8,7 +8,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/utils/misc'

View File

@@ -10,7 +10,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/utils/misc'
defineProps({

View File

@@ -40,7 +40,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'

View File

@@ -3,6 +3,4 @@ import App from './App.vue'
import './index.css'
import router from './router'
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
createApp(App).use(router).mount('#app')

View File

@@ -10,7 +10,7 @@ export const formatDuration = (duration) => {
const durationMs = duration / 1000000
if (durationMs < 1000) {
return `${durationMs.toFixed(0)}ms`
return `${Math.trunc(durationMs)}ms`
} else {
return `${(durationMs / 1000).toFixed(2)}s`
}

View File

@@ -35,7 +35,7 @@
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}</div>
</CardContent>
</Card>
@@ -89,13 +89,13 @@
<EndpointCard
v-if="endpointStatus"
:endpoint="endpointStatus"
:maxResults="50"
:maxResults="resultPageSize"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
class="border-0 shadow-none bg-transparent p-0"
/>
<div v-if="endpointStatus && endpointStatus.key" class="pt-4 border-t">
<Pagination @page="changePage" :numberOfResultsPerPage="50" :currentPageProp="currentPage" />
<Pagination @page="changePage" :numberOfResultsPerPage="resultPageSize" :currentPageProp="currentPage" />
</div>
</div>
</CardContent>
@@ -201,7 +201,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, ArrowUpCircle, ArrowDownCircle, PlayCircle, Activity, Timer } from 'lucide-vue-next'
@@ -213,7 +212,6 @@ import Settings from '@/components/Settings.vue'
import Pagination from '@/components/Pagination.vue'
import Loading from '@/components/Loading.vue'
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
import { SERVER_URL } from '@/main.js'
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
const router = useRouter()
@@ -224,10 +222,10 @@ const endpointStatus = ref(null) // For paginated historical data
const currentStatus = ref(null) // For current/latest status (always page 1)
const events = ref([])
const currentPage = ref(1)
const resultPageSize = 50
const showResponseTimeChartAndBadges = ref(false)
const showAverageResponseTime = ref(false)
const selectedChartDuration = ref('24h')
const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
const isRefreshing = ref(false)
const latestResult = computed(() => {
@@ -261,7 +259,7 @@ const pageAverageResponseTime = computed(() => {
}
}
if (count === 0) return 'N/A'
return Math.round(total / count / 1000000)
return `${Math.round(total / count / 1000000)}ms`
})
const pageResponseTimeRange = computed(() => {
@@ -274,17 +272,17 @@ const pageResponseTimeRange = computed(() => {
let hasData = false
for (const result of endpointStatus.value.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
const duration = result.duration
if (duration) {
min = Math.min(min, duration)
max = Math.max(max, duration)
hasData = true
}
}
if (!hasData) return 'N/A'
const minMs = Math.round(min)
const maxMs = Math.round(max)
const minMs = Math.trunc(min / 1000000)
const maxMs = Math.trunc(max / 1000000)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
@@ -304,7 +302,7 @@ const lastCheckTime = computed(() => {
const fetchData = async () => {
isRefreshing.value = true
try {
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
const response = await fetch(`/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
credentials: 'include'
})
@@ -385,15 +383,15 @@ const prettifyTimestamp = (timestamp) => {
}
const generateHealthBadgeImageURL = () => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
return `/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
}
const generateUptimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
return `/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
}
const generateResponseTimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
return `/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
onMounted(() => {

View File

@@ -82,7 +82,7 @@
v-for="suite in items.suites"
:key="suite.key"
:suite="suite"
:maxResults="50"
:maxResults="resultPageSize"
@showTooltip="showTooltip"
/>
</div>
@@ -96,7 +96,7 @@
v-for="endpoint in items.endpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:maxResults="resultPageSize"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
@@ -116,7 +116,7 @@
v-for="suite in paginatedSuites"
:key="suite.key"
:suite="suite"
:maxResults="50"
:maxResults="resultPageSize"
@showTooltip="showTooltip"
/>
</div>
@@ -130,7 +130,7 @@
v-for="endpoint in paginatedEndpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:maxResults="resultPageSize"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
@@ -182,7 +182,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -193,7 +192,6 @@ import Settings from '@/components/Settings.vue'
import Loading from '@/components/Loading.vue'
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
import PastAnnouncements from '@/components/PastAnnouncements.vue'
import { SERVER_URL } from '@/main.js'
const props = defineProps({
announcements: {
@@ -225,6 +223,7 @@ const showAverageResponseTime = ref(true)
const groupByGroup = ref(false)
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
const uncollapsedGroups = ref(new Set())
const resultPageSize = 50
const filteredEndpoints = computed(() => {
let filtered = [...endpointStatuses.value]
@@ -433,7 +432,7 @@ const fetchData = async () => {
}
try {
// Fetch endpoints
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
const endpointResponse = await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {
credentials: 'include'
})
if (endpointResponse.status === 200) {
@@ -444,7 +443,7 @@ const fetchData = async () => {
}
// Fetch suites
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=100`, {
const suiteResponse = await fetch(`/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {
credentials: 'include'
})
if (suiteResponse.status === 200) {
@@ -537,7 +536,7 @@ const dashboardHeading = computed(() => {
})
const dashboardSubheading = computed(() => {
return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.dashboardSubheading }}' ? window.config.dashboardSubheading : "Monitor the health of your endpoints in real-time"
return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.DashboardSubheading }}' ? window.config.dashboardSubheading : "Monitor the health of your endpoints in real-time"
})
onMounted(() => {

View File

@@ -142,7 +142,6 @@
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, AlertCircle, ChevronRight } from 'lucide-vue-next'
@@ -154,7 +153,7 @@ import StepDetailsModal from '@/components/StepDetailsModal.vue'
import Settings from '@/components/Settings.vue'
import Loading from '@/components/Loading.vue'
import { generatePrettyTimeAgo } from '@/utils/time'
import { SERVER_URL } from '@/main'
import { formatDuration } from '@/utils/format'
const router = useRouter()
const route = useRoute()
@@ -191,7 +190,7 @@ const fetchData = async () => {
}
try {
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
const response = await fetch(`/api/v1/suites/${route.params.key}/statuses`, {
credentials: 'include'
})
@@ -240,19 +239,6 @@ const formatTimestamp = (timestamp) => {
return date.toLocaleString()
}
const formatDuration = (duration) => {
if (!duration && duration !== 0) return 'N/A'
// Convert nanoseconds to milliseconds
const durationMs = duration / 1000000
if (durationMs < 1000) {
return `${durationMs.toFixed(0)}ms`
} else {
return `${(durationMs / 1000).toFixed(2)}s`
}
}
const calculateSuccessRate = (result) => {
if (!result || !result.endpointResults || result.endpointResults.length === 0) {
return 0

View File

@@ -6,5 +6,19 @@ module.exports = {
filenameHashing: false,
productionSourceMap: false,
outputDir: '../static',
publicPath: '/'
}
publicPath: '/',
devServer: {
port: 8081,
https: false,
client: {
webSocketURL:'auto://0.0.0.0/ws'
},
proxy: {
'^/api|^/css|^/oicd': {
target: "http://localhost:8080",
changeOrigin: true,
secure: false,
}
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long