Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e713dfee3 | ||
|
|
2f99eccf5f | ||
|
|
d37f71eee7 | ||
|
|
6d579a4b48 | ||
|
|
2c42aa8087 | ||
|
|
12825a2b6f | ||
|
|
e1edc15337 | ||
|
|
d17f51a1a1 | ||
|
|
a9fb48b26c | ||
|
|
131447f702 | ||
|
|
609a634df3 | ||
|
|
6c28de6950 | ||
|
|
440b732c71 | ||
|
|
8d63462fcd | ||
|
|
daf67dc1e6 | ||
|
|
3ebed01b4c | ||
|
|
a2f5516b06 | ||
|
|
a68e7e39bd | ||
|
|
f9d7320a2a | ||
|
|
c374649019 | ||
|
|
f6e938746f | ||
|
|
2c6fede468 | ||
|
|
9205cb2890 | ||
|
|
6a9cbb1728 | ||
|
|
4667fdbc15 | ||
|
|
501b71cab5 | ||
|
|
196be2b89c | ||
|
|
d27c63ded7 | ||
|
|
8c5ad54e71 | ||
|
|
6f9a2c7c32 | ||
|
|
aa08321239 | ||
|
|
ad5197f037 | ||
|
|
bdaffbca77 | ||
|
|
f4a667549e | ||
|
|
00419a4b4a | ||
|
|
7c27fcb895 | ||
|
|
3db5894e90 | ||
|
|
9b1d15c9e0 | ||
|
|
1855718e46 | ||
|
|
d5f2d92e8e | ||
|
|
20d1011a20 | ||
|
|
0888094fdb | ||
|
|
3f51536eaf | ||
|
|
d8a1da81f0 | ||
|
|
25b178bf94 | ||
|
|
e8e0b0f71c | ||
|
|
439ccaa372 | ||
|
|
1bb490e068 | ||
|
|
b78f3f85b0 | ||
|
|
f0034f88b7 | ||
|
|
659b81663e | ||
|
|
2f12088823 | ||
|
|
5b666f924c | ||
|
|
b296d4bf4c | ||
|
|
2b80b80769 | ||
|
|
40c274d36a | ||
|
|
65db65e052 | ||
|
|
0a9f5d8838 | ||
|
|
c449738844 | ||
|
|
35985017a8 | ||
|
|
d9d5815488 | ||
|
|
04692d15ba | ||
|
|
c411b001eb | ||
|
|
ce1777c680 | ||
|
|
f3a346d91c | ||
|
|
fca4e2170a | ||
|
|
b388cc87aa | ||
|
|
fc8868d996 | ||
|
|
021a28a8d9 | ||
|
|
fe214e9e25 | ||
|
|
493c7165fe | ||
|
|
ac44c1f2d6 | ||
|
|
5212b656a2 | ||
|
|
8f0a11a9e4 | ||
|
|
d576b3d72c | ||
|
|
33120bee52 | ||
|
|
53b785b581 | ||
|
|
0d11b0ef82 | ||
|
|
8a62eb0dcc | ||
|
|
c9c2639f67 | ||
|
|
76a8710e0b | ||
|
|
d5fe682f9a | ||
|
|
35a3238bc9 | ||
|
|
216dffa1a6 | ||
|
|
3191552343 | ||
|
|
fb98e853d4 | ||
|
|
e608950f98 | ||
|
|
39a623a349 | ||
|
|
75b99d3072 | ||
|
|
55d7bcdf2e | ||
|
|
b79fb09fe5 | ||
|
|
46499e13a0 | ||
|
|
43b0772e7d | ||
|
|
6d807e322e | ||
|
|
efa25c3498 | ||
|
|
ee96ba8721 | ||
|
|
e0bdda5225 | ||
|
|
fc07f15b67 | ||
|
|
168dafe5bb | ||
|
|
ca2c6899ed | ||
|
|
da3607106c | ||
|
|
e3e951aec8 | ||
|
|
a4cb2acd24 | ||
|
|
467e811fd5 | ||
|
|
11292b5f1e | ||
|
|
4cd35ef51d | ||
|
|
bb62a400f1 | ||
|
|
b750fcd209 | ||
|
|
62af9e842f | ||
|
|
2049601be5 | ||
|
|
abf5fbee9e | ||
|
|
8ad8a05535 | ||
|
|
3f94e16950 | ||
|
|
46b24b849f | ||
|
|
a1f7bd7b73 | ||
|
|
7e122a9fd9 | ||
|
|
52c142ec81 | ||
|
|
18e90d0e63 | ||
|
|
64b4c53b4e | ||
|
|
9fd134ca9c | ||
|
|
d9e0ee04f9 | ||
|
|
5227da3407 | ||
|
|
541a70584d | ||
|
|
ea2b7c4bdf | ||
|
|
9d8928dee0 | ||
|
|
41085757f9 | ||
|
|
5b3e0c8074 | ||
|
|
975ac3592e | ||
|
|
dd839be918 | ||
|
|
1c99386807 | ||
|
|
fa3e5dcc6e | ||
|
|
0bba77ab2b | ||
|
|
31073365ed | ||
|
|
69dbe4fa23 | ||
|
|
9157b5bf67 | ||
|
|
1ddaf5f3e5 | ||
|
|
e1675cc747 | ||
|
|
47246ddcf7 | ||
|
|
c259364edf | ||
|
|
b650518ccc | ||
|
|
d69844dc13 | ||
|
|
8aae277113 | ||
|
|
c44d998fb3 | ||
|
|
e88f47f0f4 | ||
|
|
19713c2d79 | ||
|
|
6eca0201ce | ||
|
|
f2a82e0681 | ||
|
|
9a12adbaf5 | ||
|
|
efbb739a44 | ||
|
|
78c9a1bd41 | ||
|
|
0440deb6b5 |
4
.examples/nixos/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# NixOS
|
||||
|
||||
Gatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example
|
||||
usage.
|
||||
23
.examples/nixos/gatus.nix
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
services.gatus = {
|
||||
enable = true;
|
||||
|
||||
settings = {
|
||||
web.port = 8080;
|
||||
|
||||
endpoints = [
|
||||
{
|
||||
name = "website";
|
||||
url = "https://twin.sh/health";
|
||||
interval = "5m";
|
||||
|
||||
conditions = [
|
||||
"[STATUS] == 200"
|
||||
"[BODY].status == UP"
|
||||
"[RESPONSE_TIME] < 300"
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
BIN
.github/assets/dashboard-conditions.jpg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.jpg
vendored
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.jpg
vendored
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
|
Before Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.jpg
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
4
.github/codecov.yml
vendored
@@ -1,12 +1,12 @@
|
||||
ignore:
|
||||
- "watchdog/watchdog.go"
|
||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||
|
||||
comment: false
|
||||
coverage:
|
||||
status:
|
||||
patch: off
|
||||
project:
|
||||
default:
|
||||
target: 75%
|
||||
target: 70%
|
||||
threshold: null
|
||||
|
||||
|
||||
6
.github/workflows/benchmark.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
||||
build:
|
||||
name: benchmark
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23.3
|
||||
go-version: 1.24.1
|
||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Benchmark
|
||||
run: go test -bench=. ./storage/store
|
||||
|
||||
46
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: labeler
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label
|
||||
continue-on-error: true
|
||||
env:
|
||||
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [[ $TITLE == "feat"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "feature"
|
||||
elif [[ $TITLE == "fix"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "bug"
|
||||
elif [[ $TITLE == "docs"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "documentation"
|
||||
fi
|
||||
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||
fi
|
||||
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/ui"
|
||||
fi
|
||||
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/storage"
|
||||
fi
|
||||
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/security"
|
||||
fi
|
||||
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/metrics"
|
||||
fi
|
||||
40
.github/workflows/publish-custom.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: publish-custom
|
||||
run-name: "${{ inputs.tag }}"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Custom tag to publish
|
||||
jobs:
|
||||
publish-custom:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.tag }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
14
.github/workflows/publish-experimental.yml
vendored
@@ -3,9 +3,9 @@ on: [workflow_dispatch]
|
||||
jobs:
|
||||
publish-experimental:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
@@ -17,10 +17,18 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=experimental
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
38
.github/workflows/publish-latest-to-ghcr.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: publish-latest-to-ghcr
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [test]
|
||||
branches: [master]
|
||||
types: [completed]
|
||||
concurrency:
|
||||
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
publish-latest-to-ghcr:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||
26
.github/workflows/publish-latest.yml
vendored
@@ -11,24 +11,42 @@ jobs:
|
||||
publish-latest:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: |
|
||||
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
37
.github/workflows/publish-release-to-ghcr.yml
vendored
@@ -1,37 +0,0 @@
|
||||
name: publish-release-to-ghcr
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
publish-release-to-ghcr:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Get the release
|
||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||
${{ env.IMAGE_REPOSITORY }}:stable
|
||||
${{ env.IMAGE_REPOSITORY }}:latest
|
||||
31
.github/workflows/publish-release.yml
vendored
@@ -6,15 +6,17 @@ jobs:
|
||||
publish-release:
|
||||
name: publish-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: |
|
||||
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Get the release
|
||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
@@ -22,13 +24,28 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.RELEASE }}
|
||||
type=raw,value=stable
|
||||
type=raw,value=latest
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||
${{ env.IMAGE_REPOSITORY }}:stable
|
||||
${{ env.IMAGE_REPOSITORY }}:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
18
.github/workflows/test-ui.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: test-ui
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'web/**'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'web/**'
|
||||
jobs:
|
||||
test-ui:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: make frontend-install-dependencies
|
||||
- run: make frontend-build
|
||||
8
.github/workflows/test.yml
vendored
@@ -14,12 +14,12 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23.3
|
||||
- uses: actions/checkout@v4
|
||||
go-version: 1.24.1
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build binary to make sure it works
|
||||
run: go build
|
||||
- name: Test
|
||||
@@ -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.1.2
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build the go application into a binary
|
||||
FROM golang:alpine as builder
|
||||
FROM golang:alpine AS builder
|
||||
RUN apk --update add ca-certificates
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
|
||||
9
Makefile
@@ -6,7 +6,11 @@ install:
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
|
||||
|
||||
.PHONY: run-binary
|
||||
run-binary:
|
||||
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@@ -34,6 +38,9 @@ docker-build-and-run: docker-build docker-run
|
||||
# Front end #
|
||||
#############
|
||||
|
||||
frontend-install-dependencies:
|
||||
npm --prefix web/app install
|
||||
|
||||
frontend-build:
|
||||
npm --prefix web/app run build
|
||||
|
||||
|
||||
510
README.md
@@ -1,6 +1,6 @@
|
||||
[](https://gatus.io)
|
||||
|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwiN/gatus)
|
||||
[](https://codecov.io/gh/TwiN/gatus)
|
||||
[](https://github.com/TwiN/gatus)
|
||||
@@ -21,18 +21,18 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
|
||||
<summary><b>Quick start</b></summary>
|
||||
|
||||
```console
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
|
||||
```
|
||||
You can also use GitHub Container Registry if you prefer:
|
||||
```console
|
||||
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus
|
||||
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
|
||||
```
|
||||
For more details, see [Usage](#usage)
|
||||
</details>
|
||||
|
||||
> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).
|
||||
|
||||

|
||||

|
||||
|
||||
Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
|
||||
|
||||
@@ -59,6 +59,9 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring GitLab alerts](#configuring-gitlab-alerts)
|
||||
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
|
||||
- [Configuring Gotify alerts](#configuring-gotify-alerts)
|
||||
- [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)
|
||||
- [Configuring Ilert alerts](#configuring-ilert-alerts)
|
||||
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
|
||||
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
|
||||
- [Configuring Matrix alerts](#configuring-matrix-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
@@ -75,12 +78,14 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Setting a default alert](#setting-a-default-alert)
|
||||
- [Announcements](#announcements)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Security](#security)
|
||||
- [Basic Authentication](#basic-authentication)
|
||||
- [OIDC](#oidc)
|
||||
- [TLS Encryption](#tls-encryption)
|
||||
- [Metrics](#metrics)
|
||||
- [Custom Labels](#custom-labels)
|
||||
- [Connectivity](#connectivity)
|
||||
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
|
||||
- [Deployment](#deployment)
|
||||
@@ -117,8 +122,13 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Health](#health)
|
||||
- [Health (Shields.io)](#health-shieldsio)
|
||||
- [Response time](#response-time)
|
||||
- [Response time (chart)](#response-time-chart)
|
||||
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
|
||||
- [API](#api)
|
||||
- [Interacting with the API programmatically](#interacting-with-the-api-programmatically)
|
||||
- [Raw Data](#raw-data)
|
||||
- [Uptime](#uptime-1)
|
||||
- [Response Time](#response-time-1)
|
||||
- [Installing as binary](#installing-as-binary)
|
||||
- [High level design overview](#high-level-design-overview)
|
||||
|
||||
@@ -153,7 +163,7 @@ The main features of Gatus are:
|
||||
- **[Badges](#badges)**:  
|
||||
- **Dark mode**
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## Usage
|
||||
@@ -192,7 +202,7 @@ endpoints:
|
||||
|
||||
This example would look similar to this:
|
||||
|
||||

|
||||

|
||||
|
||||
By default, the configuration file is expected to be at `config/config.yaml`.
|
||||
|
||||
@@ -213,32 +223,37 @@ If you want to test it locally, see [Docker](#docker).
|
||||
|
||||
|
||||
## Configuration
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` |
|
||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||
| `ui.header` | Header at the top of the dashboard. | `Health Status` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `announcements` | [Announcements configuration](#announcements). | `[]` |
|
||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `ui.custom-css` | Custom CSS | `""` |
|
||||
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
||||
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
||||
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
|
||||
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
||||
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
|
||||
@@ -269,13 +284,23 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
|
||||
| `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` |
|
||||
| `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` |
|
||||
| `endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
|
||||
| `endpoints[].maintenance-windows` | List of all maintenance windows for a given endpoint. <br />See [Maintenance](#maintenance). | `[]` |
|
||||
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
|
||||
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
|
||||
| `endpoints[].ui.hide-conditions` | Whether to hide conditions from the results. Note that this only hides conditions from results evaluated from the moment this was enabled. | `false` |
|
||||
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` |
|
||||
| `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` |
|
||||
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
|
||||
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
|
||||
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
|
||||
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
|
||||
| `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
|
||||
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
|
||||
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
|
||||
|
||||
You may use the following placeholders in the body (`endpoints[].body`):
|
||||
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
|
||||
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
|
||||
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
|
||||
- `[LOCAL_ADDRESS]` (resolves to the local IP and port like `192.0.2.1:25` or `[2001:db8::1]:80`)
|
||||
- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N (max: 8192))
|
||||
|
||||
|
||||
### External Endpoints
|
||||
@@ -287,14 +312,16 @@ For instance:
|
||||
- You can monitor services that are not supported by Gatus
|
||||
- You can implement your own monitoring system while using Gatus as the dashboard
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------|:-----------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `external-endpoints` | List of endpoints to monitor. | `[]` |
|
||||
| `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
|
||||
| `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
|
||||
| `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
|
||||
| `external-endpoints[].token` | Bearer token required to push status to. | Required `""` |
|
||||
| `external-endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------|:---------------|
|
||||
| `external-endpoints` | List of endpoints to monitor. | `[]` |
|
||||
| `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
|
||||
| `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
|
||||
| `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
|
||||
| `external-endpoints[].token` | Bearer token required to push status to. | Required `""` |
|
||||
| `external-endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
|
||||
| `external-endpoints[].heartbeat` | Heartbeat configuration for monitoring when the external endpoint stops sending updates. | `{}` |
|
||||
| `external-endpoints[].heartbeat.interval` | Expected interval between updates. If no update is received within this interval, alerts will be triggered. Must be at least 10s. | `0` (disabled) |
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
@@ -302,6 +329,8 @@ external-endpoints:
|
||||
- name: ext-ep-test
|
||||
group: core
|
||||
token: "potato"
|
||||
heartbeat:
|
||||
interval: 30m # Automatically create a failure if no update is received within 30 minutes
|
||||
alerts:
|
||||
- type: discord
|
||||
description: "healthcheck failed"
|
||||
@@ -310,13 +339,14 @@ external-endpoints:
|
||||
|
||||
To push the status of an external endpoint, the request would have to look like this:
|
||||
```
|
||||
POST /api/v1/endpoints/{key}/external?success={success}&error={error}
|
||||
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
|
||||
```
|
||||
Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{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}`: 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, it can be omitted or left empty.
|
||||
- `{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.
|
||||
- `{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.
|
||||
|
||||
@@ -372,13 +402,47 @@ Here are some examples of conditions you can use:
|
||||
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||
|
||||
|
||||
### Announcements
|
||||
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
|
||||
| `announcements` | List of announcements to display | `[]` |
|
||||
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
|
||||
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
|
||||
| `announcements[].message` | The message to display to users | Required |
|
||||
|
||||
Types:
|
||||
- **outage**: Indicates service disruptions or critical issues (red theme)
|
||||
- **warning**: Indicates potential issues or important notices (yellow theme)
|
||||
- **information**: General information or updates (blue theme)
|
||||
- **operational**: Indicates resolved issues or normal operations (green theme)
|
||||
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
|
||||
|
||||
Example Configuration:
|
||||
```yaml
|
||||
announcements:
|
||||
- timestamp: 2025-08-15T14:00:00Z
|
||||
type: outage
|
||||
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
||||
- timestamp: 2025-08-15T16:15:00Z
|
||||
type: operational
|
||||
message: "Database maintenance completed successfully. All systems operational."
|
||||
- timestamp: 2025-08-15T12:00:00Z
|
||||
type: information
|
||||
message: "New monitoring dashboard features will be deployed next week"
|
||||
```
|
||||
|
||||
|
||||
### Storage
|
||||
| Parameter | Description | Default |
|
||||
|:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
|
||||
| `storage.caching` | Whether to use write-through caching. Improves loading time for large dashboards. <br />Only supported if `storage.type` is `sqlite` or `postgres` | `false` |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
|
||||
| `storage.caching` | Whether to use write-through caching. Improves loading time for large dashboards. <br />Only supported if `storage.type` is `sqlite` or `postgres` | `false` |
|
||||
| `storage.maximum-number-of-results` | The maximum number of results that an endpoint can have | `100` |
|
||||
| `storage.maximum-number-of-events` | The maximum number of events that an endpoint can have | `50` |
|
||||
|
||||
The results for each endpoint health check as well as the data for uptime and the past events must be persisted
|
||||
so that they can be displayed on the dashboard. These parameters allow you to configure the storage in question.
|
||||
@@ -389,6 +453,8 @@ so that they can be displayed on the dashboard. These parameters allow you to co
|
||||
# Because the data is stored in memory, the data will not survive a restart.
|
||||
storage:
|
||||
type: memory
|
||||
maximum-number-of-results: 200
|
||||
maximum-number-of-events: 5
|
||||
```
|
||||
- If `storage.type` is `sqlite`, `storage.path` must not be blank:
|
||||
```yaml
|
||||
@@ -525,16 +591,17 @@ individual endpoints with configurable descriptions and thresholds.
|
||||
|
||||
Alerts are configured at the endpoint level like so:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-------------------------------------------------------------------------------|:--------------|
|
||||
| `alerts` | List of all alerts for a given endpoint. | `[]` |
|
||||
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
|
||||
| `alerts[].enabled` | Whether to enable the alert. | `true` |
|
||||
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerts` | List of all alerts for a given endpoint. | `[]` |
|
||||
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
|
||||
| `alerts[].enabled` | Whether to enable the alert. | `true` |
|
||||
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. | `0` |
|
||||
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
|
||||
|
||||
Here's an example of what an alert configuration might look like at the endpoint level:
|
||||
```yaml
|
||||
@@ -576,6 +643,8 @@ endpoints:
|
||||
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
|
||||
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
|
||||
| `alerting.ilert` | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
|
||||
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
|
||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
|
||||
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
@@ -590,6 +659,7 @@ endpoints:
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| `alerting.zulip` | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
|
||||
| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`. <br />See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` |
|
||||
|
||||
|
||||
#### Configuring AWS SES alerts
|
||||
@@ -627,6 +697,10 @@ endpoints:
|
||||
description: "healthcheck failed"
|
||||
```
|
||||
|
||||
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
|
||||
|
||||
Make sure you have the ability to use `ses:SendEmail`.
|
||||
|
||||
|
||||
#### Configuring Discord alerts
|
||||
| Parameter | Description | Default |
|
||||
@@ -877,6 +951,51 @@ endpoints:
|
||||
| `alerting.gotify.title` | Title of the notification | `"Gatus: <endpoint>"` |
|
||||
| `alerting.gotify.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
|
||||
#### Configuring ilert alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
|
||||
| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` |
|
||||
| `alerting.ilert.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` |
|
||||
|
||||
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
|
||||
of type `ilert`, because unlike other alerts, the operation resulting from setting said
|
||||
parameter to `true` will not create another alert but mark the alert as resolved on
|
||||
ilert instead.
|
||||
|
||||
Behavior:
|
||||
- By default, `alerting.ilert.integration-key` is used as the integration key
|
||||
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
ilert:
|
||||
integration-key: "********************************"
|
||||
# You can also add group-specific integration keys, which will
|
||||
# override the integration key above for the specified groups
|
||||
overrides:
|
||||
- group: "core"
|
||||
integration-key: "********************************"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: ilert
|
||||
failure-threshold: 3
|
||||
success-threshold: 5
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
```
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
gotify:
|
||||
@@ -902,6 +1021,111 @@ Here's an example of what the notifications look like:
|
||||

|
||||
|
||||
|
||||
#### Configuring HomeAssistant alerts
|
||||
To configure HomeAssistant alerts, you'll need to add the following to your configuration file:
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
homeassistant:
|
||||
url: "http://homeassistant:8123" # URL of your HomeAssistant instance
|
||||
token: "YOUR_LONG_LIVED_ACCESS_TOKEN" # Long-lived access token from HomeAssistant
|
||||
|
||||
endpoints:
|
||||
- name: my-service
|
||||
url: "https://my-service.com"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: homeassistant
|
||||
enabled: true
|
||||
send-on-resolved: true
|
||||
description: "My service health check"
|
||||
failure-threshold: 3
|
||||
success-threshold: 2
|
||||
```
|
||||
|
||||
The alerts will be sent as events to HomeAssistant with the event type `gatus_alert`. The event data includes:
|
||||
- `status`: "triggered" or "resolved"
|
||||
- `endpoint`: The name of the monitored endpoint
|
||||
- `description`: The alert description if provided
|
||||
- `conditions`: List of conditions and their results
|
||||
- `failure_count`: Number of consecutive failures (when triggered)
|
||||
- `success_count`: Number of consecutive successes (when resolved)
|
||||
|
||||
You can use these events in HomeAssistant automations to:
|
||||
- Send notifications
|
||||
- Control devices
|
||||
- Trigger scenes
|
||||
- Log to history
|
||||
- And more
|
||||
|
||||
Example HomeAssistant automation:
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Gatus Alert Handler"
|
||||
trigger:
|
||||
platform: event
|
||||
event_type: gatus_alert
|
||||
action:
|
||||
- service: notify.notify
|
||||
data_template:
|
||||
title: "Gatus Alert: {{ trigger.event.data.event_data.endpoint }}"
|
||||
message: >
|
||||
Status: {{ trigger.event.data.event_data.status }}
|
||||
{% if trigger.event.data.event_data.description %}
|
||||
Description: {{ trigger.event.data.event_data.description }}
|
||||
{% endif %}
|
||||
{% for condition in trigger.event.data.event_data.conditions %}
|
||||
{{ '✅' if condition.success else '❌' }} {{ condition.condition }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
To get your HomeAssistant long-lived access token:
|
||||
1. Open HomeAssistant
|
||||
2. Click on your profile name (bottom left)
|
||||
3. Scroll down to "Long-Lived Access Tokens"
|
||||
4. Click "Create Token"
|
||||
5. Give it a name (e.g., "Gatus")
|
||||
6. Copy the token - you'll only see it once!
|
||||
|
||||
|
||||
#### Configuring Incident.io alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.incident-io` | Configuration for alerts of type `incident-io` | `{}` |
|
||||
| `alerting.incident-io.url` | url to trigger an alert event. | Required `""` |
|
||||
| `alerting.incident-io.auth-token` | Token that is used for authentication. | Required `""` |
|
||||
| `alerting.incident-io.source-url` | Source URL | `""` |
|
||||
| `alerting.incident-io.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.incident-io.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.incident-io.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.incident-io.overrides[].*` | See `alerting.incident-io.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
incident-io:
|
||||
url: "*****************"
|
||||
auth-token: "********************************************"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: incident-io
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
In order to get the required alert source config id and authentication token, you must configure an HTTP alert source.
|
||||
|
||||
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
|
||||
|
||||
|
||||
#### Configuring JetBrains Space alerts
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
@@ -1183,16 +1407,18 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Pushover alerts
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------|:------------------------------------------------------------------------------------------------|:-----------------------------|
|
||||
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
|
||||
| `alerting.pushover.application-token` | Pushover application token | `""` |
|
||||
| `alerting.pushover.user-key` | User or group key | `""` |
|
||||
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | Name of your App in Pushover |
|
||||
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.sound` | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
|
||||
| `alerting.pushover.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------|:---------------------------------------------------------------------------------------------------------|:----------------------------|
|
||||
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
|
||||
| `alerting.pushover.application-token` | Pushover application token | `""` |
|
||||
| `alerting.pushover.user-key` | User or group key | `""` |
|
||||
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: <endpoint>"` |
|
||||
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.sound` | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
|
||||
| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` |
|
||||
| `alerting.pushover.device` | Device to send the message to (optional)<br/>See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices)|
|
||||
| `alerting.pushover.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -1376,6 +1602,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.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). | `{}` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
@@ -1388,6 +1615,7 @@ alerting:
|
||||
telegram:
|
||||
token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
id: "0123456789"
|
||||
topic-id: "7"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
@@ -1440,10 +1668,6 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
|
||||
|
||||
Make sure you have the ability to use `ses:SendEmail`.
|
||||
|
||||
#### Configuring Zulip alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
|
||||
@@ -1669,6 +1893,19 @@ maintenance:
|
||||
- Monday
|
||||
- Thursday
|
||||
```
|
||||
You can also specify maintenance windows on a per-endpoint basis:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: endpoint-1
|
||||
url: "https://example.org"
|
||||
maintenance-windows:
|
||||
- start: "07:30"
|
||||
duration: 40m
|
||||
timezone: "Europe/Berlin"
|
||||
- start: "14:30"
|
||||
duration: 1h
|
||||
timezone: "Europe/Berlin"
|
||||
```
|
||||
|
||||
|
||||
### Security
|
||||
@@ -1743,14 +1980,32 @@ endpoint on the same port your application is configured to run on (`web.port`).
|
||||
|
||||
| Metric name | Type | Description | Labels | Relevant endpoint types |
|
||||
|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
|
||||
| gatus_results_total | counter | Number of results per endpoint | key, group, name, type, success | All |
|
||||
| gatus_results_total | counter | Number of results per endpoint per success state | key, group, name, type, success | All |
|
||||
| gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP |
|
||||
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
|
||||
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
|
||||
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
|
||||
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
|
||||
|
||||
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
|
||||
|
||||
#### Custom Labels
|
||||
|
||||
You can add custom labels to your endpoints’ Prometheus metrics by defining key–value pairs under the `extra-labels` field. For example:
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: front-end
|
||||
group: core
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 150"
|
||||
extra-labels:
|
||||
environment: staging
|
||||
```
|
||||
|
||||
### Connectivity
|
||||
| Parameter | Description | Default |
|
||||
@@ -1938,8 +2193,9 @@ endpoints:
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
```
|
||||
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
|
||||
|
||||
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
|
||||
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
|
||||
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
|
||||
|
||||
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
|
||||
@@ -1959,7 +2215,9 @@ endpoints:
|
||||
- "[CONNECTED] == true"
|
||||
```
|
||||
|
||||
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
|
||||
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
|
||||
|
||||
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
|
||||
`endpoints[].method` and `endpoints[].graphql` are not supported for UDP endpoints.
|
||||
|
||||
This works for UDP based application.
|
||||
@@ -1994,7 +2252,8 @@ endpoints:
|
||||
```
|
||||
|
||||
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
|
||||
shows whether the connection was successfully established.
|
||||
shows whether the connection was successfully established. You can use Go template
|
||||
syntax. The functions LocalAddr and RandomString with a length can be used.
|
||||
|
||||
|
||||
### Monitoring an endpoint using ICMP
|
||||
@@ -2054,6 +2313,23 @@ endpoints:
|
||||
- "[STATUS] == 0"
|
||||
```
|
||||
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username
|
||||
and password fields.
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: ssh-example
|
||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||
ssh:
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
```
|
||||
|
||||
The following placeholders are supported for endpoints of type SSH:
|
||||
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
|
||||
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
|
||||
@@ -2089,6 +2365,11 @@ endpoints:
|
||||
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||
```
|
||||
|
||||
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
|
||||
|
||||
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
|
||||
`endpoints[].method` and `endpoints[].graphql` are not supported for TLS endpoints.
|
||||
|
||||
|
||||
### Monitoring domain expiration
|
||||
You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`
|
||||
@@ -2110,7 +2391,7 @@ endpoints:
|
||||
|
||||
|
||||
### disable-monitoring-lock
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time.
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time (i.e. parallel execution).
|
||||
|
||||
While this behavior wouldn't generally be harmful, conditions using the `[RESPONSE_TIME]` placeholder could be impacted
|
||||
by the evaluation of multiple endpoints at the same time, therefore, the default value for this parameter is `false`.
|
||||
@@ -2187,9 +2468,9 @@ endpoints:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
The configuration above will result in a dashboard that looks like this:
|
||||
The configuration above will result in a dashboard that looks like this when sorting by group:
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
### Exposing Gatus on a custom path
|
||||
@@ -2284,6 +2565,7 @@ web:
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Gatus can automatically generate an SVG badge for one of your monitored endpoints.
|
||||
This allows you to put badges in your individual applications' README or even create your own status page if you
|
||||
@@ -2294,8 +2576,8 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
@@ -2321,7 +2603,7 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/health/badge.svg
|
||||
```
|
||||
Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
@@ -2338,7 +2620,7 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/health/badge.shields
|
||||
```
|
||||
Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
@@ -2353,15 +2635,28 @@ See more information about the Shields.io badge endpoint [here](https://shields.
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
The endpoint to generate a badge is the following:
|
||||
```
|
||||
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
#### Response time (chart)
|
||||

|
||||

|
||||

|
||||
|
||||
The endpoint to generate a response time chart is the following:
|
||||
```
|
||||
/api/v1/endpoints/{key}/response-times/{duration}/chart.svg
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d`, `7d`, or `24h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
##### How to change the color thresholds of the response time badge
|
||||
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
|
||||
@@ -2404,6 +2699,43 @@ The API will return a JSON payload with the `Content-Type` response header set t
|
||||
No such header is required to query the API.
|
||||
|
||||
|
||||
#### Interacting with the API programmatically
|
||||
See [TwiN/gatus-sdk](https://github.com/TwiN/gatus-sdk)
|
||||
|
||||
|
||||
#### Raw Data
|
||||
Gatus exposes the raw data for one of your monitored endpoints.
|
||||
This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days.
|
||||
|
||||
##### Uptime
|
||||
The path to get raw uptime data for an endpoint is:
|
||||
```
|
||||
/api/v1/endpoints/{key}/uptimes/{duration}
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
||||
```
|
||||
https://example.com/api/v1/endpoints/core_frontend/uptimes/24h
|
||||
```
|
||||
|
||||
##### Response Time
|
||||
The path to get raw response time data for an endpoint is:
|
||||
```
|
||||
/api/v1/endpoints/{key}/response-times/{duration}
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the raw response time data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
||||
```
|
||||
https://example.com/api/v1/endpoints/core_frontend/response-times/24h
|
||||
```
|
||||
|
||||
|
||||
### Installing as binary
|
||||
You can download Gatus as a binary using the following command:
|
||||
```
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/logr"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -35,6 +36,9 @@ type Alert struct {
|
||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||
SuccessThreshold int `yaml:"success-threshold"`
|
||||
|
||||
// MinimumReminderInterval is the interval between reminders
|
||||
MinimumReminderInterval time.Duration `yaml:"minimum-reminder-interval,omitempty"`
|
||||
|
||||
// Description of the alert. Will be included in the alert sent.
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
|
||||
@@ -32,6 +32,15 @@ const (
|
||||
// TypeGotify is the Type for the gotify alerting provider
|
||||
TypeGotify Type = "gotify"
|
||||
|
||||
// TypeHomeAssistant is the Type for the homeassistant alerting provider
|
||||
TypeHomeAssistant Type = "homeassistant"
|
||||
|
||||
// TypeIlert is the Type for the ilert alerting provider
|
||||
TypeIlert Type = "ilert"
|
||||
|
||||
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||
TypeIncidentIO Type = "incident-io"
|
||||
|
||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
@@ -60,6 +63,15 @@ type Config struct {
|
||||
|
||||
// Gotify is the configuration for the gotify alerting provider
|
||||
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||
|
||||
// HomeAssistant is the configuration for the homeassistant alerting provider
|
||||
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
|
||||
|
||||
// Ilert is the configuration for the ilert alerting provider
|
||||
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
|
||||
|
||||
// IncidentIO is the configuration for the incident-io alerting provider
|
||||
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
|
||||
|
||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||
|
||||
@@ -108,8 +108,9 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
|
||||
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
|
||||
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
|
||||
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
|
||||
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
|
||||
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
|
||||
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
|
||||
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
|
||||
if resolved {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||
|
||||
@@ -179,6 +179,13 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
|
||||
Errors: []string{"error1", "error2"},
|
||||
},
|
||||
{
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: false,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=test \\\"error with quotes\\\"",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,test \\\"error with quotes\\\"",
|
||||
Errors: []string{"test \"error with quotes\""},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
|
||||
|
||||
@@ -166,7 +166,14 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + formattedConditionResults
|
||||
var extraLabels string
|
||||
if len(ep.ExtraLabels) > 0 {
|
||||
extraLabels = "\n\nExtra labels:\n"
|
||||
for key, value := range ep.ExtraLabels {
|
||||
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
return subject, message + description + extraLabels + formattedConditionResults
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
Endpoint *endpoint.Endpoint
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -84,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
@@ -92,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-extra-labels",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
|
||||
@@ -140,7 +140,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
State: gitea.StateOpen,
|
||||
CreatedBy: cfg.username,
|
||||
ListOptions: gitea.ListOptions{
|
||||
Page: 100,
|
||||
PageSize: 100,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -153,7 +153,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
_, _, err = cfg.giteaClient.EditIssue(
|
||||
cfg.repositoryOwner,
|
||||
cfg.repositoryName,
|
||||
issue.ID,
|
||||
issue.Index,
|
||||
gitea.EditIssueOption{
|
||||
State: &stateClosed,
|
||||
},
|
||||
|
||||
196
alerting/provider/homeassistant/homeassistant.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package homeassistant
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"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 (
|
||||
ErrURLNotSet = errors.New("url not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.URL) == 0 {
|
||||
return ErrURLNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.URL) > 0 {
|
||||
cfg.URL = override.URL
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using HomeAssistant
|
||||
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 integration 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()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
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
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
EventType string `json:"event_type"`
|
||||
EventData struct {
|
||||
Status string `json:"status"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Conditions []struct {
|
||||
Condition string `json:"condition"`
|
||||
Success bool `json:"success"`
|
||||
} `json:"conditions,omitempty"`
|
||||
FailureCount int `json:"failure_count,omitempty"`
|
||||
SuccessCount int `json:"success_count,omitempty"`
|
||||
} `json:"event_data"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
body := Body{
|
||||
EventType: "gatus_alert",
|
||||
EventData: struct {
|
||||
Status string `json:"status"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Conditions []struct {
|
||||
Condition string `json:"condition"`
|
||||
Success bool `json:"success"`
|
||||
} `json:"conditions,omitempty"`
|
||||
FailureCount int `json:"failure_count,omitempty"`
|
||||
SuccessCount int `json:"success_count,omitempty"`
|
||||
}{
|
||||
Status: "resolved",
|
||||
Endpoint: ep.DisplayName(),
|
||||
},
|
||||
}
|
||||
|
||||
if !resolved {
|
||||
body.EventData.Status = "triggered"
|
||||
body.EventData.FailureCount = alert.FailureThreshold
|
||||
} else {
|
||||
body.EventData.SuccessCount = alert.SuccessThreshold
|
||||
}
|
||||
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
body.EventData.Description = alertDescription
|
||||
}
|
||||
|
||||
if len(result.ConditionResults) > 0 {
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
body.EventData.Conditions = append(body.EventData.Conditions, struct {
|
||||
Condition string `json:"condition"`
|
||||
Success bool `json:"success"`
|
||||
}{
|
||||
Condition: conditionResult.Condition,
|
||||
Success: conditionResult.Success,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
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
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
158
alerting/provider/homeassistant/homeassistant_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package homeassistant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{URL: "", Token: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}}
|
||||
if err := invalidProviderNoToken.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
|
||||
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{URL: "http://homeassistant:8123", Token: "token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "SUCCESSFUL_CONDITION", Success: true},
|
||||
{Condition: "FAILING_CONDITION", Success: false},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
description := "test-description"
|
||||
provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
|
||||
body := provider.buildRequestBody(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "SUCCESSFUL_CONDITION", Success: true},
|
||||
{Condition: "FAILING_CONDITION", Success: false},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
var decodedBody Body
|
||||
if err := json.Unmarshal(body, &decodedBody); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if decodedBody.EventType != "gatus_alert" {
|
||||
t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType)
|
||||
}
|
||||
if decodedBody.EventData.Status != "triggered" {
|
||||
t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status)
|
||||
}
|
||||
if decodedBody.EventData.Description != description {
|
||||
t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description)
|
||||
}
|
||||
if len(decodedBody.EventData.Conditions) != 2 {
|
||||
t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions))
|
||||
}
|
||||
if !decodedBody.EventData.Conditions[0].Success {
|
||||
t.Error("expected first condition to be successful")
|
||||
}
|
||||
if decodedBody.EventData.Conditions[1].Success {
|
||||
t.Error("expected second condition to be unsuccessful")
|
||||
}
|
||||
}
|
||||
168
alerting/provider/ilert/ilert.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package ilert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
restAPIUrl = "https://api.ilert.com/api/v1/events/gatus/"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIntegrationKeyNotSet = errors.New("integration key is not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.IntegrationKey) == 0 {
|
||||
return ErrIntegrationKeyNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.IntegrationKey) > 0 {
|
||||
cfg.IntegrationKey = override.IntegrationKey
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using ilert
|
||||
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"`
|
||||
}
|
||||
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", restAPIUrl, cfg.IntegrationKey), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Alert alert.Alert `json:"alert"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Details string `json:"details,omitempty"`
|
||||
ConditionResults []*endpoint.ConditionResult `json:"condition_results"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var details, status string
|
||||
if resolved {
|
||||
status = "resolved"
|
||||
} else {
|
||||
status = "firing"
|
||||
}
|
||||
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
details = alert.GetDescription()
|
||||
} else {
|
||||
details = "No description"
|
||||
}
|
||||
|
||||
var body []byte
|
||||
body, _ = json.Marshal(Body{
|
||||
Alert: *alert,
|
||||
Name: ep.Name,
|
||||
Group: ep.Group,
|
||||
Title: ep.DisplayName(),
|
||||
Status: status,
|
||||
Details: details,
|
||||
ConditionResults: result.ConditionResults,
|
||||
URL: ep.URL,
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
322
alerting/provider/ilert/ilert_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package ilert
|
||||
|
||||
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) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
IntegrationKey: "some-random-key",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid-integration-key",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
IntegrationKey: "",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if scenario.expected && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if !scenario.expected && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
sendOnResolved := true
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{
|
||||
IntegrationKey: "some-integration-key",
|
||||
}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
var b bytes.Buffer
|
||||
|
||||
reader := io.NopCloser(&b)
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{
|
||||
IntegrationKey: "some-integration-key",
|
||||
}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{
|
||||
IntegrationKey: "some-integration-key",
|
||||
}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
var b bytes.Buffer
|
||||
reader := io.NopCloser(&b)
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
sendOnResolved := true
|
||||
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
|
||||
},
|
||||
{
|
||||
Name: "group-override",
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"MinimumReminderInterval":0,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
cfg,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
|
||||
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
|
||||
},
|
||||
}
|
||||
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.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
210
alerting/provider/incidentio/incidentio.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package incidentio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/logr"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
restAPIUrl = "https://api.incident.io/v2/alert_events/http/"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrURLNotSet = errors.New("url not set")
|
||||
ErrURLNotPrefixedWithRestAPIURL = fmt.Errorf("url must be prefixed with %s", restAPIUrl)
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
ErrAuthTokenNotSet = errors.New("auth-token not set")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string `yaml:"url,omitempty"`
|
||||
AuthToken string `yaml:"auth-token,omitempty"`
|
||||
SourceURL string `yaml:"source-url,omitempty"`
|
||||
Metadata map[string]interface{} `yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.URL) == 0 {
|
||||
return ErrURLNotSet
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, restAPIUrl) {
|
||||
return ErrURLNotPrefixedWithRestAPIURL
|
||||
}
|
||||
if len(cfg.AuthToken) == 0 {
|
||||
return ErrAuthTokenNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.URL) > 0 {
|
||||
cfg.URL = override.URL
|
||||
}
|
||||
if len(override.AuthToken) > 0 {
|
||||
cfg.AuthToken = override.AuthToken
|
||||
}
|
||||
if len(override.SourceURL) > 0 {
|
||||
cfg.SourceURL = override.SourceURL
|
||||
}
|
||||
if len(override.Metadata) > 0 {
|
||||
cfg.Metadata = override.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using incident.io
|
||||
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"`
|
||||
}
|
||||
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
|
||||
response, err := client.GetHTTPClient(nil).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
incidentioResponse := Response{}
|
||||
err = json.NewDecoder(response.Body).Decode(&incidentioResponse)
|
||||
if err != nil {
|
||||
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||
logr.Errorf("[incidentio.Send] Ran into error decoding pagerduty response: %s", err.Error())
|
||||
}
|
||||
alert.ResolveKey = incidentioResponse.DeduplicationKey
|
||||
return err
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
AlertSourceConfigID string `json:"alert_source_config_id"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
DeduplicationKey string `json:"deduplication_key,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
DeduplicationKey string `json:"deduplication_key"`
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var message, formattedConditionResults, status string
|
||||
if resolved {
|
||||
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
||||
status = "resolved"
|
||||
} else {
|
||||
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
||||
status = "firing"
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "🟢"
|
||||
} else {
|
||||
prefix = "🔴"
|
||||
}
|
||||
// No need for \n since incident.io trims it anyways.
|
||||
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
||||
}
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
message += " with the following description: " + alert.GetDescription()
|
||||
}
|
||||
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
|
||||
var body []byte
|
||||
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
|
||||
body, _ = json.Marshal(Body{
|
||||
AlertSourceConfigID: alertSourceID,
|
||||
Title: "Gatus: " + ep.DisplayName(),
|
||||
Status: status,
|
||||
DeduplicationKey: alert.ResolveKey,
|
||||
Description: message,
|
||||
SourceURL: cfg.SourceURL,
|
||||
Metadata: cfg.Metadata,
|
||||
})
|
||||
fmt.Printf("%v", string(body))
|
||||
return body
|
||||
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
390
alerting/provider/incidentio/incidentio_test.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package incidentio
|
||||
|
||||
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) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "https://api.incident.io/v2/alert_events/http/some-id",
|
||||
AuthToken: "some-token",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid-url",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "id-without-rest-api-url-as-prefix",
|
||||
AuthToken: "some-token",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-missing-auth-token",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "some-id",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-missing-alert-source-config-id",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthToken: "some-token",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid-override",
|
||||
provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthToken: "some-token",
|
||||
URL: "https://api.incident.io/v2/alert_events/http/some-id",
|
||||
},
|
||||
Overrides: []Override{{Group: "core", Config: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id"}}},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if scenario.expected && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if !scenario.expected && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{
|
||||
URL: restAPIUrl + "some-id",
|
||||
AuthToken: "some-token",
|
||||
}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
var b bytes.Buffer
|
||||
|
||||
response := Response{DeduplicationKey: "some-key"}
|
||||
json.NewEncoder(&b).Encode(response)
|
||||
reader := io.NopCloser(&b)
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{
|
||||
URL: restAPIUrl + "some-id",
|
||||
AuthToken: "some-token",
|
||||
}},
|
||||
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{
|
||||
URL: restAPIUrl + "some-id",
|
||||
AuthToken: "some-token",
|
||||
}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
var b bytes.Buffer
|
||||
response := Response{DeduplicationKey: "some-key"}
|
||||
json.NewEncoder(&b).Encode(response)
|
||||
reader := io.NopCloser(&b)
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{}},
|
||||
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(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-metadata-source-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
|
||||
},
|
||||
{
|
||||
Name: "group-override",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
cfg,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "https://api.incident.io/v2/alert_events/http/another-id"}},
|
||||
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id", AuthToken: "some-token"},
|
||||
},
|
||||
}
|
||||
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.URL != scenario.ExpectedOutput.URL {
|
||||
t.Errorf("expected alert source config to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||
}
|
||||
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
|
||||
t.Errorf("expected alert auth token to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{URL: "", AuthToken: "some-token"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/nice-id", AuthToken: "some-token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/very-good-id", AuthToken: "some-token"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
@@ -203,7 +202,6 @@ func randStringBytes(n int) string {
|
||||
// All the compatible characters to use in a transaction ID
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := range b {
|
||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
@@ -79,6 +83,10 @@ var (
|
||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gotify.AlertProvider)(nil)
|
||||
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
@@ -103,6 +111,10 @@ var (
|
||||
_ Config[github.Config] = (*github.Config)(nil)
|
||||
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
|
||||
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
|
||||
_ Config[gotify.Config] = (*gotify.Config)(nil)
|
||||
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
|
||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
|
||||
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
|
||||
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
|
||||
ErrInvalidDevice = errors.New("device name must have 25 characters or less")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -33,8 +34,8 @@ type Config struct {
|
||||
// Key of the user or group the messages should be sent to
|
||||
UserKey string `yaml:"user-key"`
|
||||
|
||||
// The title of your message, likely the application name
|
||||
// default: the name of your application in Pushover
|
||||
// The title of your message
|
||||
// default: "Gatus: <endpoint>""
|
||||
Title string `yaml:"title,omitempty"`
|
||||
|
||||
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
|
||||
@@ -48,6 +49,15 @@ type Config struct {
|
||||
// Sound of the messages (see: https://pushover.net/api#sounds)
|
||||
// default: "" (pushover)
|
||||
Sound string `yaml:"sound,omitempty"`
|
||||
|
||||
// TTL of your message (https://pushover.net/api#ttl)
|
||||
// If priority is 2 then this parameter is ignored
|
||||
// default: 0
|
||||
TTL int `yaml:"ttl,omitempty"`
|
||||
|
||||
// Device to send the message to (see: https://pushover.net/api#devices)
|
||||
// default: "" (all devices)
|
||||
Device string `yaml:"device,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -66,6 +76,9 @@ func (cfg *Config) Validate() error {
|
||||
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
|
||||
return ErrInvalidPriority
|
||||
}
|
||||
if len(cfg.Device) > 25 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,6 +101,12 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.Sound) > 0 {
|
||||
cfg.Sound = override.Sound
|
||||
}
|
||||
if override.TTL > 0 {
|
||||
cfg.TTL = override.TTL
|
||||
}
|
||||
if len(override.Device) > 0 {
|
||||
cfg.Device = override.Device
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||
@@ -134,28 +153,49 @@ type Body struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Html int `json:"html"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
TTL int `json:"ttl,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
var message, formattedConditionResults string
|
||||
priority := cfg.Priority
|
||||
if resolved {
|
||||
priority = cfg.ResolvedPriority
|
||||
message = fmt.Sprintf("An alert for <b>%s</b> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for <b>%s</b> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||
}
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
message += " with the following description: " + alert.GetDescription()
|
||||
}
|
||||
message += formattedConditionResults
|
||||
title := "Gatus: " + ep.DisplayName()
|
||||
if cfg.Title != "" {
|
||||
title = cfg.Title
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
Token: cfg.ApplicationToken,
|
||||
User: cfg.UserKey,
|
||||
Title: cfg.Title,
|
||||
Title: title,
|
||||
Message: message,
|
||||
Priority: priority,
|
||||
Html: 1,
|
||||
Sound: cfg.Sound,
|
||||
TTL: cfg.TTL,
|
||||
Device: cfg.Device,
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -139,29 +139,50 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-customtitle",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4", Title: "Gatus Notifications"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2}},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Priority: 2, ResolvedPriority: 2}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-priority",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":0}",
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
|
||||
},
|
||||
{
|
||||
Name: "with-sound",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"sound\":\"falling\"}",
|
||||
},
|
||||
{
|
||||
Name: "with-ttl",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600}",
|
||||
},
|
||||
{
|
||||
Name: "with-device",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600, Device: "iphone15pro",}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600,\"device\":\"iphone15pro\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
|
||||
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
}
|
||||
|
||||
// Configure default title if it's not provided
|
||||
title := "⛑ Gatus"
|
||||
title := "⛑️ Gatus"
|
||||
if cfg.Title != "" {
|
||||
title = cfg.Title
|
||||
}
|
||||
@@ -157,9 +157,9 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var key string
|
||||
if conditionResult.Success {
|
||||
key = "✅"
|
||||
key = "✅"
|
||||
} else {
|
||||
key = "❌"
|
||||
key = "❌"
|
||||
}
|
||||
facts = append(facts, Fact{
|
||||
Title: key,
|
||||
|
||||
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -23,9 +23,10 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
ApiUrl string `yaml:"api-url"`
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
TopicID string `yaml:"topic-id,omitempty"`
|
||||
ApiUrl string `yaml:"api-url"`
|
||||
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
@@ -53,6 +54,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ID) > 0 {
|
||||
cfg.ID = override.ID
|
||||
}
|
||||
if len(override.TopicID) > 0 {
|
||||
cfg.TopicID = override.TopicID
|
||||
}
|
||||
if len(override.ApiUrl) > 0 {
|
||||
cfg.ApiUrl = override.ApiUrl
|
||||
}
|
||||
@@ -117,6 +121,7 @@ type Body struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
TopicID string `json:"message_thread_id,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
@@ -150,6 +155,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
ChatID: cfg.ID,
|
||||
Text: text,
|
||||
ParseMode: "MARKDOWN",
|
||||
TopicID: cfg.TopicID,
|
||||
})
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
@@ -154,6 +154,13 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "send to topic",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
@@ -27,6 +28,9 @@ type Config struct {
|
||||
Token string `yaml:"token"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
|
||||
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -58,6 +62,12 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
if len(override.TextTwilioTriggered) > 0 {
|
||||
cfg.TextTwilioTriggered = override.TextTwilioTriggered
|
||||
}
|
||||
if len(override.TextTwilioResolved) > 0 {
|
||||
cfg.TextTwilioResolved = override.TextTwilioResolved
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||
@@ -102,9 +112,17 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
if len(cfg.TextTwilioResolved) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
if len(cfg.TextTwilioTriggered) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
}
|
||||
return url.Values{
|
||||
"To": {cfg.To},
|
||||
|
||||
13
api/api.go
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/TwiN/health"
|
||||
@@ -31,6 +32,10 @@ func New(cfg *config.Config) *API {
|
||||
logr.Warnf("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
|
||||
cfg.Web = web.GetDefaultConfig()
|
||||
}
|
||||
if cfg.UI == nil {
|
||||
logr.Warnf("[api.New] nil ui config passed as parameter. This should only happen in tests. Using default ui configuration")
|
||||
cfg.UI = ui.GetDefaultConfig()
|
||||
}
|
||||
api.router = api.createRouter(cfg)
|
||||
return api
|
||||
}
|
||||
@@ -70,10 +75,12 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
// UNPROTECTED ROUTES //
|
||||
////////////////////////
|
||||
unprotectedAPIRouter := apiRouter.Group("/")
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||
// This endpoint requires authz with bearer token, so technically it is protected
|
||||
@@ -87,6 +94,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
|
||||
return c.Status(statusCode).Send(body)
|
||||
})
|
||||
// Custom CSS
|
||||
app.Get("/css/custom.css", CustomCSSHandler{customCSS: cfg.UI.CustomCSS}.GetCustomCSS)
|
||||
// Everything else falls back on static content
|
||||
app.Use(redirect.New(redirect.Config{
|
||||
Rules: map[string]string{
|
||||
@@ -117,6 +126,6 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
}
|
||||
}
|
||||
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus)
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@ func TestNew(t *testing.T) {
|
||||
Path: "/health",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "custom.css",
|
||||
Path: "/css/custom.css",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "custom.css-gzipped",
|
||||
Path: "/css/custom.css",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
|
||||
21
api/badge.go
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -53,7 +54,10 @@ func UptimeBadge(c *fiber.Ctx) error {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
@@ -88,7 +92,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
@@ -107,7 +114,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||
|
||||
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
func HealthBadge(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
@@ -133,7 +143,10 @@ func HealthBadge(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func HealthBadgeShields(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -45,7 +46,11 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
@@ -18,8 +21,24 @@ func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||
}
|
||||
// Return the config
|
||||
|
||||
// Prepare response with announcements
|
||||
response := map[string]interface{}{
|
||||
"oidc": hasOIDC,
|
||||
"authenticated": isAuthenticated,
|
||||
}
|
||||
// Add announcements if available, otherwise use empty slice
|
||||
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
|
||||
response["announcements"] = handler.config.Announcements
|
||||
} else {
|
||||
response["announcements"] = []interface{}{}
|
||||
}
|
||||
|
||||
// Return the config as JSON
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).
|
||||
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
|
||||
}
|
||||
return c.Status(200).Send(responseBytes)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
|
||||
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
|
||||
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
14
api/custom_css.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type CustomCSSHandler struct {
|
||||
customCSS string
|
||||
}
|
||||
|
||||
func (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error {
|
||||
c.Set("Content-Type", "text/css")
|
||||
return c.Status(200).SendString(handler.customCSS)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
// Due to how intensive this operation can be on the storage, this function leverages a cache.
|
||||
func EndpointStatuses(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
|
||||
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
|
||||
var data []byte
|
||||
if !exists {
|
||||
@@ -59,12 +60,14 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
|
||||
for _, instance := range remoteConfig.Instances {
|
||||
response, err := httpClient.Get(instance.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Log the error but continue with other instances
|
||||
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
var endpointStatuses []*endpoint.Status
|
||||
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
|
||||
_ = response.Body.Close()
|
||||
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to decode endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
@@ -73,29 +76,40 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
|
||||
}
|
||||
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
|
||||
}
|
||||
// Only return nil, error if no remote instances were successfully processed
|
||||
if len(endpointStatusesFromAllRemotes) == 0 && remoteConfig.Instances != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve endpoint statuses from all remote instances")
|
||||
}
|
||||
return endpointStatusesFromAllRemotes, nil
|
||||
}
|
||||
|
||||
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
|
||||
func EndpointStatus(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
func EndpointStatus(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if endpointStatus == nil { // XXX: is this check necessary?
|
||||
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", key)
|
||||
return c.Status(404).SendString("not found")
|
||||
}
|
||||
output, err := json.Marshal(endpointStatus)
|
||||
if err != nil {
|
||||
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(output)
|
||||
}
|
||||
if endpointStatus == nil { // XXX: is this check necessary?
|
||||
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
|
||||
return c.Status(404).SendString("not found")
|
||||
}
|
||||
output, err := json.Marshal(endpointStatus)
|
||||
if err != nil {
|
||||
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(output)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
@@ -95,6 +96,10 @@ func TestEndpointStatus(t *testing.T) {
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
@@ -156,7 +161,13 @@ func TestEndpointStatuses(t *testing.T) {
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
api := New(&config.Config{Metrics: true})
|
||||
api := New(&config.Config{
|
||||
Metrics: true,
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
})
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/metrics"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
extraLabels := cfg.GetUniqueExtraMetricLabels()
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Check if the success query parameter is present
|
||||
success, exists := c.Queries()["success"]
|
||||
@@ -46,6 +48,14 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
Success: c.QueryBool("success"),
|
||||
Errors: []string{},
|
||||
}
|
||||
if len(c.Query("duration")) > 0 {
|
||||
parsedDuration, err := time.ParseDuration(c.Query("duration"))
|
||||
if err != nil {
|
||||
logr.Errorf("[api.CreateExternalEndpointResult] Invalid duration from string=%s with error: %s", c.Query("duration"), err.Error())
|
||||
return c.Status(400).SendString("invalid duration: " + err.Error())
|
||||
}
|
||||
result.Duration = parsedDuration
|
||||
}
|
||||
if !result.Success && c.Query("error") != "" {
|
||||
result.Errors = append(result.Errors, c.Query("error"))
|
||||
}
|
||||
@@ -64,6 +74,9 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
}
|
||||
if cfg.Metrics {
|
||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||
}
|
||||
// Return the result
|
||||
return c.Status(200).SendString("")
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 400,
|
||||
},
|
||||
{
|
||||
Name: "bad-duration-value",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=true&duration=invalid",
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 400,
|
||||
},
|
||||
{
|
||||
Name: "good-token-success-true",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||
@@ -82,6 +88,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "good-duration-success-true",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=true&duration=10s",
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "good-token-success-false",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=false",
|
||||
@@ -118,7 +130,7 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
})
|
||||
}
|
||||
t.Run("verify-end-results", func(t *testing.T) {
|
||||
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10))
|
||||
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 11))
|
||||
if err != nil {
|
||||
t.Errorf("failed to get endpoint status: %s", err.Error())
|
||||
return
|
||||
@@ -126,8 +138,8 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
if endpointStatus.Key != "g_n" {
|
||||
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
|
||||
}
|
||||
if len(endpointStatus.Results) != 5 {
|
||||
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
|
||||
if len(endpointStatus.Results) != 6 {
|
||||
t.Errorf("expected 6 results but got %d", len(endpointStatus.Results))
|
||||
}
|
||||
if !endpointStatus.Results[0].Success {
|
||||
t.Errorf("expected first result to be successful")
|
||||
@@ -138,8 +150,8 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
if len(endpointStatus.Results[1].Errors) > 0 {
|
||||
t.Errorf("expected second result to have no errors")
|
||||
}
|
||||
if endpointStatus.Results[2].Success {
|
||||
t.Errorf("expected third result to be unsuccessful")
|
||||
if endpointStatus.Results[2].Duration == 0 || endpointStatus.Results[2].Duration.Seconds() != 10 {
|
||||
t.Errorf("expected third result to have a duration of 10 seconds")
|
||||
}
|
||||
if endpointStatus.Results[3].Success {
|
||||
t.Errorf("expected fourth result to be unsuccessful")
|
||||
@@ -147,8 +159,11 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
if endpointStatus.Results[4].Success {
|
||||
t.Errorf("expected fifth result to be unsuccessful")
|
||||
}
|
||||
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
|
||||
t.Errorf("expected fifth result to have errors: failed")
|
||||
if endpointStatus.Results[5].Success {
|
||||
t.Errorf("expected sixth result to be unsuccessful")
|
||||
}
|
||||
if len(endpointStatus.Results[5].Errors) == 0 || endpointStatus.Results[5].Errors[0] != "failed" {
|
||||
t.Errorf("expected sixth result to have errors: failed")
|
||||
}
|
||||
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
|
||||
if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
|
||||
|
||||
75
api/raw.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func UptimeRaw(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "30d":
|
||||
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/plain")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime)))
|
||||
}
|
||||
|
||||
func ResponseTimeRaw(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "30d":
|
||||
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/plain")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send([]byte(fmt.Sprintf("%d", responseTime)))
|
||||
}
|
||||
123
api/raw_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
func TestRawDataEndpoint(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "raw-uptime-1h",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/1h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/uptimes/24h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/7d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-30d",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/30d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/uptimes/3d",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/uptimes/7d",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-1h",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/1h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-30d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/30d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
api/spa.go
@@ -10,8 +10,19 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func SinglePageApplication(ui *ui.Config) fiber.Handler {
|
||||
func SinglePageApplication(uiConfig *ui.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
vd := ui.ViewData{UI: uiConfig}
|
||||
{
|
||||
themeFromCookie := string(c.Request().Header.Cookie("theme"))
|
||||
if len(themeFromCookie) > 0 {
|
||||
if themeFromCookie == "dark" {
|
||||
vd.Theme = "dark"
|
||||
}
|
||||
} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode
|
||||
vd.Theme = "dark"
|
||||
}
|
||||
}
|
||||
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
@@ -19,7 +30,7 @@ func SinglePageApplication(ui *ui.Config) fiber.Handler {
|
||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||
}
|
||||
c.Set("Content-Type", "text/html")
|
||||
err = t.Execute(c, ui)
|
||||
err = t.Execute(c, vd)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())
|
||||
|
||||
@@ -39,29 +39,50 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
Name string
|
||||
Path string
|
||||
Gzip bool
|
||||
CookieDarkMode bool
|
||||
UIDarkMode bool
|
||||
ExpectedCode int
|
||||
ExpectedDarkTheme bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: 200,
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
CookieDarkMode: true,
|
||||
UIDarkMode: false,
|
||||
ExpectedDarkTheme: true,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint",
|
||||
Path: "/endpoints/core_frontend",
|
||||
ExpectedCode: 200,
|
||||
Name: "frontend-endpoint-light",
|
||||
Path: "/endpoints/core_frontend",
|
||||
CookieDarkMode: false,
|
||||
UIDarkMode: false,
|
||||
ExpectedDarkTheme: false,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint-dark",
|
||||
Path: "/endpoints/core_frontend",
|
||||
CookieDarkMode: false,
|
||||
UIDarkMode: true,
|
||||
ExpectedDarkTheme: true,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg.UI.DarkMode = &scenario.UIDarkMode
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
if scenario.CookieDarkMode {
|
||||
request.Header.Set("Cookie", "theme=dark")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -71,9 +92,16 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !strings.Contains(string(body), cfg.UI.Title) {
|
||||
strBody := string(body)
|
||||
if !strings.Contains(strBody, cfg.UI.Title) {
|
||||
t.Errorf("%s %s should have contained the title", request.Method, request.URL)
|
||||
}
|
||||
if scenario.ExpectedDarkTheme && !strings.Contains(strBody, "class=\"dark\"") {
|
||||
t.Errorf("%s %s should have responded with dark mode headers", request.Method, request.URL)
|
||||
}
|
||||
if !scenario.ExpectedDarkTheme && strings.Contains(strBody, "class=\"dark\"") {
|
||||
t.Errorf("%s %s should not have responded with dark mode headers", request.Method, request.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
18
api/util.go
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
@@ -13,12 +12,9 @@ const (
|
||||
|
||||
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 20
|
||||
|
||||
// MaximumPageSize is the maximum page size allowed
|
||||
MaximumPageSize = common.MaximumNumberOfResults
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) {
|
||||
var err error
|
||||
if pageParameter := c.Query("page"); len(pageParameter) == 0 {
|
||||
page = DefaultPage
|
||||
@@ -38,11 +34,13 @@ func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
|
||||
if err != nil {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaximumPageSize {
|
||||
pageSize = MaximumPageSize
|
||||
} else if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
}
|
||||
if page == 1 && pageSize > maximumNumberOfResults {
|
||||
// If the page is 1 and the page size is greater than the maximum number of results, return
|
||||
// no more than the maximum number of results
|
||||
pageSize = maximumNumberOfResults
|
||||
} else if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,54 +4,62 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
MaximumNumberOfResults int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
MaximumNumberOfResults: 20,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
MaximumNumberOfResults: 40,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
MaximumNumberOfResults: 200,
|
||||
},
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: MaximumPageSize,
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfResults: 100,
|
||||
},
|
||||
{
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
MaximumNumberOfResults: 20,
|
||||
},
|
||||
{
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
MaximumNumberOfResults: 100,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -61,7 +69,7 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
c := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(c)
|
||||
c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize))
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c)
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c, scenario.MaximumNumberOfResults)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
}
|
||||
|
||||
125
client/client.go
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/TwiN/whois"
|
||||
"github.com/ishidawataru/sctp"
|
||||
"github.com/miekg/dns"
|
||||
@@ -74,38 +76,53 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e
|
||||
return domainExpiration, nil
|
||||
}
|
||||
|
||||
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
|
||||
func CanCreateTCPConnection(address string, config *Config) bool {
|
||||
conn, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
// parseLocalAddressPlaceholder returns a string with the local address replaced
|
||||
func parseLocalAddressPlaceholder(item string, localAddr net.Addr) string {
|
||||
item = strings.ReplaceAll(item, "[LOCAL_ADDRESS]", localAddr.String())
|
||||
return item
|
||||
}
|
||||
|
||||
// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint
|
||||
func CanCreateUDPConnection(address string, config *Config) bool {
|
||||
conn, err := net.DialTimeout("udp", address, config.Timeout)
|
||||
// CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint
|
||||
func CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) {
|
||||
const (
|
||||
MaximumMessageSize = 1024 // in bytes
|
||||
)
|
||||
connection, err := net.DialTimeout(netType, address, config.Timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
defer connection.Close()
|
||||
if body != "" {
|
||||
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
|
||||
connection.SetDeadline(time.Now().Add(config.Timeout))
|
||||
_, err = connection.Write([]byte(body))
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
buf := make([]byte, MaximumMessageSize)
|
||||
n, err := connection.Read(buf)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, buf[:n]
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
|
||||
func CanCreateSCTPConnection(address string, config *Config) bool {
|
||||
ch := make(chan bool)
|
||||
ch := make(chan bool, 1)
|
||||
go (func(res chan bool) {
|
||||
addr, err := sctp.ResolveSCTPAddr("sctp", address)
|
||||
if err != nil {
|
||||
res <- false
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := sctp.DialSCTP("sctp", nil, addr)
|
||||
if err != nil {
|
||||
res <- false
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
res <- true
|
||||
@@ -148,7 +165,10 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
||||
}
|
||||
|
||||
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
|
||||
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
|
||||
func CanPerformTLS(address string, body string, config *Config) (connected bool, response []byte, certificate *x509.Certificate, err error) {
|
||||
const (
|
||||
MaximumMessageSize = 1024 // in bytes
|
||||
)
|
||||
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
})
|
||||
@@ -162,9 +182,27 @@ func CanPerformTLS(address string, config *Config) (connected bool, certificate
|
||||
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
|
||||
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
|
||||
peerCertificates := connection.ConnectionState().PeerCertificates
|
||||
return true, peerCertificates[0], nil
|
||||
certificate = peerCertificates[0]
|
||||
} else {
|
||||
certificate = verifiedChains[0][0]
|
||||
}
|
||||
return true, verifiedChains[0][0], nil
|
||||
connected = true
|
||||
if body != "" {
|
||||
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
|
||||
connection.SetDeadline(time.Now().Add(config.Timeout))
|
||||
_, err = connection.Write([]byte(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, MaximumMessageSize)
|
||||
var n int
|
||||
n, err = connection.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
response = buf[:n]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||
@@ -195,6 +233,34 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
||||
return true, cli, nil
|
||||
}
|
||||
|
||||
func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
|
||||
var port string
|
||||
if strings.Contains(address, ":") {
|
||||
addressAndPort := strings.Split(address, ":")
|
||||
if len(addressAndPort) != 2 {
|
||||
return false, 1, errors.New("invalid address for ssh, format must be ssh://host:port")
|
||||
}
|
||||
address = addressAndPort[0]
|
||||
port = addressAndPort[1]
|
||||
} else {
|
||||
port = "22"
|
||||
}
|
||||
dialer := net.Dialer{}
|
||||
connStr := net.JoinHostPort(address, port)
|
||||
conn, err := dialer.Dial("tcp", connStr)
|
||||
if err != nil {
|
||||
return false, 1, err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
buf := make([]byte, 256)
|
||||
_, err = io.ReadAtLeast(conn, buf, 1)
|
||||
if err != nil {
|
||||
return false, 1, err
|
||||
}
|
||||
return true, 0, err
|
||||
}
|
||||
|
||||
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
|
||||
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
|
||||
type Body struct {
|
||||
@@ -202,6 +268,7 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
|
||||
}
|
||||
defer sshClient.Close()
|
||||
var b Body
|
||||
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
|
||||
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
@@ -254,7 +321,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
}
|
||||
|
||||
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
|
||||
func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) {
|
||||
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
|
||||
const (
|
||||
Origin = "http://localhost/"
|
||||
MaximumMessageSize = 1024 // in bytes
|
||||
@@ -263,8 +330,22 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if config != nil {
|
||||
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
|
||||
wsConfig.TlsConfig = &tls.Config{
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
}
|
||||
if config.HasTLSConfig() && config.TLS.isValid() == nil {
|
||||
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
|
||||
}
|
||||
}
|
||||
// Dial URL
|
||||
ws, err := websocket.DialConfig(wsConfig)
|
||||
@@ -272,6 +353,7 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
|
||||
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 {
|
||||
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
|
||||
@@ -295,6 +377,7 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
||||
m.SetQuestion(queryName, queryTypeAsUint16)
|
||||
r, _, err := c.Exchange(m, url)
|
||||
if err != nil {
|
||||
logr.Infof("[client.QueryDNS] Error exchanging DNS message: %v", err)
|
||||
return false, "", nil, err
|
||||
}
|
||||
connected = true
|
||||
@@ -325,6 +408,10 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
||||
if ptr, ok := rr.(*dns.PTR); ok {
|
||||
body = []byte(ptr.Ptr)
|
||||
}
|
||||
case dns.TypeSRV:
|
||||
if srv, ok := rr.(*dns.SRV); ok {
|
||||
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
|
||||
}
|
||||
default:
|
||||
body = []byte("query type is not supported yet")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -40,26 +41,26 @@ func TestGetHTTPClient(t *testing.T) {
|
||||
|
||||
func TestGetDomainExpiration(t *testing.T) {
|
||||
t.Parallel()
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Fatalf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
|
||||
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
// Make sure the refresh works when the ttl is <24 hours
|
||||
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
@@ -200,11 +201,29 @@ func TestCanPerformTLS(t *testing.T) {
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bad cert with insecure true",
|
||||
args: args{
|
||||
address: "expired.badssl.com:443",
|
||||
insecure: true,
|
||||
},
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bad cert with insecure false",
|
||||
args: args{
|
||||
address: "expired.badssl.com:443",
|
||||
insecure: false,
|
||||
},
|
||||
wantConnected: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||
return
|
||||
@@ -216,11 +235,13 @@ func TestCanPerformTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanCreateTCPConnection(t *testing.T) {
|
||||
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
|
||||
func TestCanCreateConnection(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
if !CanCreateTCPConnection("1.1.1.1:53", &Config{Timeout: 5 * time.Second}) {
|
||||
connected, _ = CanCreateNetworkConnection("tcp", "1.1.1.1:53", "", &Config{Timeout: 5 * time.Second})
|
||||
if !connected {
|
||||
t.Error("should've succeeded, because that IP should always™ be up")
|
||||
}
|
||||
}
|
||||
@@ -284,11 +305,11 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQueryWebSocket(t *testing.T) {
|
||||
_, _, err := QueryWebSocket("", "body", &Config{Timeout: 2 * time.Second})
|
||||
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
|
||||
if err == nil {
|
||||
t.Error("expected an error due to the address being invalid")
|
||||
}
|
||||
_, _, err = QueryWebSocket("ws://example.org", "body", &Config{Timeout: 2 * time.Second})
|
||||
_, _, err = QueryWebSocket("ws://example.org", "body", nil, &Config{Timeout: 2 * time.Second})
|
||||
if err == nil {
|
||||
t.Error("expected an error due to the target not being websocket-friendly")
|
||||
}
|
||||
@@ -338,7 +359,7 @@ func TestTlsRenegotiation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQueryDNS(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
inputDNS dns.Config
|
||||
inputURL string
|
||||
@@ -354,7 +375,7 @@ func TestQueryDNS(t *testing.T) {
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "93.184.215.14",
|
||||
expectedBody: "__IPV4__",
|
||||
},
|
||||
{
|
||||
name: "test Config with type AAAA",
|
||||
@@ -364,7 +385,7 @@ func TestQueryDNS(t *testing.T) {
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c",
|
||||
expectedBody: "__IPV6__",
|
||||
},
|
||||
{
|
||||
name: "test Config with type CNAME",
|
||||
@@ -416,27 +437,77 @@ func TestQueryDNS(t *testing.T) {
|
||||
isErrExpected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, dnsRCode, body, err := QueryDNS(test.inputDNS.QueryType, test.inputDNS.QueryName, test.inputURL)
|
||||
if test.isErrExpected && err == nil {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
_, dnsRCode, body, err := QueryDNS(scenario.inputDNS.QueryType, scenario.inputDNS.QueryName, scenario.inputURL)
|
||||
if scenario.isErrExpected && err == nil {
|
||||
t.Errorf("there should be an error")
|
||||
}
|
||||
if dnsRCode != test.expectedDNSCode {
|
||||
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode)
|
||||
if dnsRCode != scenario.expectedDNSCode {
|
||||
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
|
||||
}
|
||||
if test.inputDNS.QueryType == "NS" {
|
||||
if scenario.inputDNS.QueryType == "NS" {
|
||||
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
|
||||
if !pattern.Match(test.expectedBody, string(body)) {
|
||||
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
|
||||
if !pattern.Match(scenario.expectedBody, string(body)) {
|
||||
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
|
||||
}
|
||||
} else {
|
||||
if string(body) != test.expectedBody {
|
||||
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
|
||||
if string(body) != scenario.expectedBody {
|
||||
// little hack to validate arbitrary ipv4/ipv6
|
||||
switch scenario.expectedBody {
|
||||
case "__IPV4__":
|
||||
if addr, err := netip.ParseAddr(string(body)); err != nil {
|
||||
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
|
||||
} else if !addr.Is4() {
|
||||
t.Errorf("got %s, expected valid IPv4", string(body))
|
||||
}
|
||||
case "__IPV6__":
|
||||
if addr, err := netip.ParseAddr(string(body)); err != nil {
|
||||
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
|
||||
} else if !addr.Is6() {
|
||||
t.Errorf("got %s, expected valid IPv6", string(body))
|
||||
}
|
||||
default:
|
||||
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSSHBanner(t *testing.T) {
|
||||
cfg := &Config{Timeout: 3}
|
||||
|
||||
t.Run("no-auth-ssh", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected: error != nil, got: %v ", err)
|
||||
}
|
||||
|
||||
if connected == false {
|
||||
t.Errorf("Expected: connected == true, got: %v", connected)
|
||||
}
|
||||
if status != 0 {
|
||||
t.Errorf("Expected: 0, got: %v", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid-address", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("Expected: error, got: %v ", err)
|
||||
}
|
||||
|
||||
if connected != false {
|
||||
t.Errorf("Expected: connected == false, got: %v", connected)
|
||||
}
|
||||
if status != 1 {
|
||||
t.Errorf("Expected: 1, got: %v", status)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ endpoints:
|
||||
query-name: "example.com"
|
||||
query-type: "A"
|
||||
conditions:
|
||||
- "[BODY] == 93.184.215.14"
|
||||
- "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
|
||||
- name: icmp-ping
|
||||
|
||||
94
config/announcement/announcement.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package announcement
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// TypeOutage represents a service outage
|
||||
TypeOutage = "outage"
|
||||
|
||||
// TypeWarning represents a warning or potential issue
|
||||
TypeWarning = "warning"
|
||||
|
||||
// TypeInformation represents general information
|
||||
TypeInformation = "information"
|
||||
|
||||
// TypeOperational represents operational status or resolved issues
|
||||
TypeOperational = "operational"
|
||||
|
||||
// TypeNone represents no specific type (default)
|
||||
TypeNone = "none"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified
|
||||
ErrInvalidAnnouncementType = errors.New("invalid announcement type")
|
||||
|
||||
// ErrEmptyMessage is returned when an announcement has an empty message
|
||||
ErrEmptyMessage = errors.New("announcement message cannot be empty")
|
||||
|
||||
// ErrMissingTimestamp is returned when an announcement has an empty timestamp
|
||||
ErrMissingTimestamp = errors.New("announcement timestamp must be set")
|
||||
|
||||
// validTypes contains all valid announcement types
|
||||
validTypes = map[string]bool{
|
||||
TypeOutage: true,
|
||||
TypeWarning: true,
|
||||
TypeInformation: true,
|
||||
TypeOperational: true,
|
||||
TypeNone: true,
|
||||
}
|
||||
)
|
||||
|
||||
// Announcement represents a system-wide announcement
|
||||
type Announcement struct {
|
||||
// Timestamp is the UTC timestamp when the announcement was made
|
||||
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
|
||||
|
||||
// Type is the type of announcement (outage, warning, information, operational, none)
|
||||
Type string `yaml:"type" json:"type"`
|
||||
|
||||
// Message is the user-facing text describing the announcement
|
||||
Message string `yaml:"message" json:"message"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
||||
func (a *Announcement) ValidateAndSetDefaults() error {
|
||||
// Validate message
|
||||
if a.Message == "" {
|
||||
return ErrEmptyMessage
|
||||
}
|
||||
// Set default type if empty
|
||||
if a.Type == "" {
|
||||
a.Type = TypeNone
|
||||
}
|
||||
// Validate type
|
||||
if !validTypes[a.Type] {
|
||||
return ErrInvalidAnnouncementType
|
||||
}
|
||||
// If timestamp is zero, return an error
|
||||
if a.Timestamp.IsZero() {
|
||||
return ErrMissingTimestamp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)
|
||||
func SortByTimestamp(announcements []*Announcement) {
|
||||
sort.Slice(announcements, func(i, j int) bool {
|
||||
return announcements[i].Timestamp.After(announcements[j].Timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates a slice of announcements and sets defaults
|
||||
func ValidateAndSetDefaults(announcements []*Announcement) error {
|
||||
for _, announcement := range announcements {
|
||||
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting"
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/config/announcement"
|
||||
"github.com/TwiN/gatus/v5/config/connectivity"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
@@ -22,7 +24,6 @@ import (
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -99,14 +100,39 @@ type Config struct {
|
||||
// Connectivity is the configuration for connectivity
|
||||
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
|
||||
|
||||
// Announcements is the list of system-wide announcements
|
||||
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
|
||||
|
||||
configPath string // path to the file or directory from which config was loaded
|
||||
lastFileModTime time.Time // last modification time
|
||||
}
|
||||
|
||||
// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints
|
||||
// in the configuration. It iterates through each endpoint, checks if it is enabled,
|
||||
// and then collects unique labels from the endpoint's labels map.
|
||||
func (config *Config) GetUniqueExtraMetricLabels() []string {
|
||||
labels := make([]string, 0)
|
||||
for _, ep := range config.Endpoints {
|
||||
if !ep.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
for label := range ep.ExtraLabels {
|
||||
if contains(labels, label) {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
}
|
||||
if len(labels) > 1 {
|
||||
sort.Strings(labels)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
|
||||
for i := 0; i < len(config.Endpoints); i++ {
|
||||
ep := config.Endpoints[i]
|
||||
if ep.Key() == key {
|
||||
if ep.Key() == strings.ToLower(key) {
|
||||
return ep
|
||||
}
|
||||
}
|
||||
@@ -116,7 +142,7 @@ func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
|
||||
func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {
|
||||
for i := 0; i < len(config.ExternalEndpoints); i++ {
|
||||
ee := config.ExternalEndpoints[i]
|
||||
if ee.Key() == key {
|
||||
if ee.Key() == strings.ToLower(key) {
|
||||
return ee
|
||||
}
|
||||
}
|
||||
@@ -176,6 +202,10 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error walking path %s: %w", path, err)
|
||||
}
|
||||
if strings.Contains(path, "..") {
|
||||
logr.Warnf("[config.LoadConfiguration] Ignoring configuration from %s", path)
|
||||
return nil
|
||||
}
|
||||
logr.Infof("[config.LoadConfiguration] Reading configuration from %s", path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -276,6 +306,11 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
if err := validateConnectivityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAnnouncementsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Cross-config changes
|
||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -287,6 +322,17 @@ func validateConnectivityConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnnouncementsConfig(config *Config) error {
|
||||
if config.Announcements != nil {
|
||||
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sort announcements by timestamp (newest first) for API response
|
||||
announcement.SortByTimestamp(config.Announcements)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteConfig(config *Config) error {
|
||||
if config.Remote != nil {
|
||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||
@@ -299,7 +345,9 @@ func validateRemoteConfig(config *Config) error {
|
||||
func validateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeMemory,
|
||||
Type: storage.TypeMemory,
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
}
|
||||
} else {
|
||||
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
|
||||
@@ -403,6 +451,9 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
alert.TypeGitea,
|
||||
alert.TypeGoogleChat,
|
||||
alert.TypeGotify,
|
||||
alert.TypeHomeAssistant,
|
||||
alert.TypeIlert,
|
||||
alert.TypeIncidentIO,
|
||||
alert.TypeJetBrainsSpace,
|
||||
alert.TypeMatrix,
|
||||
alert.TypeMattermost,
|
||||
@@ -433,7 +484,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
|
||||
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +498,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
|
||||
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ endpoints:
|
||||
name: "dir-with-two-config-files",
|
||||
configPath: dir,
|
||||
pathAndFiles: map[string]string{
|
||||
"config.yaml": `endpoints:
|
||||
"config.yaml": `endpoints:
|
||||
- name: one
|
||||
url: https://example.com
|
||||
conditions:
|
||||
@@ -135,7 +135,7 @@ endpoints:
|
||||
url: https://example.org
|
||||
conditions:
|
||||
- "len([BODY]) > 0"`,
|
||||
"config.yml": `endpoints:
|
||||
"config.yml": `endpoints:
|
||||
- name: three
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
@@ -237,7 +237,7 @@ endpoints:
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
for path, content := range scenario.pathAndFiles {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
|
||||
}
|
||||
}
|
||||
@@ -282,7 +282,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`), 0644)
|
||||
`), 0o644)
|
||||
|
||||
t.Run("config-file-as-config-path", func(t *testing.T) {
|
||||
config, err := LoadConfiguration(configFilePath)
|
||||
@@ -298,7 +298,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"`), 0644); err != nil {
|
||||
- "[STATUS] == 200"`), 0o644); err != nil {
|
||||
t.Fatalf("failed to overwrite config file: %v", err)
|
||||
}
|
||||
if !config.HasLoadedConfigurationBeenModified() {
|
||||
@@ -315,7 +315,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
|
||||
}
|
||||
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
|
||||
// Update the config file
|
||||
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
|
||||
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
|
||||
t.Fatalf("failed to overwrite config file: %v", err)
|
||||
}
|
||||
if !config.HasLoadedConfigurationBeenModified() {
|
||||
@@ -330,6 +330,8 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||
storage:
|
||||
type: sqlite
|
||||
path: %s
|
||||
maximum-number-of-results: 10
|
||||
maximum-number-of-events: 5
|
||||
|
||||
maintenance:
|
||||
enabled: true
|
||||
@@ -386,6 +388,9 @@ endpoints:
|
||||
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
|
||||
t.Error("expected storage to be set to sqlite, got", config.Storage)
|
||||
}
|
||||
if config.Storage == nil || config.Storage.MaximumNumberOfResults != 10 || config.Storage.MaximumNumberOfEvents != 5 {
|
||||
t.Error("expected MaximumNumberOfResults and MaximumNumberOfEvents to be set to 10 and 5, got", config.Storage.MaximumNumberOfResults, config.Storage.MaximumNumberOfEvents)
|
||||
}
|
||||
if config.UI == nil || config.UI.Title != "T" || config.UI.Header != "H" || config.UI.Link != "https://example.org" || len(config.UI.Buttons) != 2 || config.UI.Buttons[0].Name != "Home" || config.UI.Buttons[0].Link != "https://example.org" || config.UI.Buttons[1].Name != "Status page" || config.UI.Buttons[1].Link != "https://status.example.org" {
|
||||
t.Error("expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got", config.UI)
|
||||
}
|
||||
@@ -708,7 +713,7 @@ func TestParseAndValidateBadConfigBytes(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
badconfig:
|
||||
- asdsa: w0w
|
||||
usadasdrl: asdxzczxc
|
||||
usadasdrl: asdxzczxc
|
||||
asdas:
|
||||
- soup
|
||||
`))
|
||||
@@ -1938,3 +1943,114 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no-endpoints",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single-endpoint-no-labels",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single-endpoint-with-labels",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "production",
|
||||
"team": "backend",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"env", "team"},
|
||||
},
|
||||
{
|
||||
name: "multiple-endpoints-with-labels",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "production",
|
||||
"team": "backend",
|
||||
"module": "auth",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "endpoint2",
|
||||
URL: "https://example.org",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "staging",
|
||||
"team": "frontend",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"env", "team", "module"},
|
||||
},
|
||||
{
|
||||
name: "multiple-endpoints-with-some-disabled",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "production",
|
||||
"team": "backend",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "endpoint2",
|
||||
URL: "https://example.org",
|
||||
Enabled: toPtr(false),
|
||||
ExtraLabels: map[string]string{
|
||||
"module": "auth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"env", "team"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
labels := tt.config.GetUniqueExtraMetricLabels()
|
||||
if len(labels) != len(tt.expected) {
|
||||
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
|
||||
}
|
||||
for _, label := range tt.expected {
|
||||
if !contains(labels, label) {
|
||||
t.Errorf("expected label %s to be present", label)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ type Checker struct {
|
||||
}
|
||||
|
||||
func (c *Checker) Check() bool {
|
||||
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
|
||||
connected, _ := client.CanCreateNetworkConnection("tcp", c.Target, "", &client.Config{Timeout: 5 * time.Second})
|
||||
return connected
|
||||
}
|
||||
|
||||
func (c *Checker) IsConnected() bool {
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +21,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -95,6 +99,9 @@ type Endpoint struct {
|
||||
// Headers of the request
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
|
||||
// ExtraLabels are key-value pairs that can be used to metric the endpoint
|
||||
ExtraLabels map[string]string `yaml:"extra-labels,omitempty"`
|
||||
|
||||
// Interval is the duration to wait between every status check
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
|
||||
@@ -104,6 +111,9 @@ type Endpoint struct {
|
||||
// Alerts is the alerting configuration for the endpoint in case of failure
|
||||
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
|
||||
|
||||
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
|
||||
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
|
||||
|
||||
// DNSConfig is the configuration for DNS monitoring
|
||||
DNSConfig *dns.Config `yaml:"dns,omitempty"`
|
||||
|
||||
@@ -121,6 +131,9 @@ type Endpoint struct {
|
||||
|
||||
// NumberOfSuccessesInARow is the number of successful evaluations in a row
|
||||
NumberOfSuccessesInARow int `yaml:"-"`
|
||||
|
||||
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
|
||||
LastReminderSent time.Time `yaml:"-"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the endpoint is enabled or not
|
||||
@@ -219,8 +232,13 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
|
||||
if e.Type() == TypeUNKNOWN {
|
||||
return ErrUnknownEndpointType
|
||||
}
|
||||
for _, maintenanceWindow := range e.MaintenanceWindows {
|
||||
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Make sure that the request can be created
|
||||
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
|
||||
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -255,12 +273,17 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
// Parse or extract hostname from URL
|
||||
if e.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(e.URL, ":53")
|
||||
} else if e.Type() == TypeICMP {
|
||||
// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,
|
||||
// "1111:2222:3333::4444" being displayed as "1111:2222:3333:" because :4444 would be interpreted as a port.
|
||||
result.Hostname = strings.TrimPrefix(e.URL, "icmp://")
|
||||
} else {
|
||||
urlObject, err := url.Parse(e.URL)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
} else {
|
||||
result.Hostname = urlObject.Hostname()
|
||||
result.port = urlObject.Port()
|
||||
}
|
||||
}
|
||||
// Retrieve IP if necessary
|
||||
@@ -298,7 +321,13 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
||||
}
|
||||
result.Hostname = ""
|
||||
result.Hostname = "" // remove it from the result so it doesn't get exposed
|
||||
}
|
||||
if e.UIConfig.HidePort && len(result.port) > 0 {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
|
||||
}
|
||||
result.port = ""
|
||||
}
|
||||
if e.UIConfig.HideConditions {
|
||||
result.ConditionResults = nil
|
||||
@@ -306,6 +335,29 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Endpoint) getParsedBody() string {
|
||||
body := e.Body
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", e.Group)
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", e.URL)
|
||||
randRegex, err := regexp.Compile(`\[RANDOM_STRING_\d+\]`)
|
||||
if err == nil {
|
||||
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
|
||||
n, _ := strconv.Atoi(match[15 : len(match)-1])
|
||||
if n > 8192 {
|
||||
n = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage
|
||||
}
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||
}
|
||||
return string(b)
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (e *Endpoint) getIP(result *Result) {
|
||||
if ips, err := net.LookupIP(result.Hostname); err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -336,7 +388,7 @@ func (e *Endpoint) call(result *Result) {
|
||||
if endpointType == TypeSTARTTLS {
|
||||
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
|
||||
} else {
|
||||
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig)
|
||||
result.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.getParsedBody(), e.ClientConfig)
|
||||
}
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -345,10 +397,10 @@ func (e *Endpoint) call(result *Result) {
|
||||
result.Duration = time.Since(startTime)
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
} else if endpointType == TypeTCP {
|
||||
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig)
|
||||
result.Connected, result.Body = client.CanCreateNetworkConnection("tcp", strings.TrimPrefix(e.URL, "tcp://"), e.getParsedBody(), e.ClientConfig)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeUDP {
|
||||
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig)
|
||||
result.Connected, result.Body = client.CanCreateNetworkConnection("udp", strings.TrimPrefix(e.URL, "udp://"), e.getParsedBody(), e.ClientConfig)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSCTP {
|
||||
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
|
||||
@@ -356,20 +408,40 @@ func (e *Endpoint) call(result *Result) {
|
||||
} else if endpointType == TypeICMP {
|
||||
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
|
||||
} else if endpointType == TypeWS {
|
||||
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig)
|
||||
wsHeaders := map[string]string{}
|
||||
if e.Headers != nil {
|
||||
for k, v := range e.Headers {
|
||||
wsHeaders[k] = v
|
||||
}
|
||||
}
|
||||
if _, exists := wsHeaders["User-Agent"]; !exists {
|
||||
wsHeaders["User-Agent"] = GatusUserAgent
|
||||
}
|
||||
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSSH {
|
||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
||||
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
|
||||
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Success = result.Connected
|
||||
result.Duration = time.Since(startTime)
|
||||
return
|
||||
}
|
||||
var cli *ssh.Client
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig)
|
||||
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
@@ -403,12 +475,12 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
|
||||
var bodyBuffer *bytes.Buffer
|
||||
if e.GraphQL {
|
||||
graphQlBody := map[string]string{
|
||||
"query": e.Body,
|
||||
"query": e.getParsedBody(),
|
||||
}
|
||||
body, _ := json.Marshal(graphQlBody)
|
||||
bodyBuffer = bytes.NewBuffer(body)
|
||||
} else {
|
||||
bodyBuffer = bytes.NewBuffer([]byte(e.Body))
|
||||
bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))
|
||||
}
|
||||
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
|
||||
for k, v := range e.Headers {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
@@ -164,9 +165,9 @@ func TestEndpoint(t *testing.T) {
|
||||
Name: "endpoint-that-will-time-out-and-hidden-hostname",
|
||||
Endpoint: Endpoint{
|
||||
Name: "endpoint-that-will-time-out",
|
||||
URL: "https://twin.sh/health",
|
||||
URL: "https://twin.sh:9999/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HideHostname: true},
|
||||
UIConfig: &ui.Config{HideHostname: true, HidePort: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
@@ -179,7 +180,7 @@ func TestEndpoint(t *testing.T) {
|
||||
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
DomainExpiration: 0,
|
||||
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
|
||||
Errors: []string{`Get "https://<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
|
||||
Errors: []string{`Get "https://<redacted>:<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
|
||||
},
|
||||
MockRoundTripper: nil,
|
||||
},
|
||||
@@ -390,10 +391,11 @@ func TestEndpoint_Type(t *testing.T) {
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
MaintenanceWindows: []*maintenance.Config{{Start: "03:50", Duration: 4 * time.Hour}},
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
@@ -432,6 +434,15 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
if endpoint.Alerts[0].FailureThreshold != 3 {
|
||||
t.Error("Endpoint alert should've defaulted to a failure threshold of 3")
|
||||
}
|
||||
if len(endpoint.MaintenanceWindows) != 1 {
|
||||
t.Error("Endpoint should've had 1 maintenance window")
|
||||
}
|
||||
if !endpoint.MaintenanceWindows[0].IsEnabled() {
|
||||
t.Error("Endpoint maintenance should've defaulted to true")
|
||||
}
|
||||
if endpoint.MaintenanceWindows[0].Timezone != "UTC" {
|
||||
t.Error("Endpoint maintenance should've defaulted to UTC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {
|
||||
@@ -761,7 +772,7 @@ func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
|
||||
|
||||
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
|
||||
conditionBody := Condition("[BODY] == 93.184.215.14")
|
||||
conditionBody := Condition("[BODY] == pat(*.*.*.*)")
|
||||
endpoint := Endpoint{
|
||||
Name: "example",
|
||||
URL: "8.8.8.8",
|
||||
|
||||
@@ -2,13 +2,19 @@ package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.
|
||||
ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint")
|
||||
|
||||
// ErrExternalEndpointHeartbeatIntervalTooLow is the error with which Gatus will panic if an external endpoint's heartbeat interval is less than 10 seconds.
|
||||
ErrExternalEndpointHeartbeatIntervalTooLow = errors.New("heartbeat interval must be at least 10 seconds")
|
||||
)
|
||||
|
||||
// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that
|
||||
@@ -30,6 +36,12 @@ type ExternalEndpoint struct {
|
||||
// Alerts is the alerting configuration for the endpoint in case of failure
|
||||
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
|
||||
|
||||
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
|
||||
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
|
||||
|
||||
// Heartbeat is the configuration that checks if the external endpoint has received new results when it should have.
|
||||
Heartbeat heartbeat.Config `yaml:"heartbeat,omitempty"`
|
||||
|
||||
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
||||
NumberOfFailuresInARow int `yaml:"-"`
|
||||
|
||||
@@ -45,6 +57,10 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
|
||||
if len(externalEndpoint.Token) == 0 {
|
||||
return ErrExternalEndpointWithNoToken
|
||||
}
|
||||
if externalEndpoint.Heartbeat.Interval != 0 && externalEndpoint.Heartbeat.Interval < 10*time.Second {
|
||||
// If the heartbeat interval is set (non-0), it must be at least 10 seconds.
|
||||
return ErrExternalEndpointHeartbeatIntervalTooLow
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
11
config/endpoint/heartbeat/heartbeat.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package heartbeat
|
||||
|
||||
import "time"
|
||||
|
||||
// Config used to check if the external endpoint has received new results when it should have.
|
||||
// This configuration is used to trigger alerts when an external endpoint has no new results for a defined period of time
|
||||
type Config struct {
|
||||
// Interval is the time interval at which Gatus verifies whether the external endpoint has received new results
|
||||
// If no new result is received within the interval, the endpoint is marked as failed and alerts are triggered
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
}
|
||||
@@ -14,5 +14,6 @@ func sanitize(s string) string {
|
||||
s = strings.ReplaceAll(s, ".", "-")
|
||||
s = strings.ReplaceAll(s, ",", "-")
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = strings.ReplaceAll(s, "#", "-")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ type Result struct {
|
||||
// Note that this field is not persisted in the storage.
|
||||
// It is used for health evaluation as well as debugging purposes.
|
||||
Body []byte `json:"-"`
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Below is used only for the UI and is not persisted in the storage //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
port string `yaml:"-"` // used for endpoints[].ui.hide-port
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
|
||||
@@ -19,6 +19,10 @@ type Config struct {
|
||||
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Username) == 0 {
|
||||
return ErrEndpointWithoutSSHUsername
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
|
||||
func TestSSH_validate(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected an error")
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHUsername) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error("didn't expect an error")
|
||||
}
|
||||
cfg.Username = "username"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
|
||||
@@ -13,6 +13,9 @@ type Config struct {
|
||||
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
|
||||
HideURL bool `yaml:"hide-url"`
|
||||
|
||||
// HidePort whether to hide the port in the Result
|
||||
HidePort bool `yaml:"hide-port"`
|
||||
|
||||
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
|
||||
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
|
||||
|
||||
@@ -54,6 +57,7 @@ func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
HideHostname: false,
|
||||
HideURL: false,
|
||||
HidePort: false,
|
||||
DontResolveFailedConditions: false,
|
||||
HideConditions: false,
|
||||
Badge: &Badge{
|
||||
|
||||
@@ -110,12 +110,13 @@ func (c *Config) IsUnderMaintenance() bool {
|
||||
if c.TimezoneLocation != nil {
|
||||
now = now.In(c.TimezoneLocation)
|
||||
}
|
||||
var dayWhereMaintenancePeriodWouldStart time.Time
|
||||
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
|
||||
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
|
||||
} else {
|
||||
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
|
||||
adjustedDate := now.Day()
|
||||
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
||||
// if time in maintenance window is later than now, treat it as yesterday
|
||||
adjustedDate--
|
||||
}
|
||||
// Set to midnight prior to adding duration
|
||||
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
|
||||
hasMaintenanceEveryDay := len(c.Every) == 0
|
||||
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
|
||||
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
|
||||
|
||||
@@ -177,16 +177,16 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
yes, no := true, false
|
||||
now := time.Now().UTC()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expected bool
|
||||
name string
|
||||
cfg *Config
|
||||
expectedUnderMaintenance bool
|
||||
}{
|
||||
{
|
||||
name: "disabled",
|
||||
cfg: &Config{
|
||||
Enabled: &no,
|
||||
},
|
||||
expected: false,
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-explicitly-enabled",
|
||||
@@ -195,7 +195,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-2h",
|
||||
@@ -203,7 +203,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-8h",
|
||||
@@ -211,7 +211,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 8 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-8h-explicit-days",
|
||||
@@ -220,7 +220,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Duration: 8 * time.Hour,
|
||||
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-23h-explicit-days",
|
||||
@@ -229,7 +229,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Duration: 23 * time.Hour,
|
||||
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-4h-ago-for-8h",
|
||||
@@ -237,7 +237,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 8 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-22h-ago-for-23h",
|
||||
@@ -245,7 +245,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
|
||||
Duration: 23 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-22h-ago-for-24h",
|
||||
@@ -253,59 +253,25 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
|
||||
Duration: 24 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-amsterdam-timezone-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Europe/Amsterdam", t).Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
Timezone: "Europe/Amsterdam",
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-utc-timezone-starting-now-for-2h",
|
||||
name: "under-maintenance-perth-timezone-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Australia/Perth", t).Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
Timezone: "UTC",
|
||||
Timezone: "Australia/Perth",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-4h-ago-for-3h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 3 * time.Hour,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-5h-ago-for-1h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
|
||||
Duration: time.Hour,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today-with-24h-duration",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 24 * time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-los-angeles-timezone-starting-now-for-2h-today",
|
||||
@@ -315,7 +281,50 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Timezone: "America/Los_Angeles",
|
||||
Every: []string{now.Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-utc-timezone-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
Timezone: "UTC",
|
||||
},
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-4h-ago-for-3h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 3 * time.Hour,
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-5h-ago-for-1h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
|
||||
Duration: time.Hour,
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today-with-24h-duration",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 24 * time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -326,8 +335,8 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
t.Fatal("validation shouldn't have returned an error, got", err)
|
||||
}
|
||||
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
|
||||
if isUnderMaintenance != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, isUnderMaintenance)
|
||||
if isUnderMaintenance != scenario.expectedUnderMaintenance {
|
||||
t.Errorf("expectedUnderMaintenance %v, got %v", scenario.expectedUnderMaintenance, isUnderMaintenance)
|
||||
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
|
||||
}
|
||||
})
|
||||
@@ -340,3 +349,11 @@ func normalizeHour(hour int) int {
|
||||
}
|
||||
return hour
|
||||
}
|
||||
|
||||
func inTimezone(passedTime time.Time, timezone string, t *testing.T) time.Time {
|
||||
timezoneLocation, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
t.Fatalf("timezone %s did not load", timezone)
|
||||
}
|
||||
return passedTime.In(timezoneLocation)
|
||||
}
|
||||
|
||||
@@ -5,29 +5,53 @@ import (
|
||||
"errors"
|
||||
"html/template"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTitle = "Health Dashboard | Gatus"
|
||||
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
|
||||
defaultHeader = "Health Status"
|
||||
defaultHeader = "Gatus"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
defaultCustomCSS = ""
|
||||
defaultSortBy = "name"
|
||||
defaultFilterBy = "none"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultDarkMode = true
|
||||
|
||||
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
|
||||
ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'")
|
||||
ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'none', 'failing', or 'unstable'")
|
||||
)
|
||||
|
||||
// Config is the configuration for the UI of Gatus
|
||||
type Config struct {
|
||||
Title string `yaml:"title,omitempty"` // Title of the page
|
||||
Description string `yaml:"description,omitempty"` // Meta description of the page
|
||||
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
Title string `yaml:"title,omitempty"` // Title of the page
|
||||
Description string `yaml:"description,omitempty"` // Meta description of the page
|
||||
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
|
||||
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
|
||||
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
|
||||
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// Non-configurable - used for UI rendering //
|
||||
//////////////////////////////////////////////
|
||||
MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
|
||||
}
|
||||
|
||||
func (cfg *Config) IsDarkMode() bool {
|
||||
if cfg.DarkMode != nil {
|
||||
return *cfg.DarkMode
|
||||
}
|
||||
return defaultDarkMode
|
||||
}
|
||||
|
||||
// Button is the configuration for a button on the UI
|
||||
@@ -47,11 +71,16 @@ func (btn *Button) Validate() error {
|
||||
// GetDefaultConfig returns a Config struct with the default values
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
Title: defaultTitle,
|
||||
Description: defaultDescription,
|
||||
Header: defaultHeader,
|
||||
Logo: defaultLogo,
|
||||
Link: defaultLink,
|
||||
Title: defaultTitle,
|
||||
Description: defaultDescription,
|
||||
Header: defaultHeader,
|
||||
Logo: defaultLogo,
|
||||
Link: defaultLink,
|
||||
CustomCSS: defaultCustomCSS,
|
||||
DarkMode: &defaultDarkMode,
|
||||
DefaultSortBy: defaultSortBy,
|
||||
DefaultFilterBy: defaultFilterBy,
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +95,27 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if len(cfg.Header) == 0 {
|
||||
cfg.Header = defaultHeader
|
||||
}
|
||||
if len(cfg.Header) == 0 {
|
||||
cfg.Header = defaultLink
|
||||
if len(cfg.Logo) == 0 {
|
||||
cfg.Logo = defaultLogo
|
||||
}
|
||||
if len(cfg.Link) == 0 {
|
||||
cfg.Link = defaultLink
|
||||
}
|
||||
if len(cfg.CustomCSS) == 0 {
|
||||
cfg.CustomCSS = defaultCustomCSS
|
||||
}
|
||||
if cfg.DarkMode == nil {
|
||||
cfg.DarkMode = &defaultDarkMode
|
||||
}
|
||||
if len(cfg.DefaultSortBy) == 0 {
|
||||
cfg.DefaultSortBy = defaultSortBy
|
||||
} else if cfg.DefaultSortBy != "name" && cfg.DefaultSortBy != "group" && cfg.DefaultSortBy != "health" {
|
||||
return ErrInvalidDefaultSortBy
|
||||
}
|
||||
if len(cfg.DefaultFilterBy) == 0 {
|
||||
cfg.DefaultFilterBy = defaultFilterBy
|
||||
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
|
||||
return ErrInvalidDefaultFilterBy
|
||||
}
|
||||
for _, btn := range cfg.Buttons {
|
||||
if err := btn.Validate(); err != nil {
|
||||
@@ -80,9 +128,10 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
return err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
err = t.Execute(&buffer, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return t.Execute(&buffer, ViewData{UI: cfg, Theme: "dark"})
|
||||
}
|
||||
|
||||
type ViewData struct {
|
||||
UI *Config
|
||||
Theme string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
if cfg.Header != defaultHeader {
|
||||
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||
}
|
||||
if cfg.DefaultSortBy != defaultSortBy {
|
||||
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
|
||||
}
|
||||
if cfg.DefaultFilterBy != defaultFilterBy {
|
||||
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestButton_Validate(t *testing.T) {
|
||||
@@ -74,4 +81,114 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
if defaultConfig.Logo != defaultLogo {
|
||||
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||
}
|
||||
if defaultConfig.DefaultSortBy != defaultSortBy {
|
||||
t.Error("expected GetDefaultConfig() to return defaultSortBy, got", defaultConfig.DefaultSortBy)
|
||||
}
|
||||
if defaultConfig.DefaultFilterBy != defaultFilterBy {
|
||||
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
DefaultSortBy string
|
||||
ExpectedError error
|
||||
ExpectedValue string
|
||||
}{
|
||||
{
|
||||
Name: "EmptyDefaultSortBy",
|
||||
DefaultSortBy: "",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: defaultSortBy,
|
||||
},
|
||||
{
|
||||
Name: "ValidDefaultSortBy_name",
|
||||
DefaultSortBy: "name",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: "name",
|
||||
},
|
||||
{
|
||||
Name: "ValidDefaultSortBy_group",
|
||||
DefaultSortBy: "group",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: "group",
|
||||
},
|
||||
{
|
||||
Name: "ValidDefaultSortBy_health",
|
||||
DefaultSortBy: "health",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: "health",
|
||||
},
|
||||
{
|
||||
Name: "InvalidDefaultSortBy",
|
||||
DefaultSortBy: "invalid",
|
||||
ExpectedError: ErrInvalidDefaultSortBy,
|
||||
ExpectedValue: "invalid",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg := &Config{DefaultSortBy: scenario.DefaultSortBy}
|
||||
err := cfg.ValidateAndSetDefaults()
|
||||
if !errors.Is(err, scenario.ExpectedError) {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
if cfg.DefaultSortBy != scenario.ExpectedValue {
|
||||
t.Errorf("expected DefaultSortBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultSortBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
DefaultFilterBy string
|
||||
ExpectedError error
|
||||
ExpectedValue string
|
||||
}{
|
||||
{
|
||||
Name: "EmptyDefaultFilterBy",
|
||||
DefaultFilterBy: "",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: defaultFilterBy,
|
||||
},
|
||||
{
|
||||
Name: "ValidDefaultFilterBy_none",
|
||||
DefaultFilterBy: "none",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: "none",
|
||||
},
|
||||
{
|
||||
Name: "ValidDefaultFilterBy_failing",
|
||||
DefaultFilterBy: "failing",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: "failing",
|
||||
},
|
||||
{
|
||||
Name: "ValidDefaultFilterBy_unstable",
|
||||
DefaultFilterBy: "unstable",
|
||||
ExpectedError: nil,
|
||||
ExpectedValue: "unstable",
|
||||
},
|
||||
{
|
||||
Name: "InvalidDefaultFilterBy",
|
||||
DefaultFilterBy: "invalid",
|
||||
ExpectedError: ErrInvalidDefaultFilterBy,
|
||||
ExpectedValue: "invalid",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}
|
||||
err := cfg.ValidateAndSetDefaults()
|
||||
if !errors.Is(err, scenario.ExpectedError) {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
if cfg.DefaultFilterBy != scenario.ExpectedValue {
|
||||
t.Errorf("expected DefaultFilterBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultFilterBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
16
config/util.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package config
|
||||
|
||||
// toPtr returns a pointer to the given value
|
||||
func toPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
// contains checks if a key exists in the slice
|
||||
func contains[T comparable](slice []T, key T) bool {
|
||||
for _, item := range slice {
|
||||
if item == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
96
go.mod
@@ -1,94 +1,88 @@
|
||||
module github.com/TwiN/gatus/v5
|
||||
|
||||
go 1.23.3
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.19.0
|
||||
code.gitea.io/sdk/gitea v0.21.0
|
||||
github.com/TwiN/deepmerge v0.2.2
|
||||
github.com/TwiN/g8/v2 v2.0.0
|
||||
github.com/TwiN/gocache/v2 v2.2.2
|
||||
github.com/TwiN/health v1.6.0
|
||||
github.com/TwiN/logr v0.3.1
|
||||
github.com/TwiN/whois v1.1.9
|
||||
github.com/aws/aws-sdk-go v1.55.5
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/gofiber/fiber/v2 v2.52.5
|
||||
github.com/TwiN/whois v1.1.11
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/gofiber/fiber/v2 v2.52.8
|
||||
github.com/google/go-github/v48 v48.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.62
|
||||
github.com/prometheus-community/pro-bing v0.4.0
|
||||
github.com/prometheus/client_golang v1.20.4
|
||||
github.com/valyala/fasthttp v1.56.0
|
||||
github.com/miekg/dns v1.1.67
|
||||
github.com/prometheus-community/pro-bing v0.6.1
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/valyala/fasthttp v1.64.0
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
google.golang.org/api v0.209.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
google.golang.org/api v0.242.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.2
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.10.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.2 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
cloud.google.com/go/auth v0.16.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.2 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // 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
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241113202542-65e8d215514f // indirect
|
||||
google.golang.org/grpc v1.67.1 // indirect
|
||||
google.golang.org/protobuf v1.35.2 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
320
go.sum
@@ -1,13 +1,13 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.10.2 h1:oKF7rgBfSHdp/kuhXtqU/tNDr0mZqhYbEh+6SiqzkKo=
|
||||
cloud.google.com/go/auth v0.10.2/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
|
||||
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
||||
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
|
||||
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
||||
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
|
||||
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
|
||||
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
|
||||
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
|
||||
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
|
||||
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
|
||||
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
|
||||
@@ -18,22 +18,18 @@ github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
|
||||
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
|
||||
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
|
||||
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
|
||||
github.com/TwiN/whois v1.1.9 h1:m20+m1CXnrstie+tW2ZmAJkfcT9zgwpVRUFsKeMw+ng=
|
||||
github.com/TwiN/whois v1.1.9/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
|
||||
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -41,75 +37,51 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
||||
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
||||
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
|
||||
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
|
||||
github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
|
||||
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||
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=
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -123,25 +95,24 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0=
|
||||
github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus-community/pro-bing v0.6.1 h1:EQukUOma9YFZRPe4DGSscxUf9LH07rpqwisNWjSZrgU=
|
||||
github.com/prometheus-community/pro-bing v0.6.1/go.mod h1:jNCOI3D7pmTCeaoF41cNS6uaxeFY/Gmc3ffwbuJVzAQ=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -149,37 +120,36 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
|
||||
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
|
||||
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
@@ -187,28 +157,21 @@ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
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-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
@@ -216,25 +179,20 @@ 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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -246,8 +204,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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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=
|
||||
@@ -256,8 +214,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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
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=
|
||||
@@ -267,51 +225,28 @@ 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
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=
|
||||
google.golang.org/api v0.209.0 h1:Ja2OXNlyRlWCWu8o+GgI4yUn/wz9h/5ZfFbKz+dQX+w=
|
||||
google.golang.org/api v0.209.0/go.mod h1:I53S168Yr/PNDNMi5yPnDc0/LGRZO6o7PoEbl/HY3CM=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241113202542-65e8d215514f h1:C1QccEa9kUwvMgEUORqQD9S17QesQijxjZ84sO82mfo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241113202542-65e8d215514f/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
|
||||
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -322,34 +257,31 @@ gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
|
||||
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
3
main.go
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/controller"
|
||||
"github.com/TwiN/gatus/v5/metrics"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
"github.com/TwiN/logr"
|
||||
@@ -49,6 +50,7 @@ func main() {
|
||||
|
||||
func start(cfg *config.Config) {
|
||||
go controller.Handle(cfg)
|
||||
metrics.InitializePrometheusMetrics(cfg, nil)
|
||||
watchdog.Monitor(cfg)
|
||||
go listenToConfigurationFileChanges(cfg)
|
||||
}
|
||||
@@ -56,6 +58,7 @@ func start(cfg *config.Config) {
|
||||
func stop(cfg *config.Config) {
|
||||
watchdog.Shutdown(cfg)
|
||||
controller.Shutdown()
|
||||
metrics.UnregisterPrometheusMetrics()
|
||||
}
|
||||
|
||||
func save() {
|
||||
|
||||
@@ -3,71 +3,146 @@ package metrics
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
const namespace = "gatus" // The prefix of the metrics
|
||||
|
||||
var (
|
||||
initializedMetrics bool // Whether the metrics have been initialized
|
||||
|
||||
resultTotal *prometheus.CounterVec
|
||||
resultDurationSeconds *prometheus.GaugeVec
|
||||
resultConnectedTotal *prometheus.CounterVec
|
||||
resultCodeTotal *prometheus.CounterVec
|
||||
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
||||
resultEndpointSuccess *prometheus.GaugeVec
|
||||
|
||||
// Track if metrics have been initialized to prevent duplicate registration
|
||||
metricsInitialized bool
|
||||
currentRegisterer prometheus.Registerer
|
||||
)
|
||||
|
||||
func initializePrometheusMetrics() {
|
||||
resultTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
// UnregisterPrometheusMetrics unregisters all previously registered metrics
|
||||
func UnregisterPrometheusMetrics() {
|
||||
if !metricsInitialized || currentRegisterer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Unregister all metrics if they exist
|
||||
if resultTotal != nil {
|
||||
currentRegisterer.Unregister(resultTotal)
|
||||
}
|
||||
if resultDurationSeconds != nil {
|
||||
currentRegisterer.Unregister(resultDurationSeconds)
|
||||
}
|
||||
if resultConnectedTotal != nil {
|
||||
currentRegisterer.Unregister(resultConnectedTotal)
|
||||
}
|
||||
if resultCodeTotal != nil {
|
||||
currentRegisterer.Unregister(resultCodeTotal)
|
||||
}
|
||||
if resultCertificateExpirationSeconds != nil {
|
||||
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
|
||||
}
|
||||
if resultEndpointSuccess != nil {
|
||||
currentRegisterer.Unregister(resultEndpointSuccess)
|
||||
}
|
||||
|
||||
metricsInitialized = false
|
||||
currentRegisterer = nil
|
||||
}
|
||||
|
||||
func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer) {
|
||||
// If metrics are already initialized, unregister them first
|
||||
if metricsInitialized {
|
||||
UnregisterPrometheusMetrics()
|
||||
}
|
||||
|
||||
if reg == nil {
|
||||
reg = prometheus.DefaultRegisterer
|
||||
}
|
||||
|
||||
// Store the registerer for later unregistration
|
||||
currentRegisterer = reg
|
||||
|
||||
extraLabels := cfg.GetUniqueExtraMetricLabels()
|
||||
resultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: namespace,
|
||||
Name: "results_total",
|
||||
Help: "Number of results per endpoint",
|
||||
}, []string{"key", "group", "name", "type", "success"})
|
||||
resultDurationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
}, append([]string{"key", "group", "name", "type", "success"}, extraLabels...))
|
||||
reg.MustRegister(resultTotal)
|
||||
|
||||
resultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "results_duration_seconds",
|
||||
Help: "Duration of the request in seconds",
|
||||
}, []string{"key", "group", "name", "type"})
|
||||
resultConnectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||
reg.MustRegister(resultDurationSeconds)
|
||||
|
||||
resultConnectedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: namespace,
|
||||
Name: "results_connected_total",
|
||||
Help: "Total number of results in which a connection was successfully established",
|
||||
}, []string{"key", "group", "name", "type"})
|
||||
resultCodeTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||
reg.MustRegister(resultConnectedTotal)
|
||||
|
||||
resultCodeTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: namespace,
|
||||
Name: "results_code_total",
|
||||
Help: "Total number of results by code",
|
||||
}, []string{"key", "group", "name", "type", "code"})
|
||||
resultCertificateExpirationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
}, append([]string{"key", "group", "name", "type", "code"}, extraLabels...))
|
||||
reg.MustRegister(resultCodeTotal)
|
||||
|
||||
resultCertificateExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "results_certificate_expiration_seconds",
|
||||
Help: "Number of seconds until the certificate expires",
|
||||
}, []string{"key", "group", "name", "type"})
|
||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||
reg.MustRegister(resultCertificateExpirationSeconds)
|
||||
|
||||
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "results_endpoint_success",
|
||||
Help: "Displays whether or not the endpoint was a success",
|
||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||
reg.MustRegister(resultEndpointSuccess)
|
||||
|
||||
// Mark as initialized
|
||||
metricsInitialized = true
|
||||
}
|
||||
|
||||
// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
|
||||
// These metrics will be exposed at /metrics if the metrics are enabled
|
||||
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result) {
|
||||
if !initializedMetrics {
|
||||
initializePrometheusMetrics()
|
||||
initializedMetrics = true
|
||||
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, extraLabels []string) {
|
||||
labelValues := []string{}
|
||||
for _, label := range extraLabels {
|
||||
if value, ok := ep.ExtraLabels[label]; ok {
|
||||
labelValues = append(labelValues, value)
|
||||
} else {
|
||||
labelValues = append(labelValues, "")
|
||||
}
|
||||
}
|
||||
|
||||
endpointType := ep.Type()
|
||||
resultTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc()
|
||||
resultDurationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.Duration.Seconds())
|
||||
resultTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)}, labelValues...)...).Inc()
|
||||
resultDurationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.Duration.Seconds())
|
||||
if result.Connected {
|
||||
resultConnectedTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Inc()
|
||||
resultConnectedTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Inc()
|
||||
}
|
||||
if result.DNSRCode != "" {
|
||||
resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode).Inc()
|
||||
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode}, labelValues...)...).Inc()
|
||||
}
|
||||
if result.HTTPStatus != 0 {
|
||||
resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc()
|
||||
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)}, labelValues...)...).Inc()
|
||||
}
|
||||
if result.CertificateExpiration != 0 {
|
||||
resultCertificateExpirationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds())
|
||||
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
|
||||
}
|
||||
if result.Success {
|
||||
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
|
||||
} else {
|
||||
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,112 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
)
|
||||
|
||||
// TestInitializePrometheusMetrics tests metrics initialization with extraLabels.
|
||||
// Note: Because of the global Prometheus registry, this test can only safely verify one label set per process.
|
||||
// If the function is called with a different set of labels for the same metric, a panic will occur.
|
||||
func TestInitializePrometheusMetrics(t *testing.T) {
|
||||
cfgWithExtras := &config.Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "TestEP",
|
||||
Group: "G",
|
||||
URL: "http://x/",
|
||||
ExtraLabels: map[string]string{
|
||||
"foo": "foo-val",
|
||||
"hello": "world-val",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg := prometheus.NewRegistry()
|
||||
InitializePrometheusMetrics(cfgWithExtras, reg)
|
||||
// Metrics variables should be non-nil
|
||||
if resultTotal == nil {
|
||||
t.Error("resultTotal metric not initialized")
|
||||
}
|
||||
if resultDurationSeconds == nil {
|
||||
t.Error("resultDurationSeconds metric not initialized")
|
||||
}
|
||||
if resultConnectedTotal == nil {
|
||||
t.Error("resultConnectedTotal metric not initialized")
|
||||
}
|
||||
if resultCodeTotal == nil {
|
||||
t.Error("resultCodeTotal metric not initialized")
|
||||
}
|
||||
if resultCertificateExpirationSeconds == nil {
|
||||
t.Error("resultCertificateExpirationSeconds metric not initialized")
|
||||
}
|
||||
if resultEndpointSuccess == nil {
|
||||
t.Error("resultEndpointSuccess metric not initialized")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("resultTotal.WithLabelValues panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
_ = resultTotal.WithLabelValues("k", "g", "n", "ty", "true", "fval", "hval")
|
||||
}
|
||||
|
||||
// TestPublishMetricsForEndpoint_withExtraLabels ensures extraLabels are included in the exported metrics.
|
||||
func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
|
||||
// Only test one label set per process due to Prometheus registry limits.
|
||||
reg := prometheus.NewRegistry()
|
||||
cfg := &config.Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "ep-extra",
|
||||
URL: "https://sample.com",
|
||||
ExtraLabels: map[string]string{
|
||||
"foo": "my-foo",
|
||||
"bar": "my-bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
InitializePrometheusMetrics(cfg, reg)
|
||||
|
||||
ep := &endpoint.Endpoint{
|
||||
Name: "ep-extra",
|
||||
Group: "g1",
|
||||
URL: "https://sample.com",
|
||||
ExtraLabels: map[string]string{
|
||||
"foo": "my-foo",
|
||||
"bar": "my-bar",
|
||||
},
|
||||
}
|
||||
result := &endpoint.Result{
|
||||
HTTPStatus: 200,
|
||||
Connected: true,
|
||||
Duration: 2340 * time.Millisecond,
|
||||
Success: true,
|
||||
}
|
||||
// Get labels in sorted order as per GetUniqueExtraMetricLabels
|
||||
extraLabels := cfg.GetUniqueExtraMetricLabels()
|
||||
PublishMetricsForEndpoint(ep, result, extraLabels)
|
||||
|
||||
expected := `
|
||||
# HELP gatus_results_total Number of results per endpoint
|
||||
# TYPE gatus_results_total counter
|
||||
gatus_results_total{bar="my-bar",foo="my-foo",group="g1",key="g1_ep-extra",name="ep-extra",success="true",type="HTTP"} 1
|
||||
`
|
||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expected), "gatus_results_total")
|
||||
if err != nil {
|
||||
t.Error("metrics export does not include extraLabels as expected:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMetricsForEndpoint(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
InitializePrometheusMetrics(&config.Config{}, reg)
|
||||
|
||||
httpEndpoint := &endpoint.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"}
|
||||
PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{
|
||||
HTTPStatus: 200,
|
||||
@@ -23,11 +122,8 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
|
||||
},
|
||||
Success: true,
|
||||
CertificateExpiration: 49 * time.Hour,
|
||||
})
|
||||
err := testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
|
||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
|
||||
}, []string{})
|
||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# HELP gatus_results_code_total Total number of results by code
|
||||
# TYPE gatus_results_code_total counter
|
||||
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
|
||||
@@ -40,7 +136,13 @@ gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-
|
||||
# HELP gatus_results_total Number of results per endpoint
|
||||
# TYPE gatus_results_total counter
|
||||
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
|
||||
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds")
|
||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
|
||||
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||
# TYPE gatus_results_endpoint_success gauge
|
||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
|
||||
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds", "gatus_results_endpoint_success")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
@@ -54,11 +156,8 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
|
||||
},
|
||||
Success: false,
|
||||
CertificateExpiration: 47 * time.Hour,
|
||||
})
|
||||
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
|
||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
|
||||
}, []string{})
|
||||
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# HELP gatus_results_code_total Total number of results by code
|
||||
# TYPE gatus_results_code_total counter
|
||||
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
|
||||
@@ -72,14 +171,22 @@ gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-
|
||||
# TYPE gatus_results_total counter
|
||||
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="false",type="HTTP"} 1
|
||||
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
|
||||
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds")
|
||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
|
||||
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||
# TYPE gatus_results_endpoint_success gauge
|
||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
|
||||
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds", "gatus_results_endpoint_success")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
dnsEndpoint := &endpoint.Endpoint{Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com.",
|
||||
}}
|
||||
dnsEndpoint := &endpoint.Endpoint{
|
||||
Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com.",
|
||||
},
|
||||
}
|
||||
PublishMetricsForEndpoint(dnsEndpoint, &endpoint.Result{
|
||||
DNSRCode: "NOERROR",
|
||||
Connected: true,
|
||||
@@ -88,11 +195,8 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
|
||||
{Condition: "[DNS_RCODE] == NOERROR", Success: true},
|
||||
},
|
||||
Success: true,
|
||||
})
|
||||
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
|
||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
|
||||
}, []string{})
|
||||
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# HELP gatus_results_code_total Total number of results by code
|
||||
# TYPE gatus_results_code_total counter
|
||||
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
|
||||
@@ -110,7 +214,14 @@ gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-
|
||||
gatus_results_total{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",success="true",type="DNS"} 1
|
||||
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="false",type="HTTP"} 1
|
||||
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
|
||||
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds")
|
||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
|
||||
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||
# TYPE gatus_results_endpoint_success gauge
|
||||
gatus_results_endpoint_success{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 1
|
||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
|
||||
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds", "gatus_results_endpoint_success")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaximumNumberOfResults = 100
|
||||
DefaultMaximumNumberOfEvents = 50
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSQLStorageRequiresPath = errors.New("sql storage requires a non-empty path to be defined")
|
||||
ErrMemoryStorageDoesNotSupportPath = errors.New("memory storage does not support persistence, use sqlite if you want persistence on file")
|
||||
@@ -25,6 +30,12 @@ type Config struct {
|
||||
// as they happen, also known as the write-through caching strategy.
|
||||
// Does not apply if Config.Type is not TypePostgres or TypeSQLite.
|
||||
Caching bool `yaml:"caching,omitempty"`
|
||||
|
||||
// MaximumNumberOfResults is the number of results each endpoint should be able to provide
|
||||
MaximumNumberOfResults int `yaml:"maximum-number-of-results,omitempty"`
|
||||
|
||||
// MaximumNumberOfEvents is the number of events each endpoint should be able to provide
|
||||
MaximumNumberOfEvents int `yaml:"maximum-number-of-events,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the configuration and sets the default values (if applicable)
|
||||
@@ -38,5 +49,11 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.Type == TypeMemory && len(c.Path) > 0 {
|
||||
return ErrMemoryStorageDoesNotSupportPath
|
||||
}
|
||||
if c.MaximumNumberOfResults <= 0 {
|
||||
c.MaximumNumberOfResults = DefaultMaximumNumberOfResults
|
||||
}
|
||||
if c.MaximumNumberOfEvents <= 0 {
|
||||
c.MaximumNumberOfEvents = DefaultMaximumNumberOfEvents
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package common
|
||||
|
||||
const (
|
||||
// MaximumNumberOfResults is the maximum number of results that an endpoint can have
|
||||
MaximumNumberOfResults = 100
|
||||
|
||||
// MaximumNumberOfEvents is the maximum number of events that an endpoint can have
|
||||
MaximumNumberOfEvents = 50
|
||||
)
|
||||
@@ -17,15 +17,20 @@ type Store struct {
|
||||
sync.RWMutex
|
||||
|
||||
cache *gocache.Cache
|
||||
|
||||
maximumNumberOfResults int // maximum number of results that an endpoint can have
|
||||
maximumNumberOfEvents int // maximum number of events that an endpoint can have
|
||||
}
|
||||
|
||||
// NewStore creates a new store using gocache.Cache
|
||||
//
|
||||
// This store holds everything in memory, and if the file parameter is not blank,
|
||||
// supports eventual persistence.
|
||||
func NewStore() (*Store, error) {
|
||||
func NewStore(maximumNumberOfResults, maximumNumberOfEvents int) (*Store, error) {
|
||||
store := &Store{
|
||||
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
|
||||
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
|
||||
maximumNumberOfResults: maximumNumberOfResults,
|
||||
maximumNumberOfEvents: maximumNumberOfEvents,
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
@@ -151,7 +156,7 @@ func (s *Store) Insert(ep *endpoint.Endpoint, result *endpoint.Result) error {
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
AddResult(status.(*endpoint.Status), result)
|
||||
AddResult(status.(*endpoint.Status), result, s.maximumNumberOfResults, s.maximumNumberOfEvents)
|
||||
s.cache.Set(key, status)
|
||||
s.Unlock()
|
||||
return nil
|
||||
@@ -206,6 +211,23 @@ func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.En
|
||||
return 0
|
||||
}
|
||||
|
||||
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
|
||||
func (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
endpointStatus := s.cache.GetValue(key)
|
||||
if endpointStatus == nil {
|
||||
// If no endpoint exists, there's no newer status, so return false instead of an error
|
||||
return false, nil
|
||||
}
|
||||
for _, result := range endpointStatus.(*endpoint.Status).Results {
|
||||
if result.Timestamp.After(timestamp) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Clear deletes everything from the store
|
||||
func (s *Store) Clear() {
|
||||
s.cache.Clear()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
)
|
||||
|
||||
@@ -82,7 +83,8 @@ var (
|
||||
// Note that are much more extensive tests in /storage/store/store_test.go.
|
||||
// This test is simply an extra sanity check
|
||||
func TestStore_SanityCheck(t *testing.T) {
|
||||
store, _ := NewStore()
|
||||
store, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
defer store.Clear()
|
||||
defer store.Close()
|
||||
store.Insert(&testEndpoint, &testSuccessfulResult)
|
||||
endpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
|
||||
@@ -122,7 +124,7 @@ func TestStore_SanityCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStore_Save(t *testing.T) {
|
||||
store, err := NewStore()
|
||||
store, err := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
if err != nil {
|
||||
t.Fatal("expected no error, got", err.Error())
|
||||
}
|
||||
@@ -133,3 +135,30 @@ func TestStore_Save(t *testing.T) {
|
||||
store.Clear()
|
||||
store.Close()
|
||||
}
|
||||
|
||||
func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
|
||||
store, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
defer store.Clear()
|
||||
defer store.Close()
|
||||
// Insert a result
|
||||
err := store.Insert(&testEndpoint, &testSuccessfulResult)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while inserting result, got %v", err)
|
||||
}
|
||||
// Check with a timestamp in the past
|
||||
hasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !hasNewerStatus {
|
||||
t.Fatal("expected to have a newer status, but didn't")
|
||||
}
|
||||
// Check with a timestamp in the future
|
||||
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if hasNewerStatus {
|
||||
t.Fatal("expected not to have a newer status, but did")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
)
|
||||
|
||||
func TestProcessUptimeAfterResult(t *testing.T) {
|
||||
@@ -50,7 +51,7 @@ func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
|
||||
// Start 12 days ago
|
||||
timestamp := now.Add(-12 * 24 * time.Hour)
|
||||
for timestamp.Unix() <= now.Unix() {
|
||||
AddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true})
|
||||
AddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
if len(status.Uptime.HourlyStatistics) > uptimeCleanUpThreshold {
|
||||
t.Errorf("At no point in time should there be more than %d entries in status.SuccessfulExecutionsPerHour, but there are %d", uptimeCleanUpThreshold, len(status.Uptime.HourlyStatistics))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package memory
|
||||
|
||||
import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
)
|
||||
|
||||
@@ -51,7 +50,7 @@ func getStartAndEndIndex(numberOfResults int, page, pageSize int) (int, int) {
|
||||
|
||||
// AddResult adds a Result to Status.Results and makes sure that there are
|
||||
// no more than MaximumNumberOfResults results in the Results slice
|
||||
func AddResult(ss *endpoint.Status, result *endpoint.Result) {
|
||||
func AddResult(ss *endpoint.Status, result *endpoint.Result, maximumNumberOfResults, maximumNumberOfEvents int) {
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
@@ -59,11 +58,11 @@ func AddResult(ss *endpoint.Status, result *endpoint.Result) {
|
||||
// Check if there's any change since the last result
|
||||
if ss.Results[len(ss.Results)-1].Success != result.Success {
|
||||
ss.Events = append(ss.Events, endpoint.NewEventFromResult(result))
|
||||
if len(ss.Events) > common.MaximumNumberOfEvents {
|
||||
if len(ss.Events) > maximumNumberOfEvents {
|
||||
// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has
|
||||
// more than one extra element, we can get rid of all of them at once and thus returning the slice to a
|
||||
// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead
|
||||
ss.Events = ss.Events[len(ss.Events)-common.MaximumNumberOfEvents:]
|
||||
ss.Events = ss.Events[len(ss.Events)-maximumNumberOfEvents:]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -71,11 +70,11 @@ func AddResult(ss *endpoint.Status, result *endpoint.Result) {
|
||||
ss.Events = append(ss.Events, endpoint.NewEventFromResult(result))
|
||||
}
|
||||
ss.Results = append(ss.Results, result)
|
||||
if len(ss.Results) > common.MaximumNumberOfResults {
|
||||
if len(ss.Results) > maximumNumberOfResults {
|
||||
// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more
|
||||
// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of
|
||||
// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead
|
||||
ss.Results = ss.Results[len(ss.Results)-common.MaximumNumberOfResults:]
|
||||
ss.Results = ss.Results[len(ss.Results)-maximumNumberOfResults:]
|
||||
}
|
||||
processUptimeAfterResult(ss.Uptime, result)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
)
|
||||
|
||||
func BenchmarkShallowCopyEndpointStatus(b *testing.B) {
|
||||
ep := &testEndpoint
|
||||
status := endpoint.NewStatus(ep.Group, ep.Name)
|
||||
for i := 0; i < common.MaximumNumberOfResults; i++ {
|
||||
AddResult(status, &testSuccessfulResult)
|
||||
for i := 0; i < storage.DefaultMaximumNumberOfResults; i++ {
|
||||
AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
}
|
||||
for n := 0; n < b.N; n++ {
|
||||
ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20))
|
||||
|
||||
@@ -5,24 +5,24 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
)
|
||||
|
||||
func TestAddResult(t *testing.T) {
|
||||
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
|
||||
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
|
||||
for i := 0; i < (common.MaximumNumberOfResults+common.MaximumNumberOfEvents)*2; i++ {
|
||||
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()})
|
||||
for i := 0; i < (storage.DefaultMaximumNumberOfResults+storage.DefaultMaximumNumberOfEvents)*2; i++ {
|
||||
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
}
|
||||
if len(endpointStatus.Results) != common.MaximumNumberOfResults {
|
||||
t.Errorf("expected endpointStatus.Results to not exceed a length of %d", common.MaximumNumberOfResults)
|
||||
if len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults {
|
||||
t.Errorf("expected endpointStatus.Results to not exceed a length of %d", storage.DefaultMaximumNumberOfResults)
|
||||
}
|
||||
if len(endpointStatus.Events) != common.MaximumNumberOfEvents {
|
||||
t.Errorf("expected endpointStatus.Events to not exceed a length of %d", common.MaximumNumberOfEvents)
|
||||
if len(endpointStatus.Events) != storage.DefaultMaximumNumberOfEvents {
|
||||
t.Errorf("expected endpointStatus.Events to not exceed a length of %d", storage.DefaultMaximumNumberOfEvents)
|
||||
}
|
||||
// Try to add nil endpointStatus
|
||||
AddResult(nil, &endpoint.Result{Timestamp: time.Now()})
|
||||
AddResult(nil, &endpoint.Result{Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
}
|
||||
|
||||
func TestShallowCopyEndpointStatus(t *testing.T) {
|
||||
@@ -30,7 +30,7 @@ func TestShallowCopyEndpointStatus(t *testing.T) {
|
||||
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
|
||||
ts := time.Now().Add(-25 * time.Hour)
|
||||
for i := 0; i < 25; i++ {
|
||||
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts})
|
||||
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||
ts = ts.Add(time.Hour)
|
||||
}
|
||||
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(-1, -1)).Results) != 0 {
|
||||
|
||||
@@ -79,6 +79,25 @@ func (s *Store) createSQLiteSchema() error {
|
||||
UNIQUE(endpoint_id, configuration_checksum)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Create indices for performance reasons
|
||||
_, err = s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS endpoint_results_endpoint_id_idx ON endpoint_results (endpoint_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS endpoint_uptimes_endpoint_id_idx ON endpoint_uptimes (endpoint_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS endpoint_result_conditions_endpoint_result_id_idx ON endpoint_result_conditions (endpoint_result_id);
|
||||
`)
|
||||
// Silent table modifications TODO: Remove this in v6.0.0
|
||||
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD domain_expiration INTEGER NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
|
||||