1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-04 14:06:45 +00:00

Merge branch 'master' into dependabot/go_modules/github.com/coreos/go-oidc/v3-3.17.0

This commit is contained in:
TwiN
2026-01-01 20:36:18 -05:00
committed by GitHub
43 changed files with 979 additions and 148 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

198
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)
@@ -133,6 +136,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Use environment variables in config files](#use-environment-variables-in-config-files)
- [Configuring a startup delay](#configuring-a-startup-delay)
- [Keeping your configuration small](#keeping-your-configuration-small)
- [Proxy client configuration](#proxy-client-configuration)
@@ -221,6 +225,9 @@ This example would look similar to this:
![Simple example](.github/assets/example.jpg)
If you want to test it locally, see [Docker](#docker).
## Configuration
By default, the configuration file is expected to be at `config/config.yaml`.
You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.
@@ -234,48 +241,29 @@ subdirectories are merged like so:
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
>
> See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example.
> ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
>
> When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
> See [Use environment variables in config files](#use-environment-variables-in-config-files) or [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for examples.
If you want to test it locally, see [Docker](#docker).
## Configuration
| Parameter | Description | Default |
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `announcements` | [Announcements configuration](#announcements). | `[]` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <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`.
@@ -525,6 +513,38 @@ Here are some examples of conditions you can use:
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Web
Allows you to configure how and where the dashboard is being served.
| Parameter | Description | Default |
|:---------------------------|:--------------------------------------------------------------------------------------------|:----------|
| `web` | Web configuration | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
### UI
Allows you to configure the application wide defaults for the dashboard's UI. Some of these parameters can be overridden locally by users using the local storage of their browser.
| Parameter | Description | Default |
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|
| `ui` | UI configuration | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` |
| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
### Announcements
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information. You can use markdown to format your announcements.
@@ -812,6 +832,7 @@ endpoints:
| Parameter | Description | Default |
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`. <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 +884,9 @@ endpoints:
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
| `alerting.aws-ses.default-alert` | Default alert configuration. <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 +917,72 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b
Make sure you have the ability to use `ses:SendEmail`.
#### Configuring ClickUp alerts
| Parameter | Description | Default |
| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ |
| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` |
| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` |
| `alerting.clickup.token` | ClickUp API token | Required `""` |
| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` |
| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` |
| `alerting.clickup.status` | Initial status for created tasks | `""` |
| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` |
| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` |
| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` |
| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` |
| `alerting.clickup.default-alert` | Default alert configuration. <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.
@@ -2008,8 +2098,6 @@ Here's an example of what the notifications look like:
#### Configuring Splunk alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
@@ -2193,7 +2281,7 @@ Here's an example of what the notifications look like:
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.id` | Telegram Chat ID | Required `""` |
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
@@ -2405,15 +2493,18 @@ endpoints:
#### Configuring custom alerts
| Parameter | Description | Default |
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
| `alerting.custom.url` | Custom alerting request url | Required `""` |
| `alerting.custom.method` | Request method | `GET` |
| `alerting.custom.body` | Custom alerting request body. | `""` |
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
| `alerting.custom.client` | Client configuration. <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.
@@ -3284,12 +3375,19 @@ web:
```
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
variable instead, you can use that environment variable directly in the configuration file:
variable instead see [Use environment variables in config files](#use-environment-variables-in-config-files).
### Use environment variables in config files
You can use environment variables directly in the configuration file which will be substituted from the environment:
```yaml
web:
port: ${PORT}
```
ui:
title: $TITLE
```
⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
### Configuring a startup delay
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.

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 (
@@ -394,48 +394,53 @@ func ShouldRunPingerAsPrivileged() bool {
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const (
Origin = "http://localhost/"
MaximumMessageSize = 1024 // in bytes
Origin = "http://localhost/"
)
wsConfig, err := websocket.NewConfig(address, Origin)
if err != nil {
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
}
if headers != nil {
if wsConfig.Header == nil {
wsConfig.Header = make(http.Header)
}
for name, value := range headers {
wsConfig.Header.Set(name, value)
var (
dialer = websocket.Dialer{
EnableCompression: true,
}
wsHeaders = make(http.Header)
)
wsHeaders.Set("Origin", Origin)
for name, value := range headers {
wsHeaders.Set(name, value)
}
ctx := context.Background()
if config != nil {
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
wsConfig.TlsConfig = &tls.Config{
if config.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
defer cancel()
}
dialer.TLSClientConfig = &tls.Config{
InsecureSkipVerify: config.Insecure,
}
if config.HasTLSConfig() && config.TLS.isValid() == nil {
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)
}
}
// Dial URL
ws, err := websocket.DialConfig(wsConfig)
ws, _, err := dialer.DialContext(ctx, address, wsHeaders)
if err != nil {
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
}
defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message
if _, err := ws.Write([]byte(body)); err != nil {
if err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
}
// Read message
var n int
msg := make([]byte, MaximumMessageSize)
if n, err = ws.Read(msg); err != nil {
msgType, msg, err := ws.ReadMessage()
if err != nil {
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage)
}
return true, msg[:n], nil
return true, msg, nil
}
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {

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

5
go.mod
View File

@@ -5,7 +5,7 @@ go 1.24.4
toolchain go1.24.7
require (
code.gitea.io/sdk/gitea v0.22.0
code.gitea.io/sdk/gitea v0.22.1
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.4.0
@@ -20,6 +20,7 @@ require (
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.68
@@ -29,7 +30,6 @@ require (
github.com/valyala/fasthttp v1.67.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.18.0
google.golang.org/api v0.252.0
@@ -93,6 +93,7 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect

6
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=
@@ -101,6 +101,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=

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`

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

@@ -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()
@@ -228,7 +226,6 @@ 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(() => {
@@ -284,8 +281,8 @@ const pageResponseTimeRange = computed(() => {
}
if (!hasData) return 'N/A'
const minMs = Math.round(min / 1000000)
const maxMs = Math.round(max / 1000000)
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`
@@ -305,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=${resultPageSize}`, {
const response = await fetch(`/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
credentials: 'include'
})
@@ -386,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

@@ -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: {
@@ -434,7 +432,7 @@ const fetchData = async () => {
}
try {
// Fetch endpoints
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {
const endpointResponse = await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {
credentials: 'include'
})
if (endpointResponse.status === 200) {
@@ -445,7 +443,7 @@ const fetchData = async () => {
}
// Fetch suites
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {
const suiteResponse = await fetch(`/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {
credentials: 'include'
})
if (suiteResponse.status === 200) {

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