mirror of
https://github.com/TwiN/gatus.git
synced 2026-08-22 15:16:04 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c7fbe82da | ||
|
|
67bf6c16b6 | ||
|
|
d7e0843a39 | ||
|
|
c94c9fa36a | ||
|
|
9587e5db8a | ||
|
|
2d5f0a5927 | ||
|
|
6c8761ca35 | ||
|
|
40b1576ec7 | ||
|
|
64c3b12a7b | ||
|
|
138f5bfb76 | ||
|
|
15a8055617 | ||
|
|
13184232d1 | ||
|
|
d0cca91043 | ||
|
|
239d1f5118 | ||
|
|
47bc78dc25 | ||
|
|
1df0801a61 | ||
|
|
d42c5f899e | ||
|
|
5f4c26e5fe | ||
|
|
2beaca5700 | ||
|
|
a55eb7da40 | ||
|
|
b0629773e5 | ||
|
|
c5f7e5b82b | ||
|
|
3c71980d79 | ||
|
|
a2a7e1f14a | ||
|
|
1e4c440f01 | ||
|
|
844847bb05 | ||
|
|
0c3231713f | ||
|
|
ee01adb603 | ||
|
|
9121d87965 | ||
|
|
86cd1a9eb2 | ||
|
|
9f960fdd27 | ||
|
|
6f9db4107c | ||
|
|
5d626f2934 | ||
|
|
75c1b290f6 | ||
|
|
fe7b74f555 | ||
|
|
ed4c270a25 | ||
|
|
379ec2983d | ||
|
|
907716289c | ||
|
|
7c6b5539c1 | ||
|
|
607f3c5549 | ||
|
|
9e97efaba1 | ||
|
|
8912b4b3e3 | ||
|
|
5fdc489113 | ||
|
|
2ebb74ae1e | ||
|
|
e2f06e9ede | ||
|
|
beb9a2f3d9 | ||
|
|
e469b6adf4 |
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:stable
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -5,6 +5,15 @@ on:
|
||||
inputs:
|
||||
tag:
|
||||
description: Custom tag to publish
|
||||
platforms:
|
||||
description: Platforms to publish to (comma separated list)
|
||||
default: linux/amd64
|
||||
type: choice
|
||||
options:
|
||||
- linux/amd64
|
||||
- linux/arm/v7
|
||||
- linux/arm64
|
||||
|
||||
jobs:
|
||||
publish-custom:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,7 +42,7 @@ jobs:
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ inputs.platforms }}
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: regenerate-static-assets
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
check-command:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request }}
|
||||
permissions:
|
||||
pull-requests: write # required for adding reactions to command comments on PRs
|
||||
checks: read # required to check if all ci checks have passed
|
||||
outputs:
|
||||
continue: ${{ steps.command.outputs.continue }}
|
||||
steps:
|
||||
- name: Check command trigger
|
||||
id: command
|
||||
uses: github/command@v2
|
||||
with:
|
||||
command: "/regenerate-static-assets"
|
||||
permissions: "write,admin" # The allowed permission levels to invoke this command
|
||||
allow_forks: true
|
||||
allow_drafts: true
|
||||
skip_ci: true
|
||||
skip_completing: true
|
||||
|
||||
regenerate-static-assets:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-command
|
||||
if: ${{ needs.check-command.outputs.continue == 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
status: ${{ steps.commit.outputs.status }}
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
id: pr
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
core.setOutput('ref', pr.data.head.ref);
|
||||
core.setOutput('repo', pr.data.head.repo.full_name);
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
ref: ${{ steps.pr.outputs.ref }}
|
||||
- name: Regenerate static assets
|
||||
run: |
|
||||
make frontend-install-dependencies
|
||||
make frontend-build
|
||||
- name: Commit and push changes
|
||||
id: commit
|
||||
run: |
|
||||
echo "Checking for changes..."
|
||||
if git diff --quiet; then
|
||||
echo "No changes detected."
|
||||
echo "status=no_changes" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
echo "Changes detected. Committing and pushing..."
|
||||
git add .
|
||||
git commit -m "chore(ui): Regenerate static assets"
|
||||
git push origin ${{ steps.pr.outputs.ref }}
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
|
||||
create-response-comment:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-command, regenerate-static-assets]
|
||||
if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Create response comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const status = '${{ needs.regenerate-static-assets.outputs.status }}';
|
||||
let reaction = '';
|
||||
if (status === 'success') {
|
||||
reaction = 'hooray';
|
||||
} else if (status === 'no_changes') {
|
||||
reaction = '+1';
|
||||
} else {
|
||||
reaction = '-1';
|
||||
var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
|
||||
var body = '⚠️ There was an issue regenerating static assets. Please check the [workflow run logs](' + workflowUrl + ') for more details.';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: reaction
|
||||
});
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5.5.1
|
||||
uses: codecov/codecov-action@v5.5.2
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
|
||||
RUN apk --update add ca-certificates
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN go mod tidy
|
||||
RUN go mod tidy -diff
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||
|
||||
# Run Tests inside docker image if you don't have a configured go environment
|
||||
|
||||
@@ -50,12 +50,15 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Conditions](#conditions)
|
||||
- [Placeholders](#placeholders)
|
||||
- [Functions](#functions)
|
||||
- [Web](#web)
|
||||
- [UI](#ui)
|
||||
- [Announcements](#announcements)
|
||||
- [Storage](#storage)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Tunneling](#tunneling)
|
||||
- [Alerting](#alerting)
|
||||
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
|
||||
- [Configuring ClickUp alerts](#configuring-clickup-alerts)
|
||||
- [Configuring Datadog alerts](#configuring-datadog-alerts)
|
||||
- [Configuring Discord alerts](#configuring-discord-alerts)
|
||||
- [Configuring Email alerts](#configuring-email-alerts)
|
||||
@@ -109,6 +112,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Docker](#docker)
|
||||
- [Helm Chart](#helm-chart)
|
||||
- [Terraform](#terraform)
|
||||
- [Kubernetes](#kubernetes)
|
||||
- [Running the tests](#running-the-tests)
|
||||
- [Using in Production](#using-in-production)
|
||||
- [FAQ](#faq)
|
||||
@@ -119,6 +123,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
|
||||
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
|
||||
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)
|
||||
- [Monitoring an endpoint using gRPC](#monitoring-an-endpoint-using-grpc)
|
||||
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
|
||||
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
|
||||
@@ -131,6 +136,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)
|
||||
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
|
||||
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||
- [Use environment variables in config files](#use-environment-variables-in-config-files)
|
||||
- [Configuring a startup delay](#configuring-a-startup-delay)
|
||||
- [Keeping your configuration small](#keeping-your-configuration-small)
|
||||
- [Proxy client configuration](#proxy-client-configuration)
|
||||
@@ -219,6 +225,9 @@ This example would look similar to this:
|
||||
|
||||

|
||||
|
||||
If you want to test it locally, see [Docker](#docker).
|
||||
|
||||
## Configuration
|
||||
By default, the configuration file is expected to be at `config/config.yaml`.
|
||||
|
||||
You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.
|
||||
@@ -232,46 +241,29 @@ subdirectories are merged like so:
|
||||
|
||||
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
|
||||
>
|
||||
> See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example.
|
||||
> ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
|
||||
>
|
||||
> When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
|
||||
> See [Use environment variables in config files](#use-environment-variables-in-config-files) or [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for examples.
|
||||
|
||||
If you want to test it locally, see [Docker](#docker).
|
||||
|
||||
|
||||
## Configuration
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `announcements` | [Announcements configuration](#announcements). | `[]` |
|
||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `ui.custom-css` | Custom CSS | `""` |
|
||||
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
||||
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
||||
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `announcements` | [Announcements configuration](#announcements). | `[]` |
|
||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | [Web configuration](#web). | `{}` |
|
||||
| `ui` | [UI configuration](#ui). | `{}` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
|
||||
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
||||
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
|
||||
@@ -370,14 +362,14 @@ Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||
- Using the example configuration above, the key would be `core_ext-ep-test`.
|
||||
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
|
||||
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful.
|
||||
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
|
||||
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, this will be ignored.
|
||||
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
|
||||
|
||||
You must also pass the token as a `Bearer` token in the `Authorization` header.
|
||||
|
||||
|
||||
### Suites (ALPHA)
|
||||
Suites are collections of endpoints that are executed sequentially with a shared context.
|
||||
Suites are collections of endpoints that are executed sequentially with a shared context.
|
||||
This allows you to create complex monitoring scenarios where the result from one endpoint can be used in subsequent endpoints, enabling workflow-style monitoring.
|
||||
|
||||
Here are a few cases in which suites could be useful:
|
||||
@@ -419,7 +411,7 @@ suites:
|
||||
context:
|
||||
price: "19.99" # Initial static value in context
|
||||
endpoints:
|
||||
# Step 1: Create an item and store the item ID
|
||||
# Step 1: Create an item and store the item ID
|
||||
- name: create-item
|
||||
url: https://api.example.com/items
|
||||
method: POST
|
||||
@@ -433,7 +425,7 @@ suites:
|
||||
alerts:
|
||||
- type: slack
|
||||
description: "Failed to create item"
|
||||
|
||||
|
||||
# Step 2: Update the item using the stored item ID
|
||||
- name: update-item
|
||||
url: https://api.example.com/items/[CONTEXT].itemId
|
||||
@@ -444,7 +436,7 @@ suites:
|
||||
alerts:
|
||||
- type: slack
|
||||
description: "Failed to update item"
|
||||
|
||||
|
||||
# Step 3: Fetch the item and validate the price
|
||||
- name: get-item
|
||||
url: https://api.example.com/items/[CONTEXT].itemId
|
||||
@@ -455,7 +447,7 @@ suites:
|
||||
alerts:
|
||||
- type: slack
|
||||
description: "Item price did not update correctly"
|
||||
|
||||
|
||||
# Step 4: Delete the item (always-run: true to ensure cleanup even if step 2 or 3 fails)
|
||||
- name: delete-item
|
||||
url: https://api.example.com/items/[CONTEXT].itemId
|
||||
@@ -521,20 +513,55 @@ Here are some examples of conditions you can use:
|
||||
|
||||
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||
|
||||
### Web
|
||||
Allows you to configure how and where the dashboard is being served.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------|:--------------------------------------------------------------------------------------------|:----------|
|
||||
| `web` | Web configuration | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
||||
|
||||
### UI
|
||||
Allows you to configure the application wide defaults for the dashboard's UI. Some of these parameters can be overridden locally by users using the local storage of their browser.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|
|
||||
| `ui` | UI configuration | `{}` |
|
||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||
| `ui.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` |
|
||||
| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` |
|
||||
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `ui.custom-css` | Custom CSS | `""` |
|
||||
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
||||
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
||||
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
||||
|
||||
### Announcements
|
||||
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
|
||||
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information. You can use markdown to format your announcements.
|
||||
|
||||
| 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 |
|
||||
This is essentially what some status page calls "incident communications".
|
||||
|
||||
| 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 |
|
||||
| `announcements[].archived` | Whether to archive the announcement. Archived announcements show at the bottom of the status page instead of at the top. | `false` |
|
||||
|
||||
Types:
|
||||
- **outage**: Indicates service disruptions or critical issues (red theme)
|
||||
- **warning**: Indicates potential issues or important notices (yellow 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)
|
||||
@@ -542,17 +569,24 @@ Types:
|
||||
Example Configuration:
|
||||
```yaml
|
||||
announcements:
|
||||
- timestamp: 2025-08-15T14:00:00Z
|
||||
- timestamp: 2025-11-07T14: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
|
||||
- timestamp: 2025-11-07T16:15:00Z
|
||||
type: operational
|
||||
message: "Database maintenance completed successfully. All systems operational."
|
||||
- timestamp: 2025-08-15T12:00:00Z
|
||||
- timestamp: 2025-11-07T12:00:00Z
|
||||
type: information
|
||||
message: "New monitoring dashboard features will be deployed next week"
|
||||
- timestamp: 2025-11-06T09:00:00Z
|
||||
type: warning
|
||||
message: "Elevated API response times observed for US customers"
|
||||
archived: true
|
||||
```
|
||||
|
||||
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:
|
||||

|
||||
|
||||
|
||||
### Storage
|
||||
| Parameter | Description | Default |
|
||||
@@ -707,7 +741,7 @@ endpoints:
|
||||
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
|
||||
|
||||
### Tunneling
|
||||
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
|
||||
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
|
||||
This is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.
|
||||
|
||||
SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
|
||||
@@ -744,7 +778,7 @@ endpoints:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
|
||||
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
|
||||
> This may lead to inaccurate response time measurements.
|
||||
|
||||
|
||||
@@ -798,6 +832,7 @@ endpoints:
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
|
||||
| `alerting.clickup` | Configuration for alerts of type `clickup`. <br />See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
| `alerting.datadog` | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
@@ -849,6 +884,9 @@ endpoints:
|
||||
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
|
||||
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
|
||||
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.aws-ses.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.aws-ses.overrides[].*` | See `alerting.aws-ses.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -879,6 +917,72 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b
|
||||
Make sure you have the ability to use `ses:SendEmail`.
|
||||
|
||||
|
||||
#### Configuring ClickUp alerts
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ |
|
||||
| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` |
|
||||
| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` |
|
||||
| `alerting.clickup.token` | ClickUp API token | Required `""` |
|
||||
| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` |
|
||||
| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` |
|
||||
| `alerting.clickup.status` | Initial status for created tasks | `""` |
|
||||
| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` |
|
||||
| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` |
|
||||
| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` |
|
||||
| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` |
|
||||
| `alerting.clickup.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` |
|
||||
|
||||
The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved.
|
||||
|
||||
The following placeholders are supported in `name` and `content`:
|
||||
|
||||
- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group`
|
||||
- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name`
|
||||
- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description`
|
||||
- `[RESULT_ERRORS]` - Resolved from the health evaluation errors
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
clickup:
|
||||
list-id: "123456789"
|
||||
token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
assignees:
|
||||
- "12345"
|
||||
- "67890"
|
||||
status: "in progress"
|
||||
priority: high
|
||||
name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]"
|
||||
content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: clickup
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to)
|
||||
|
||||
To find your List ID:
|
||||
|
||||
1. Open the ClickUp list where you want tasks to be created
|
||||
2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}`
|
||||
|
||||
To find Assignee IDs:
|
||||
|
||||
1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people`
|
||||
2. Hover over a team member
|
||||
3. Click the 3 dots (overflow menu)
|
||||
3. Click `Copy member ID`
|
||||
|
||||
#### Configuring Datadog alerts
|
||||
|
||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||
@@ -1994,8 +2098,6 @@ Here's an example of what the notifications look like:
|
||||
|
||||
#### Configuring Splunk alerts
|
||||
|
||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
|
||||
| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
|
||||
@@ -2179,8 +2281,8 @@ 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.id` | Telegram Chat ID | Required `""` |
|
||||
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
|
||||
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
|
||||
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
@@ -2391,15 +2493,18 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring custom alerts
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
| `alerting.custom.body` | Custom alerting request body. | `""` |
|
||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
| `alerting.custom.body` | Custom alerting request body. | `""` |
|
||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.custom.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.custom.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.custom.overrides[].*` | See `alerting.custom.*` parameters | `{}` |
|
||||
|
||||
While they're called alerts, you can use this feature to call anything.
|
||||
|
||||
@@ -2791,8 +2896,10 @@ To get more details, please check [chart's configuration](https://github.com/Twi
|
||||
|
||||
|
||||
### Terraform
|
||||
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
Gatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
|
||||
|
||||
## Running the tests
|
||||
```console
|
||||
@@ -2836,7 +2943,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
|
||||
|
||||
|
||||
### Recommended interval
|
||||
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
|
||||
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
|
||||
endpoints/suites that can be evaluated at the same time.
|
||||
In other words, even if you have multiple endpoints with the same interval, they are not guaranteed to run at the same time.
|
||||
|
||||
@@ -2944,8 +3051,47 @@ endpoints:
|
||||
```
|
||||
|
||||
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
|
||||
shows whether the connection was successfully established. You can use Go template
|
||||
syntax.
|
||||
shows whether the connection was successfully established. You can use Go template
|
||||
syntax.
|
||||
|
||||
|
||||
### Monitoring an endpoint using gRPC
|
||||
You can monitor gRPC services by prefixing `endpoints[].url` with `grpc://` or `grpcs://`.
|
||||
Gatus executes the standard `grpc.health.v1.Health/Check` RPC against the target.
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: my-grpc
|
||||
url: grpc://localhost:50051
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[BODY].status == SERVING" # BODY is read only when referenced
|
||||
client:
|
||||
timeout: 5s
|
||||
```
|
||||
|
||||
For TLS-enabled servers, use `grpcs://` and configure client TLS if necessary:
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: my-grpcs
|
||||
url: grpcs://example.com:443
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[BODY].status == SERVING"
|
||||
client:
|
||||
timeout: 5s
|
||||
insecure: false # set true to skip cert verification (not recommended)
|
||||
tls:
|
||||
certificate-file: /path/to/cert.pem # optional mTLS client cert
|
||||
private-key-file: /path/to/key.pem # optional mTLS client key
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The health check targets the default service (`service: ""`). Support for a custom service name can be added later if needed.
|
||||
- The response body is exposed as a minimal JSON object like `{"status":"SERVING"}` only when required by conditions or suite store mappings.
|
||||
- Timeouts, custom DNS resolvers and SSH tunnels are honored via the existing [`client` configuration](#client-configuration).
|
||||
|
||||
|
||||
### Monitoring an endpoint using ICMP
|
||||
@@ -2962,9 +3108,12 @@ endpoints:
|
||||
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
|
||||
You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.
|
||||
|
||||
If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux
|
||||
If you run Gatus on Linux, please read the Linux section on [https://github.com/prometheus-community/pro-bing#linux]
|
||||
if you encounter any problems.
|
||||
|
||||
Prior to `v5.31.0`, some environment setups required adding `CAP_NET_RAW` capabilities to allow pings to work.
|
||||
As of `v5.31.0`, this is no longer necessary, and ICMP checks will work with unprivileged pings unless running as root. See #1346 for details.
|
||||
|
||||
|
||||
### Monitoring an endpoint using DNS queries
|
||||
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
|
||||
@@ -2990,7 +3139,8 @@ There are two placeholders that can be used in the conditions for endpoints of t
|
||||
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: ssh-example
|
||||
# Password-based SSH example
|
||||
- name: ssh-example-password
|
||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||
ssh:
|
||||
username: "username"
|
||||
@@ -3004,10 +3154,24 @@ endpoints:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
- "[BODY].memory.used > 500"
|
||||
|
||||
# Key-based SSH example
|
||||
- name: ssh-example-key
|
||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||
ssh:
|
||||
username: "username"
|
||||
private-key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
TESTRSAKEY...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
```
|
||||
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username
|
||||
and password fields.
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username,
|
||||
password and private key fields.
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
@@ -3016,6 +3180,7 @@ endpoints:
|
||||
ssh:
|
||||
username: ""
|
||||
password: ""
|
||||
private-key: ""
|
||||
|
||||
interval: 1m
|
||||
conditions:
|
||||
@@ -3080,7 +3245,7 @@ endpoints:
|
||||
- "[CERTIFICATE_EXPIRATION] > 240h"
|
||||
```
|
||||
|
||||
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
|
||||
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
|
||||
> [through a library](https://github.com/TwiN/whois) and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
|
||||
> To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
|
||||
> using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
|
||||
@@ -3109,7 +3274,7 @@ concurrency: 0
|
||||
|
||||
**Use cases for higher concurrency:**
|
||||
- You have a large number of endpoints to monitor
|
||||
- You want to monitor endpoints at very short intervals (< 5s)
|
||||
- You want to monitor endpoints at very short intervals (< 5s)
|
||||
- You're using Gatus for load testing scenarios
|
||||
|
||||
**Legacy configuration:**
|
||||
@@ -3193,7 +3358,7 @@ ui:
|
||||
default-sort-by: group
|
||||
```
|
||||
Note that if a user has already sorted the dashboard by a different field, the default sort will not be applied unless the user
|
||||
clears their browser's localstorage.
|
||||
clears their browser's localstorage.
|
||||
|
||||
|
||||
### Exposing Gatus on a custom path
|
||||
@@ -3210,12 +3375,19 @@ web:
|
||||
```
|
||||
|
||||
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
|
||||
variable instead, you can use that environment variable directly in the configuration file:
|
||||
variable instead see [Use environment variables in config files](#use-environment-variables-in-config-files).
|
||||
|
||||
### Use environment variables in config files
|
||||
|
||||
You can use environment variables directly in the configuration file which will be substituted from the environment:
|
||||
```yaml
|
||||
web:
|
||||
port: ${PORT}
|
||||
```
|
||||
|
||||
ui:
|
||||
title: $TITLE
|
||||
```
|
||||
⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
|
||||
|
||||
### Configuring a startup delay
|
||||
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.
|
||||
|
||||
@@ -8,6 +8,9 @@ const (
|
||||
// TypeAWSSES is the Type for the awsses alerting provider
|
||||
TypeAWSSES Type = "aws-ses"
|
||||
|
||||
// TypeClickUp is the Type for the clickup alerting provider
|
||||
TypeClickUp Type = "clickup"
|
||||
|
||||
// TypeCustom is the Type for the custom alerting provider
|
||||
TypeCustom Type = "custom"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
@@ -54,6 +55,9 @@ type Config struct {
|
||||
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
||||
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
||||
|
||||
// ClickUp is the configuration for the clickup alerting provider
|
||||
ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"`
|
||||
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package clickup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrListIDNotSet = errors.New("list-id not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
|
||||
)
|
||||
|
||||
var priorityMap = map[string]int{
|
||||
"urgent": 1,
|
||||
"high": 2,
|
||||
"normal": 3,
|
||||
"low": 4,
|
||||
"none": 0,
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
APIURL string `yaml:"api-url"`
|
||||
ListID string `yaml:"list-id"`
|
||||
Token string `yaml:"token"`
|
||||
Assignees []string `yaml:"assignees"`
|
||||
Status string `yaml:"status"`
|
||||
Priority string `yaml:"priority"`
|
||||
NotifyAll *bool `yaml:"notify-all,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
MarkdownContent string `yaml:"content,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if cfg.ListID == "" {
|
||||
return ErrListIDNotSet
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
if cfg.Priority == "" {
|
||||
cfg.Priority = "normal"
|
||||
}
|
||||
if _, ok := priorityMap[cfg.Priority]; !ok {
|
||||
return ErrInvalidPriority
|
||||
}
|
||||
if cfg.NotifyAll == nil {
|
||||
defaultNotifyAll := true
|
||||
cfg.NotifyAll = &defaultNotifyAll
|
||||
}
|
||||
if cfg.APIURL == "" {
|
||||
cfg.APIURL = "https://api.clickup.com/api/v2"
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
|
||||
}
|
||||
if cfg.MarkdownContent == "" {
|
||||
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.APIURL != "" {
|
||||
cfg.APIURL = override.APIURL
|
||||
}
|
||||
if override.ListID != "" {
|
||||
cfg.ListID = override.ListID
|
||||
}
|
||||
if override.Token != "" {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
if override.Status != "" {
|
||||
cfg.Status = override.Status
|
||||
}
|
||||
if override.Priority != "" {
|
||||
cfg.Priority = override.Priority
|
||||
}
|
||||
if override.NotifyAll != nil {
|
||||
cfg.NotifyAll = override.NotifyAll
|
||||
}
|
||||
if len(override.Assignees) > 0 {
|
||||
cfg.Assignees = override.Assignees
|
||||
}
|
||||
if override.Name != "" {
|
||||
cfg.Name = override.Name
|
||||
}
|
||||
if override.MarkdownContent != "" {
|
||||
cfg.MarkdownContent = override.MarkdownContent
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using ClickUp
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default configuration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved {
|
||||
return provider.CloseTask(cfg, ep)
|
||||
}
|
||||
// Replace placeholders
|
||||
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
|
||||
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
|
||||
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
|
||||
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
|
||||
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"markdown_content": markdownContent,
|
||||
"assignees": cfg.Assignees,
|
||||
"status": cfg.Status,
|
||||
"notify_all": *cfg.NotifyAll,
|
||||
}
|
||||
if cfg.Priority != "none" {
|
||||
body["priority"] = priorityMap[cfg.Priority]
|
||||
}
|
||||
return provider.CreateTask(cfg, body)
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
|
||||
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", cfg.Token)
|
||||
httpClient := client.GetHTTPClient(nil)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
|
||||
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
|
||||
req, err := http.NewRequest("GET", fetchURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", cfg.Token)
|
||||
httpClient := client.GetHTTPClient(nil)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
|
||||
}
|
||||
var fetchResponse struct {
|
||||
Tasks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
var matchingTaskIDs []string
|
||||
for _, task := range fetchResponse.Tasks {
|
||||
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
|
||||
matchingTaskIDs = append(matchingTaskIDs, task.ID)
|
||||
}
|
||||
}
|
||||
if len(matchingTaskIDs) == 0 {
|
||||
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
|
||||
}
|
||||
for _, taskID := range matchingTaskIDs {
|
||||
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
|
||||
return fmt.Errorf("failed to close task %s: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
|
||||
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
|
||||
body := map[string]interface{}{"status": status}
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", cfg.Token)
|
||||
httpClient := client.GetHTTPClient(nil)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package clickup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
|
||||
if err := invalidProviderNoListID.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid without list-id")
|
||||
}
|
||||
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
|
||||
if err := invalidProviderNoToken.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid without token")
|
||||
}
|
||||
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
|
||||
if err := invalidProviderBadPriority.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid with invalid priority")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if validProvider.DefaultConfig.Priority != "normal" {
|
||||
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
|
||||
}
|
||||
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
|
||||
if err := validProviderWithAPIURL.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
|
||||
if err := validProviderWithPriority.Validate(); err != nil {
|
||||
t.Error("provider should've been valid with priority 'urgent'")
|
||||
}
|
||||
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
|
||||
if err := validProviderWithNone.Validate(); err != nil {
|
||||
t.Error("provider should've been valid with priority 'none'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
|
||||
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
|
||||
if err := provider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
|
||||
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Endpoint endpoint.Endpoint
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Method == "GET" {
|
||||
// Mock fetch tasks response
|
||||
tasksResponse := map[string]interface{}{
|
||||
"tasks": []map[string]interface{}{
|
||||
{
|
||||
"id": "task-123",
|
||||
"name": "Health Check: endpoint-group:endpoint-name",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(tasksResponse)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
}
|
||||
if r.Method == "PUT" {
|
||||
// Mock update task status response
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-no-matching-tasks",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Method == "GET" {
|
||||
// Mock fetch tasks response with no matching tasks
|
||||
tasksResponse := map[string]interface{}{
|
||||
"tasks": []map[string]interface{}{},
|
||||
}
|
||||
body, _ := json.Marshal(tasksResponse)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error-fetching-tasks",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
Errors: []string{"error1", "error2"},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"list-id": "override-list-id",
|
||||
"token": "override-token",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-partial-alert-override-should-merge",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"status": "closed",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-assignees-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"assignees": []string{"user1", "user2"},
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-priority-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"priority": "urgent",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-none-priority",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"priority": "none",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
Overrides: []Override{
|
||||
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
|
||||
},
|
||||
},
|
||||
InputGroup: "core",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.ListID != scenario.ExpectedOutput.ListID {
|
||||
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
|
||||
}
|
||||
if got.Token != scenario.ExpectedOutput.Token {
|
||||
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||
}
|
||||
if got.Status != scenario.ExpectedOutput.Status {
|
||||
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
|
||||
}
|
||||
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
|
||||
}
|
||||
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
|
||||
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package provider
|
||||
import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
@@ -92,6 +93,7 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl
|
||||
var (
|
||||
// Validate provider interface implementation on compile
|
||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||
_ AlertProvider = (*clickup.AlertProvider)(nil)
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*datadog.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
@@ -133,6 +135,7 @@ var (
|
||||
|
||||
// Validate config interface implementation on compile
|
||||
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||
_ Config[clickup.Config] = (*clickup.Config)(nil)
|
||||
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||
_ Config[datadog.Config] = (*datadog.Config)(nil)
|
||||
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||
|
||||
@@ -84,6 +84,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
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)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/history", ResponseTimeHistory)
|
||||
// This endpoint requires authz with bearer token, so technically it is protected
|
||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||
// SPA
|
||||
|
||||
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResponseTimeHistory(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "30d":
|
||||
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
|
||||
case "7d":
|
||||
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||
}
|
||||
endpointKey, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if len(hourlyAverageResponseTime) == 0 {
|
||||
return c.Status(200).JSON(map[string]interface{}{
|
||||
"timestamps": []int64{},
|
||||
"values": []int{},
|
||||
})
|
||||
}
|
||||
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
|
||||
earliestTimestamp := int64(0)
|
||||
for hourlyTimestamp := range hourlyAverageResponseTime {
|
||||
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
|
||||
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
|
||||
earliestTimestamp = hourlyTimestamp
|
||||
}
|
||||
}
|
||||
for earliestTimestamp > from.Unix() {
|
||||
earliestTimestamp -= int64(time.Hour.Seconds())
|
||||
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
|
||||
}
|
||||
sort.Ints(hourlyTimestamps)
|
||||
timestamps := make([]int64, 0, len(hourlyTimestamps))
|
||||
values := make([]int, 0, len(hourlyTimestamps))
|
||||
for _, hourlyTimestamp := range hourlyTimestamps {
|
||||
timestamp := int64(hourlyTimestamp)
|
||||
averageResponseTime := hourlyAverageResponseTime[timestamp]
|
||||
timestamps = append(timestamps, timestamp*1000)
|
||||
values = append(values, averageResponseTime)
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(map[string]interface{}{
|
||||
"timestamps": timestamps,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseTimeHistory(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",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "history-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-30d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
||||
inEndpointMaintenanceWindow := false
|
||||
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
|
||||
if maintenanceWindow.IsUnderMaintenance() {
|
||||
logr.Debug("[api.CreateExternalEndpointResult] Under endpoint maintenance window")
|
||||
inEndpointMaintenanceWindow = true
|
||||
}
|
||||
}
|
||||
// Check if an alert should be triggered or resolved
|
||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
||||
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
} else {
|
||||
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
|
||||
}
|
||||
if cfg.Metrics {
|
||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||
|
||||
+9
-2
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
@@ -25,7 +26,10 @@ func UptimeRaw(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) {
|
||||
@@ -57,7 +61,10 @@ func ResponseTimeRaw(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")
|
||||
}
|
||||
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
|
||||
+66
-34
@@ -13,6 +13,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,13 +21,13 @@ import (
|
||||
"github.com/TwiN/gocache/v2"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/TwiN/whois"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/ishidawataru/sctp"
|
||||
"github.com/miekg/dns"
|
||||
ping "github.com/prometheus-community/pro-bing"
|
||||
"github.com/registrobr/rdap"
|
||||
"github.com/registrobr/rdap/protocol"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -247,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
|
||||
|
||||
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||
// using the SSH protocol.
|
||||
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
|
||||
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
|
||||
var port string
|
||||
if strings.Contains(address, ":") {
|
||||
addressAndPort := strings.Split(address, ":")
|
||||
@@ -259,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
||||
} else {
|
||||
port = "22"
|
||||
}
|
||||
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
|
||||
|
||||
// Build auth methods: prefer parsed private key if provided, fall back to password.
|
||||
var authMethods []ssh.AuthMethod
|
||||
if len(privateKey) > 0 {
|
||||
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
} else {
|
||||
return false, nil, fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
}
|
||||
if len(password) > 0 {
|
||||
authMethods = append(authMethods, ssh.Password(password))
|
||||
}
|
||||
|
||||
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
Timeout: config.Timeout,
|
||||
Auth: authMethods,
|
||||
Timeout: config.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
@@ -343,12 +356,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
pinger := ping.New(address)
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = config.Timeout
|
||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
||||
// See https://github.com/TwiN/gatus/issues/132
|
||||
//
|
||||
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
||||
// See https://github.com/prometheus-community/pro-bing#linux
|
||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
|
||||
pinger.SetNetwork(config.Network)
|
||||
err := pinger.Run()
|
||||
if err != nil {
|
||||
@@ -364,51 +372,75 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// ShouldRunPingerAsPrivileged will determine whether or not to run pinger in privileged mode.
|
||||
// It should be set to privileged when running as root, and always on windows. See https://pkg.go.dev/github.com/macrat/go-parallel-pinger#Pinger.SetPrivileged
|
||||
func ShouldRunPingerAsPrivileged() bool {
|
||||
// Set the pinger's privileged mode to false for darwin
|
||||
// See https://github.com/TwiN/gatus/issues/132
|
||||
// linux should also be set to false, but there are potential complications
|
||||
// See https://github.com/TwiN/gatus/pull/748 and https://github.com/TwiN/gatus/issues/697#issuecomment-2081700989
|
||||
//
|
||||
// Note that for this to work on Linux, Gatus must run with sudo privileges. (in certain cases)
|
||||
// See https://github.com/prometheus-community/pro-bing#linux
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
// To actually check for cap_net_raw capabilities, we would need to add "kernel.org/pub/linux/libs/security/libcap/cap" to gatus.
|
||||
// Or use a syscall and check for permission errors, but this requires platform specific compilation
|
||||
// As a backstop we can simply check the effective user id and run as privileged when running as root
|
||||
return os.Geteuid() == 0
|
||||
}
|
||||
|
||||
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
|
||||
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
|
||||
const (
|
||||
Origin = "http://localhost/"
|
||||
MaximumMessageSize = 1024 // in bytes
|
||||
Origin = "http://localhost/"
|
||||
)
|
||||
wsConfig, err := websocket.NewConfig(address, Origin)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
|
||||
}
|
||||
if headers != nil {
|
||||
if wsConfig.Header == nil {
|
||||
wsConfig.Header = make(http.Header)
|
||||
}
|
||||
for name, value := range headers {
|
||||
wsConfig.Header.Set(name, value)
|
||||
var (
|
||||
dialer = websocket.Dialer{
|
||||
EnableCompression: true,
|
||||
}
|
||||
wsHeaders = make(http.Header)
|
||||
)
|
||||
|
||||
wsHeaders.Set("Origin", Origin)
|
||||
for name, value := range headers {
|
||||
wsHeaders.Set(name, value)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if config != nil {
|
||||
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
|
||||
wsConfig.TlsConfig = &tls.Config{
|
||||
if config.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
dialer.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
}
|
||||
if config.HasTLSConfig() && config.TLS.isValid() == nil {
|
||||
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
|
||||
dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)
|
||||
}
|
||||
}
|
||||
// Dial URL
|
||||
ws, err := websocket.DialConfig(wsConfig)
|
||||
ws, _, err := dialer.DialContext(ctx, address, wsHeaders)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
|
||||
}
|
||||
defer ws.Close()
|
||||
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
|
||||
// Write message
|
||||
if _, err := ws.Write([]byte(body)); err != nil {
|
||||
if err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil {
|
||||
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
|
||||
}
|
||||
// Read message
|
||||
var n int
|
||||
msg := make([]byte, MaximumMessageSize)
|
||||
if n, err = ws.Read(msg); err != nil {
|
||||
msgType, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
|
||||
} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
|
||||
return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage)
|
||||
}
|
||||
return true, msg[:n], nil
|
||||
return true, msg, nil
|
||||
}
|
||||
|
||||
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
|
||||
|
||||
+37
-1
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetHTTPClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{
|
||||
Insecure: false,
|
||||
IgnoreRedirect: false,
|
||||
@@ -40,6 +43,7 @@ func TestGetHTTPClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRdapQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := rdapQuery("1.1.1.1"); err == nil {
|
||||
t.Error("expected an error due to the invalid domain type")
|
||||
}
|
||||
@@ -129,6 +133,32 @@ func TestPing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRunPingerAsPrivileged(t *testing.T) {
|
||||
// Don't run in parallel since we're testing system-dependent behavior
|
||||
if runtime.GOOS == "windows" {
|
||||
result := ShouldRunPingerAsPrivileged()
|
||||
if !result {
|
||||
t.Error("On Windows, ShouldRunPingerAsPrivileged() should return true")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Non-Windows tests
|
||||
result := ShouldRunPingerAsPrivileged()
|
||||
isRoot := os.Geteuid() == 0
|
||||
|
||||
// Test cases based on current environment
|
||||
if isRoot {
|
||||
if !result {
|
||||
t.Error("When running as root, ShouldRunPingerAsPrivileged() should return true")
|
||||
}
|
||||
} else {
|
||||
// When not root, the result depends on raw socket creation
|
||||
// We can at least verify the function runs without panic
|
||||
t.Logf("Non-root privileged result: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanPerformStartTLS(t *testing.T) {
|
||||
type args struct {
|
||||
address string
|
||||
@@ -260,6 +290,7 @@ func TestCanPerformTLS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCanCreateConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
|
||||
if connected {
|
||||
t.Error("should've failed, because there's no port in the address")
|
||||
@@ -274,6 +305,7 @@ func TestCanCreateConnection(t *testing.T) {
|
||||
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
|
||||
// header to all outgoing HTTP calls.
|
||||
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
defer InjectHTTPClient(nil)
|
||||
oAuth2Config := &OAuth2Config{
|
||||
ClientID: "00000000-0000-0000-0000-000000000000",
|
||||
@@ -329,6 +361,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQueryWebSocket(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
|
||||
if err == nil {
|
||||
t.Error("expected an error due to the address being invalid")
|
||||
@@ -340,6 +373,7 @@ func TestQueryWebSocket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTlsRenegotiation(t *testing.T) {
|
||||
t.Parallel()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg TLSConfig
|
||||
@@ -383,6 +417,7 @@ func TestTlsRenegotiation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQueryDNS(t *testing.T) {
|
||||
t.Parallel()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
inputDNS dns.Config
|
||||
@@ -439,7 +474,7 @@ func TestQueryDNS(t *testing.T) {
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "*.iana-servers.net.",
|
||||
expectedBody: "*.ns.cloudflare.com.",
|
||||
},
|
||||
{
|
||||
name: "test Config with type PTR",
|
||||
@@ -512,6 +547,7 @@ func TestQueryDNS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckSSHBanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{Timeout: 3}
|
||||
t.Run("no-auth-ssh", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/logr"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
health "google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.
|
||||
// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.
|
||||
func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {
|
||||
if cfg == nil {
|
||||
cfg = GetDefaultConfig()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
var opts []grpc.DialOption
|
||||
// Transport credentials
|
||||
if useTLS {
|
||||
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}
|
||||
if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {
|
||||
tlsCfg = configureTLS(tlsCfg, *cfg.TLS)
|
||||
}
|
||||
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
|
||||
} else {
|
||||
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
}
|
||||
// Custom dialer for DNS resolver or SSH tunnel
|
||||
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if cfg.ResolvedTunnel != nil {
|
||||
return cfg.ResolvedTunnel.Dial("tcp", addr)
|
||||
}
|
||||
if cfg.HasCustomDNSResolver() {
|
||||
resolverCfg, err := cfg.parseDNSResolver()
|
||||
if err != nil {
|
||||
// Shouldn't happen because already validated; log and fall back
|
||||
logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err)
|
||||
} else {
|
||||
d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port)
|
||||
}}}
|
||||
return d.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", addr)
|
||||
}))
|
||||
|
||||
start := time.Now()
|
||||
conn, err := grpc.DialContext(ctx, address, opts...)
|
||||
if err != nil {
|
||||
return false, "", err, time.Since(start)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := health.NewHealthClient(conn)
|
||||
resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""})
|
||||
if err != nil {
|
||||
return false, "", err, time.Since(start)
|
||||
}
|
||||
return true, resp.GetStatus().String(), nil, time.Since(start)
|
||||
}
|
||||
@@ -53,6 +53,10 @@ type Announcement struct {
|
||||
|
||||
// Message is the user-facing text describing the announcement
|
||||
Message string `yaml:"message" json:"message"`
|
||||
|
||||
// Archived indicates whether the announcement should be displayed in the historical section
|
||||
// instead of at the top of the status page
|
||||
Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package announcement
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
|
||||
now := time.Now()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
announcement *Announcement
|
||||
expectedError error
|
||||
expectedType string
|
||||
}{
|
||||
{
|
||||
name: "valid-announcement-with-all-fields",
|
||||
announcement: &Announcement{
|
||||
Timestamp: now,
|
||||
Type: TypeWarning,
|
||||
Message: "This is a test announcement",
|
||||
Archived: false,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedType: TypeWarning,
|
||||
},
|
||||
{
|
||||
name: "valid-announcement-with-archived-true",
|
||||
announcement: &Announcement{
|
||||
Timestamp: now,
|
||||
Type: TypeOperational,
|
||||
Message: "This is an archived announcement",
|
||||
Archived: true,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedType: TypeOperational,
|
||||
},
|
||||
{
|
||||
name: "valid-announcement-with-empty-type-should-default-to-none",
|
||||
announcement: &Announcement{
|
||||
Timestamp: now,
|
||||
Message: "This announcement has no type",
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedType: TypeNone,
|
||||
},
|
||||
{
|
||||
name: "invalid-announcement-with-empty-message",
|
||||
announcement: &Announcement{
|
||||
Timestamp: now,
|
||||
Type: TypeWarning,
|
||||
Message: "",
|
||||
},
|
||||
expectedError: ErrEmptyMessage,
|
||||
},
|
||||
{
|
||||
name: "invalid-announcement-with-zero-timestamp",
|
||||
announcement: &Announcement{
|
||||
Timestamp: time.Time{},
|
||||
Type: TypeWarning,
|
||||
Message: "Test message",
|
||||
},
|
||||
expectedError: ErrMissingTimestamp,
|
||||
},
|
||||
{
|
||||
name: "invalid-announcement-with-invalid-type",
|
||||
announcement: &Announcement{
|
||||
Timestamp: now,
|
||||
Type: "invalid-type",
|
||||
Message: "Test message",
|
||||
},
|
||||
expectedError: ErrInvalidAnnouncementType,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.announcement.ValidateAndSetDefaults()
|
||||
if !errors.Is(err, scenario.expectedError) {
|
||||
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||
}
|
||||
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
|
||||
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
|
||||
now := time.Now()
|
||||
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
|
||||
for _, validType := range validTypes {
|
||||
t.Run("type-"+validType, func(t *testing.T) {
|
||||
announcement := &Announcement{
|
||||
Timestamp: now,
|
||||
Type: validType,
|
||||
Message: "Test message",
|
||||
}
|
||||
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||
t.Errorf("expected no error for type %s, got %v", validType, err)
|
||||
}
|
||||
if announcement.Type != validType {
|
||||
t.Errorf("expected type %s, got %s", validType, announcement.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByTimestamp(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-1 * time.Hour)
|
||||
later := now.Add(1 * time.Hour)
|
||||
announcements := []*Announcement{
|
||||
{Timestamp: now, Message: "now"},
|
||||
{Timestamp: later, Message: "later"},
|
||||
{Timestamp: earlier, Message: "earlier"},
|
||||
}
|
||||
SortByTimestamp(announcements)
|
||||
if announcements[0].Timestamp != later {
|
||||
t.Error("expected first announcement to be the latest")
|
||||
}
|
||||
if announcements[1].Timestamp != now {
|
||||
t.Error("expected second announcement to be the middle one")
|
||||
}
|
||||
if announcements[2].Timestamp != earlier {
|
||||
t.Error("expected third announcement to be the earliest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-1 * time.Hour)
|
||||
later := now.Add(1 * time.Hour)
|
||||
announcements := []*Announcement{
|
||||
{Timestamp: now, Message: "now", Archived: false},
|
||||
{Timestamp: later, Message: "later", Archived: true},
|
||||
{Timestamp: earlier, Message: "earlier", Archived: false},
|
||||
}
|
||||
SortByTimestamp(announcements)
|
||||
// Sorting should be by timestamp only, not affected by archived status
|
||||
if announcements[0].Timestamp != later {
|
||||
t.Error("expected first announcement to be the latest, regardless of archived status")
|
||||
}
|
||||
if !announcements[0].Archived {
|
||||
t.Error("expected first announcement to be archived")
|
||||
}
|
||||
if announcements[1].Timestamp != now {
|
||||
t.Error("expected second announcement to be the middle one")
|
||||
}
|
||||
if announcements[2].Timestamp != earlier {
|
||||
t.Error("expected third announcement to be the earliest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSetDefaults_Slice(t *testing.T) {
|
||||
now := time.Now()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
announcements []*Announcement
|
||||
expectedError error
|
||||
shouldValidate bool
|
||||
}{
|
||||
{
|
||||
name: "all-valid-announcements",
|
||||
announcements: []*Announcement{
|
||||
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
|
||||
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
|
||||
},
|
||||
expectedError: nil,
|
||||
shouldValidate: true,
|
||||
},
|
||||
{
|
||||
name: "mixed-archived-announcements",
|
||||
announcements: []*Announcement{
|
||||
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
|
||||
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
|
||||
},
|
||||
expectedError: nil,
|
||||
shouldValidate: true,
|
||||
},
|
||||
{
|
||||
name: "one-invalid-announcement-in-slice",
|
||||
announcements: []*Announcement{
|
||||
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||
{Timestamp: now, Type: TypeOperational, Message: ""},
|
||||
},
|
||||
expectedError: ErrEmptyMessage,
|
||||
shouldValidate: false,
|
||||
},
|
||||
{
|
||||
name: "announcement-with-missing-timestamp",
|
||||
announcements: []*Announcement{
|
||||
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
|
||||
},
|
||||
expectedError: ErrMissingTimestamp,
|
||||
shouldValidate: false,
|
||||
},
|
||||
{
|
||||
name: "announcement-with-invalid-type",
|
||||
announcements: []*Announcement{
|
||||
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
|
||||
},
|
||||
expectedError: ErrInvalidAnnouncementType,
|
||||
shouldValidate: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := ValidateAndSetDefaults(scenario.announcements)
|
||||
if !errors.Is(err, scenario.expectedError) {
|
||||
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
|
||||
now := time.Now()
|
||||
announcement := &Announcement{
|
||||
Timestamp: now,
|
||||
Type: TypeWarning,
|
||||
Message: "Test announcement",
|
||||
// Archived not set, should default to false
|
||||
}
|
||||
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
// Zero value for bool is false
|
||||
if announcement.Archived {
|
||||
t.Error("expected Archived to default to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
|
||||
announcements := []*Announcement{}
|
||||
if err := ValidateAndSetDefaults(announcements); err != nil {
|
||||
t.Errorf("expected no error for empty slice, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -594,6 +594,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
}
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeAWSSES,
|
||||
alert.TypeClickUp,
|
||||
alert.TypeCustom,
|
||||
alert.TypeDatadog,
|
||||
alert.TypeDiscord,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
@@ -1854,6 +1855,7 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
||||
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
alertingConfig := &alerting.Config{
|
||||
AWSSimpleEmailService: &awsses.AlertProvider{},
|
||||
ClickUp: &clickup.AlertProvider{},
|
||||
Custom: &custom.AlertProvider{},
|
||||
Datadog: &datadog.AlertProvider{},
|
||||
Discord: &discord.AlertProvider{},
|
||||
@@ -1898,6 +1900,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
expected provider.AlertProvider
|
||||
}{
|
||||
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
|
||||
{alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp},
|
||||
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
||||
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
|
||||
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
||||
|
||||
@@ -209,7 +209,44 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
|
||||
}
|
||||
|
||||
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
|
||||
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
|
||||
resolvedStrings := make([]string, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
// Check if the parameter is a certificate or domain expiration placeholder
|
||||
if parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {
|
||||
// Format as duration string (convert milliseconds back to duration)
|
||||
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
|
||||
resolvedStrings[i] = formatDuration(duration)
|
||||
} else if _, err := time.ParseDuration(parameters[i]); err == nil {
|
||||
// If the original parameter was a duration string (like "48h"), format the resolved value
|
||||
// as a duration string too so it matches and doesn't show in parentheses
|
||||
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
|
||||
resolvedStrings[i] = formatDuration(duration)
|
||||
} else {
|
||||
// Format as integer
|
||||
resolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))
|
||||
}
|
||||
}
|
||||
return prettify(parameters, resolvedStrings, operator)
|
||||
}
|
||||
|
||||
// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.
|
||||
// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.
|
||||
// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.
|
||||
func formatDuration(d time.Duration) string {
|
||||
// Truncate to whole seconds to avoid decimal seconds
|
||||
d = d.Truncate(time.Second)
|
||||
s := d.String()
|
||||
// Special case: if duration is zero, return "0s"
|
||||
if s == "0s" {
|
||||
return "0s"
|
||||
}
|
||||
// Remove trailing "0s" if present
|
||||
if strings.HasSuffix(s, "0s") {
|
||||
s = strings.TrimSuffix(s, "0s")
|
||||
// Remove trailing "0m" if present after removing "0s"
|
||||
s = strings.TrimSuffix(s, "0m")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
|
||||
@@ -476,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-duration",
|
||||
@@ -490,7 +490,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
|
||||
},
|
||||
{
|
||||
Name: "no-placeholders",
|
||||
|
||||
@@ -50,6 +50,7 @@ const (
|
||||
TypeSTARTTLS Type = "STARTTLS"
|
||||
TypeTLS Type = "TLS"
|
||||
TypeHTTP Type = "HTTP"
|
||||
TypeGRPC Type = "GRPC"
|
||||
TypeWS Type = "WEBSOCKET"
|
||||
TypeSSH Type = "SSH"
|
||||
TypeUNKNOWN Type = "UNKNOWN"
|
||||
@@ -177,6 +178,8 @@ func (e *Endpoint) Type() Type {
|
||||
return TypeTLS
|
||||
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
||||
return TypeHTTP
|
||||
case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"):
|
||||
return TypeGRPC
|
||||
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
|
||||
return TypeWS
|
||||
case strings.HasPrefix(e.URL, "ssh://"):
|
||||
@@ -500,8 +503,8 @@ func (e *Endpoint) call(result *Result) {
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSSH {
|
||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
||||
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0) {
|
||||
// If there's no username, password or private key specified, attempt to validate just the SSH banner
|
||||
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
|
||||
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -512,7 +515,7 @@ func (e *Endpoint) call(result *Result) {
|
||||
return
|
||||
}
|
||||
var cli *ssh.Client
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
@@ -528,6 +531,19 @@ func (e *Endpoint) call(result *Result) {
|
||||
result.Body = output
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeGRPC {
|
||||
useTLS := strings.HasPrefix(e.URL, "grpcs://")
|
||||
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
|
||||
connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Connected = connected
|
||||
result.Duration = duration
|
||||
if e.needsToReadBody() {
|
||||
result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status))
|
||||
}
|
||||
} else {
|
||||
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
||||
result.Duration = time.Since(startTime)
|
||||
|
||||
@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
privateKey string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "fail when has no user",
|
||||
name: "fail when has no user but has password",
|
||||
username: "",
|
||||
password: "password",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||
},
|
||||
{
|
||||
name: "fail when has no password",
|
||||
username: "username",
|
||||
password: "",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
|
||||
name: "fail when has no user but has private key",
|
||||
username: "",
|
||||
privateKey: "-----BEGIN",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||
},
|
||||
{
|
||||
name: "success when all fields are set",
|
||||
name: "fail when has no password or private key",
|
||||
username: "username",
|
||||
password: "",
|
||||
privateKey: "",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
|
||||
},
|
||||
{
|
||||
name: "success when username and password are set",
|
||||
username: "username",
|
||||
password: "password",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "success when username and private key are set",
|
||||
username: "username",
|
||||
privateKey: "-----BEGIN",
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -539,8 +553,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
||||
Name: "ssh-test",
|
||||
URL: "https://example.com",
|
||||
SSHConfig: &ssh.Config{
|
||||
Username: scenario.username,
|
||||
Password: scenario.password,
|
||||
Username: scenario.username,
|
||||
Password: scenario.password,
|
||||
PrivateKey: scenario.privateKey,
|
||||
},
|
||||
Conditions: []Condition{Condition("[STATUS] == 0")},
|
||||
}
|
||||
@@ -1605,7 +1620,7 @@ func TestEndpoint_HideUIFeatures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if tt.checkConditions {
|
||||
hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
|
||||
hasConditions := len(result.ConditionResults) > 0
|
||||
if hasConditions != tt.expectConditions {
|
||||
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
||||
}
|
||||
|
||||
@@ -8,26 +8,29 @@ var (
|
||||
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
|
||||
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
|
||||
|
||||
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
|
||||
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
|
||||
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
|
||||
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Username string `yaml:"username,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
Username string `yaml:"username,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
PrivateKey string `yaml:"private-key,omitempty"`
|
||||
}
|
||||
|
||||
// Validate the SSH configuration
|
||||
func (cfg *Config) Validate() error {
|
||||
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
|
||||
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||
return nil
|
||||
}
|
||||
// If any authentication method is provided (password or private key), a username is required
|
||||
if len(cfg.Username) == 0 {
|
||||
return ErrEndpointWithoutSSHUsername
|
||||
}
|
||||
if len(cfg.Password) == 0 {
|
||||
return ErrEndpointWithoutSSHPassword
|
||||
// If a username is provided, require at least a password or a private key
|
||||
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||
return ErrEndpointWithoutSSHAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSSH_validate(t *testing.T) {
|
||||
func TestSSH_validatePasswordCfg(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error("didn't expect an error")
|
||||
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
|
||||
cfg.Username = "username"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected an error")
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
|
||||
}
|
||||
cfg.Password = "password"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("expected no error, got '%v'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
|
||||
t.Run("fail when username missing but private key provided", func(t *testing.T) {
|
||||
cfg := &Config{PrivateKey: "-----BEGIN"}
|
||||
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
|
||||
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("success when username with private key", func(t *testing.T) {
|
||||
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+30
-19
@@ -10,14 +10,16 @@ import (
|
||||
)
|
||||
|
||||
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 = "Gatus"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
defaultCustomCSS = ""
|
||||
defaultSortBy = "name"
|
||||
defaultFilterBy = "none"
|
||||
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 = "Gatus"
|
||||
defaultDashboardHeading = "Health Dashboard"
|
||||
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
defaultCustomCSS = ""
|
||||
defaultSortBy = "name"
|
||||
defaultFilterBy = "none"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,17 +32,18 @@ var (
|
||||
|
||||
// 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
|
||||
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')
|
||||
|
||||
Title string `yaml:"title,omitempty"` // Title of the page
|
||||
Description string `yaml:"description,omitempty"` // Meta description of the page
|
||||
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
|
||||
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
|
||||
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 //
|
||||
//////////////////////////////////////////////
|
||||
@@ -73,6 +76,8 @@ func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
Title: defaultTitle,
|
||||
Description: defaultDescription,
|
||||
DashboardHeading: defaultDashboardHeading,
|
||||
DashboardSubheading: defaultDashboardSubheading,
|
||||
Header: defaultHeader,
|
||||
Logo: defaultLogo,
|
||||
Link: defaultLink,
|
||||
@@ -92,6 +97,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if len(cfg.Description) == 0 {
|
||||
cfg.Description = defaultDescription
|
||||
}
|
||||
if len(cfg.DashboardHeading) == 0 {
|
||||
cfg.DashboardHeading = defaultDashboardHeading
|
||||
}
|
||||
if len(cfg.DashboardSubheading) == 0 {
|
||||
cfg.DashboardSubheading = defaultDashboardSubheading
|
||||
}
|
||||
if len(cfg.Header) == 0 {
|
||||
cfg.Header = defaultHeader
|
||||
}
|
||||
|
||||
+110
-25
@@ -7,31 +7,110 @@ import (
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Title: "",
|
||||
Description: "",
|
||||
Header: "",
|
||||
Logo: "",
|
||||
Link: "",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != defaultTitle {
|
||||
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||
}
|
||||
if cfg.Description != defaultDescription {
|
||||
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
||||
}
|
||||
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)
|
||||
}
|
||||
t.Run("empty-config", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Title: "",
|
||||
Description: "",
|
||||
DashboardHeading: "",
|
||||
DashboardSubheading: "",
|
||||
Header: "",
|
||||
Logo: "",
|
||||
Link: "",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != defaultTitle {
|
||||
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||
}
|
||||
if cfg.Description != defaultDescription {
|
||||
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
||||
}
|
||||
if cfg.DashboardHeading != defaultDashboardHeading {
|
||||
t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
|
||||
}
|
||||
if cfg.DashboardSubheading != defaultDashboardSubheading {
|
||||
t.Errorf("expected DashboardSubheading to be %s, got %s", defaultDashboardSubheading, cfg.DashboardSubheading)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
t.Run("custom-values", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Title: "Custom Title",
|
||||
Description: "Custom Description",
|
||||
DashboardHeading: "Production Status",
|
||||
DashboardSubheading: "Monitor all production endpoints",
|
||||
Header: "My Company",
|
||||
Logo: "https://example.com/logo.png",
|
||||
Link: "https://example.com",
|
||||
DefaultSortBy: "health",
|
||||
DefaultFilterBy: "failing",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != "Custom Title" {
|
||||
t.Errorf("expected title to be preserved, got %s", cfg.Title)
|
||||
}
|
||||
if cfg.Description != "Custom Description" {
|
||||
t.Errorf("expected description to be preserved, got %s", cfg.Description)
|
||||
}
|
||||
if cfg.DashboardHeading != "Production Status" {
|
||||
t.Errorf("expected DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
|
||||
}
|
||||
if cfg.DashboardSubheading != "Monitor all production endpoints" {
|
||||
t.Errorf("expected DashboardSubheading to be preserved, got %s", cfg.DashboardSubheading)
|
||||
}
|
||||
if cfg.Header != "My Company" {
|
||||
t.Errorf("expected header to be preserved, got %s", cfg.Header)
|
||||
}
|
||||
if cfg.Logo != "https://example.com/logo.png" {
|
||||
t.Errorf("expected logo to be preserved, got %s", cfg.Logo)
|
||||
}
|
||||
if cfg.Link != "https://example.com" {
|
||||
t.Errorf("expected link to be preserved, got %s", cfg.Link)
|
||||
}
|
||||
if cfg.DefaultSortBy != "health" {
|
||||
t.Errorf("expected defaultSortBy to be preserved, got %s", cfg.DefaultSortBy)
|
||||
}
|
||||
if cfg.DefaultFilterBy != "failing" {
|
||||
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
|
||||
}
|
||||
})
|
||||
t.Run("partial-custom-values", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Title: "Custom Title",
|
||||
DashboardHeading: "My Dashboard",
|
||||
Header: "",
|
||||
DashboardSubheading: "",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != "Custom Title" {
|
||||
t.Errorf("expected custom title to be preserved, got %s", cfg.Title)
|
||||
}
|
||||
if cfg.DashboardHeading != "My Dashboard" {
|
||||
t.Errorf("expected custom DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
|
||||
}
|
||||
if cfg.DashboardSubheading != defaultDashboardSubheading {
|
||||
t.Errorf("expected DashboardSubheading to use default, got %s", cfg.DashboardSubheading)
|
||||
}
|
||||
if cfg.Header != defaultHeader {
|
||||
t.Errorf("expected header to use default, got %s", cfg.Header)
|
||||
}
|
||||
if cfg.Description != defaultDescription {
|
||||
t.Errorf("expected description to use default, got %s", cfg.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestButton_Validate(t *testing.T) {
|
||||
@@ -78,6 +157,12 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
if defaultConfig.Title != defaultTitle {
|
||||
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
||||
}
|
||||
if defaultConfig.DashboardHeading != defaultDashboardHeading {
|
||||
t.Error("expected GetDefaultConfig() to return defaultDashboardHeading, got", defaultConfig.DashboardHeading)
|
||||
}
|
||||
if defaultConfig.DashboardSubheading != defaultDashboardSubheading {
|
||||
t.Error("expected GetDefaultConfig() to return defaultDashboardSubheading, got", defaultConfig.DashboardSubheading)
|
||||
}
|
||||
if defaultConfig.Logo != defaultLogo {
|
||||
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||
}
|
||||
|
||||
@@ -5,21 +5,22 @@ go 1.24.4
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.22.0
|
||||
code.gitea.io/sdk/gitea v0.22.1
|
||||
github.com/TwiN/deepmerge v0.2.2
|
||||
github.com/TwiN/g8/v2 v2.0.0
|
||||
github.com/TwiN/gocache/v2 v2.4.0
|
||||
github.com/TwiN/health v1.6.0
|
||||
github.com/TwiN/logr v0.3.1
|
||||
github.com/TwiN/whois v1.2.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.2
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.2
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/google/go-github/v48 v48.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.68
|
||||
@@ -28,11 +29,11 @@ require (
|
||||
github.com/registrobr/rdap v1.1.8
|
||||
github.com/valyala/fasthttp v1.67.0
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sync v0.18.0
|
||||
google.golang.org/api v0.252.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.39.1
|
||||
@@ -44,16 +45,17 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
|
||||
github.com/aws/smithy-go v1.23.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
|
||||
github.com/aws/smithy-go v1.23.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
@@ -82,20 +84,20 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
|
||||
@@ -4,8 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
|
||||
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
|
||||
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
|
||||
@@ -24,40 +24,42 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
|
||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 h1:NwOeuOFrWoh4xWKINrmaAK4Vh75jmmY0RAuNjQ6W5Es=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5/go.mod h1:m3BsMJZD0eqjGIniBzwrNUqG9ZUPquC4hY9FyE2qNFo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7 h1:As+tSgow5R48+hFwkgtEBOFYFtvSEqMal2QSMRccWgw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7/go.mod h1:DgSdE3UxodNau1977aGbt4YixHYESTNAEStFo9qJ2TQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
|
||||
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
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=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
@@ -99,6 +101,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
|
||||
@@ -145,8 +149,8 @@ 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -158,22 +162,22 @@ github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLf
|
||||
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.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/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
@@ -185,8 +189,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
@@ -196,8 +200,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -207,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -217,8 +221,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -232,8 +236,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -242,8 +246,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -253,8 +257,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -263,18 +267,18 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
|
||||
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting"
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
@@ -506,6 +507,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "clickup",
|
||||
AlertType: alert.TypeClickUp,
|
||||
AlertingConfig: &alerting.Config{
|
||||
ClickUp: &clickup.AlertProvider{
|
||||
DefaultConfig: clickup.Config{
|
||||
ListID: "test-list-id",
|
||||
Token: "test-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
Generated
+146
-464
@@ -8,16 +8,19 @@
|
||||
"name": "gatus",
|
||||
"version": "4.0.0",
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.45.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"radix-vue": "^1.9.17",
|
||||
"marked": "^16.4.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1861,42 +1864,6 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
|
||||
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/vue": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
|
||||
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
@@ -1912,30 +1879,6 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/vue": {
|
||||
"version": "1.7.23",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/vue-virtual": "^3.0.0-beta.60"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/vue": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1974,24 +1917,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/number": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
|
||||
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||
@@ -2063,6 +1988,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||
@@ -2244,41 +2175,6 @@
|
||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -2472,11 +2368,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.5.3",
|
||||
@@ -3221,44 +3118,6 @@
|
||||
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
|
||||
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "13.6.0",
|
||||
"@vueuse/shared": "13.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
|
||||
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
|
||||
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||
@@ -3670,18 +3529,6 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
||||
@@ -4170,6 +4017,37 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
@@ -5002,6 +4880,16 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@@ -5146,12 +5034,6 @@
|
||||
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5298,6 +5180,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -6237,7 +6128,8 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.2.11",
|
||||
@@ -7798,6 +7690,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
@@ -9535,88 +9439,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue": {
|
||||
"version": "1.9.17",
|
||||
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
|
||||
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.7",
|
||||
"@floating-ui/vue": "^1.1.0",
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@internationalized/number": "^3.5.3",
|
||||
"@tanstack/vue-virtual": "^3.8.1",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/shared": "^10.11.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/nanoid": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -10821,6 +10643,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
@@ -11026,30 +10849,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
@@ -13323,38 +13130,6 @@
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"requires": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"@floating-ui/dom": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
|
||||
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
|
||||
"requires": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
||||
},
|
||||
"@floating-ui/vue": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
|
||||
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
|
||||
"requires": {
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
@@ -13370,20 +13145,6 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"@headlessui/vue": {
|
||||
"version": "1.7.23",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
|
||||
"requires": {
|
||||
"@tanstack/vue-virtual": "^3.0.0-beta.60"
|
||||
}
|
||||
},
|
||||
"@heroicons/vue": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -13407,22 +13168,6 @@
|
||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||
"dev": true
|
||||
},
|
||||
"@internationalized/date": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"requires": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@internationalized/number": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
|
||||
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
|
||||
"requires": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||
@@ -13483,6 +13228,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||
@@ -13629,27 +13379,6 @@
|
||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||
"dev": true
|
||||
},
|
||||
"@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"requires": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="
|
||||
},
|
||||
"@tanstack/vue-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
|
||||
"requires": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
}
|
||||
},
|
||||
"@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -13840,10 +13569,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"optional": true
|
||||
},
|
||||
"@types/ws": {
|
||||
"version": "8.5.3",
|
||||
@@ -14421,27 +14151,6 @@
|
||||
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
||||
"dev": true
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
|
||||
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
|
||||
"requires": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "13.6.0",
|
||||
"@vueuse/shared": "13.6.0"
|
||||
}
|
||||
},
|
||||
"@vueuse/metadata": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
|
||||
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ=="
|
||||
},
|
||||
"@vueuse/shared": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
|
||||
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@webassemblyjs/ast": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||
@@ -14776,14 +14485,6 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true
|
||||
},
|
||||
"aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"requires": {
|
||||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"array-flatten": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
||||
@@ -15123,6 +14824,26 @@
|
||||
"supports-color": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"requires": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"requires": {}
|
||||
},
|
||||
"chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
@@ -15721,6 +15442,11 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@@ -15822,11 +15548,6 @@
|
||||
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
||||
"dev": true
|
||||
},
|
||||
"defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
|
||||
},
|
||||
"depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -15936,6 +15657,14 @@
|
||||
"domelementtype": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"requires": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -16615,7 +16344,8 @@
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.2.11",
|
||||
@@ -17784,6 +17514,11 @@
|
||||
"semver": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"marked": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="
|
||||
},
|
||||
"mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
@@ -18970,60 +18705,6 @@
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"dev": true
|
||||
},
|
||||
"radix-vue": {
|
||||
"version": "1.9.17",
|
||||
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
|
||||
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
|
||||
"requires": {
|
||||
"@floating-ui/dom": "^1.6.7",
|
||||
"@floating-ui/vue": "^1.1.0",
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@internationalized/number": "^3.5.3",
|
||||
"@tanstack/vue-virtual": "^3.8.1",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/shared": "^10.11.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"requires": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
}
|
||||
},
|
||||
"@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="
|
||||
},
|
||||
"@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"requires": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
}
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -19954,7 +19635,8 @@
|
||||
"tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true
|
||||
},
|
||||
"type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -20092,10 +19774,10 @@
|
||||
"@vue/shared": "3.5.18"
|
||||
}
|
||||
},
|
||||
"vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"vue-chartjs": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||
"requires": {}
|
||||
},
|
||||
"vue-eslint-parser": {
|
||||
|
||||
@@ -8,22 +8,29 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.45.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"marked": "^16.4.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@vue/compiler-sfc": "^3.5.18",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script type="text/javascript">
|
||||
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
// Initialize theme immediately to prevent flash
|
||||
(function() {
|
||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||
|
||||
+2
-4
@@ -133,7 +133,7 @@
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="`${SERVER_URL}/oidc/login`"
|
||||
:href="`/oidc/login`"
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full"
|
||||
@click="isOidcLoading = true"
|
||||
>
|
||||
@@ -153,7 +153,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Menu, X, LogIn } from 'lucide-vue-next'
|
||||
@@ -162,7 +161,6 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import Social from './components/Social.vue'
|
||||
import Tooltip from './components/Tooltip.vue'
|
||||
import Loading from './components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -196,7 +194,7 @@ const buttons = computed(() => {
|
||||
// Methods
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
|
||||
const response = await fetch(`/api/v1/config`, { credentials: 'include' })
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
config.value = data
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -44,17 +44,10 @@
|
||||
:key="date"
|
||||
class="relative"
|
||||
>
|
||||
<!-- Vertical line from date to last icon -->
|
||||
<div
|
||||
v-if="group.length > 0"
|
||||
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||
:style="getTimelineHeight(group)"
|
||||
></div>
|
||||
|
||||
<!-- Date Header -->
|
||||
<div class="flex items-center gap-3 mb-2 relative">
|
||||
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<time class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
<time class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ formatDate(date) }}
|
||||
</time>
|
||||
</div>
|
||||
@@ -69,38 +62,60 @@
|
||||
class="relative"
|
||||
>
|
||||
<!-- Timeline Icon -->
|
||||
<div
|
||||
<div
|
||||
:class="[
|
||||
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
|
||||
'absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
|
||||
index === group.length - 1 ? 'top-3' : 'top-1/2 -translate-y-1/2',
|
||||
getTypeClasses(announcement.type).border
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="getTypeIcon(announcement.type)"
|
||||
<component
|
||||
:is="getTypeIcon(announcement.type)"
|
||||
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Vertical line segment connecting upward from first icon to date -->
|
||||
<div
|
||||
v-if="index === 0"
|
||||
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||
style="left: -16px; top: -2.5rem; height: calc(50% + 2.5rem);"
|
||||
></div>
|
||||
|
||||
<!-- Vertical line segment connecting downward to next icon -->
|
||||
<div
|
||||
v-if="index < group.length - 1"
|
||||
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||
:style="{
|
||||
left: '-16px',
|
||||
top: '50%',
|
||||
height: index === group.length - 2 ? 'calc(50% + 1.25rem)' : 'calc(50% + 2rem)'
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Announcement Card -->
|
||||
<div
|
||||
<div
|
||||
:class="[
|
||||
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
|
||||
getTypeClasses(announcement.type).background
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
|
||||
</div>
|
||||
<time
|
||||
<div class="flex items-center gap-3">
|
||||
<time
|
||||
:class="[
|
||||
'text-xs font-mono whitespace-nowrap',
|
||||
'text-sm font-mono whitespace-nowrap flex-shrink-0',
|
||||
getTypeClasses(announcement.type).text
|
||||
]"
|
||||
:title="formatFullTimestamp(announcement.timestamp)"
|
||||
>
|
||||
{{ formatTime(announcement.timestamp) }}
|
||||
</time>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
v-html="formatAnnouncementMessage(announcement.message)"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +131,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
||||
import { formatAnnouncementMessage } from '@/utils/markdown'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
@@ -220,14 +236,6 @@ const getTypeClasses = (type) => {
|
||||
return typeConfigs[type] || typeConfigs.none
|
||||
}
|
||||
|
||||
const getTimelineHeight = (group) => {
|
||||
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
|
||||
return {
|
||||
top: '1.5rem',
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
@@ -291,4 +299,4 @@ const formatFullTimestamp = (timestamp) => {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
@@ -142,8 +141,8 @@ const formattedResponseTime = computed(() => {
|
||||
return `~${avgMs}ms`
|
||||
} else {
|
||||
// Show min-max range
|
||||
const minMs = Math.round(min)
|
||||
const maxMs = Math.round(max)
|
||||
const minMs = Math.trunc(min)
|
||||
const maxMs = Math.trunc(max)
|
||||
// If min and max are the same, show single value
|
||||
if (minMs === maxMs) {
|
||||
return `${minMs}ms`
|
||||
@@ -154,7 +153,8 @@ const formattedResponseTime = computed(() => {
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
|
||||
return generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
|
||||
const oldestResultIndex = Math.max(0, props.endpoint.results.length - props.maxResults)
|
||||
return generatePrettyTimeAgo(props.endpoint.results[oldestResultIndex].timestamp)
|
||||
})
|
||||
|
||||
const newestResultTime = computed(() => {
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed } from 'vue'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div v-if="announcements && announcements.length" class="past-announcements">
|
||||
<h2 class="text-2xl font-semibold text-foreground mb-6">Past Announcements</h2>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div
|
||||
v-for="(group, date) in displayedAnnouncements"
|
||||
:key="date"
|
||||
>
|
||||
<!-- Date Header -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{{ formatDate(date) }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Announcements for this date or empty state -->
|
||||
<div v-if="group.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(announcement, index) in group"
|
||||
:key="`${date}-${index}-${announcement.timestamp}`"
|
||||
:class="[
|
||||
'border-l-4 p-4 transition-all duration-200',
|
||||
getTypeClasses(announcement.type).background,
|
||||
getTypeClasses(announcement.type).borderColor
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<component
|
||||
:is="getTypeIcon(announcement.type)"
|
||||
:class="['w-5 h-5 flex-shrink-0 mt-0.5', getTypeClasses(announcement.type).iconColor]"
|
||||
/>
|
||||
<time
|
||||
:class="[
|
||||
'text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5',
|
||||
getTypeClasses(announcement.type).text
|
||||
]"
|
||||
:title="formatFullTimestamp(announcement.timestamp)"
|
||||
>
|
||||
{{ formatTime(announcement.timestamp) }}
|
||||
</time>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
v-html="formatAnnouncementMessage(announcement.message)"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for dates without announcements -->
|
||||
<div v-else class="py-2">
|
||||
<p class="text-sm italic text-muted-foreground/60">
|
||||
No incidents reported on this day
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Older Announcements Link -->
|
||||
<div v-if="hasOlderAnnouncements && !showAllAnnouncements">
|
||||
<button @click="showAllAnnouncements = true" class="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer group">
|
||||
<ChevronDown class="w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200" />
|
||||
<span class="group-hover:underline">View older announcements</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
||||
import { formatAnnouncementMessage } from '@/utils/markdown'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
announcements: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// State
|
||||
const showAllAnnouncements = ref(false)
|
||||
|
||||
// Type configurations (consistent with AnnouncementBanner)
|
||||
const typeConfigs = {
|
||||
outage: {
|
||||
icon: XCircle,
|
||||
background: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-500 dark:border-red-400',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
text: 'text-red-700 dark:text-red-300'
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
background: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
borderColor: 'border-yellow-500 dark:border-yellow-400',
|
||||
iconColor: 'text-yellow-600 dark:text-yellow-400',
|
||||
text: 'text-yellow-700 dark:text-yellow-300'
|
||||
},
|
||||
information: {
|
||||
icon: Info,
|
||||
background: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-500 dark:border-blue-400',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
text: 'text-blue-700 dark:text-blue-300'
|
||||
},
|
||||
operational: {
|
||||
icon: CheckCircle,
|
||||
background: 'bg-green-50 dark:bg-green-900/20',
|
||||
borderColor: 'border-green-500 dark:border-green-400',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
text: 'text-green-700 dark:text-green-300'
|
||||
},
|
||||
none: {
|
||||
icon: Circle,
|
||||
background: 'bg-gray-50 dark:bg-gray-800/20',
|
||||
borderColor: 'border-gray-500 dark:border-gray-400',
|
||||
iconColor: 'text-gray-600 dark:text-gray-400',
|
||||
text: 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to normalize date to start of day
|
||||
const normalizeDate = (date) => {
|
||||
const normalized = new Date(date)
|
||||
normalized.setHours(0, 0, 0, 0)
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const displayedAnnouncements = computed(() => {
|
||||
if (!props.announcements?.length) return {}
|
||||
|
||||
// Group announcements by date and find oldest
|
||||
const grouped = {}
|
||||
let oldest = new Date()
|
||||
|
||||
props.announcements.forEach(announcement => {
|
||||
const date = new Date(announcement.timestamp)
|
||||
const key = date.toDateString()
|
||||
grouped[key] = grouped[key] || []
|
||||
grouped[key].push(announcement)
|
||||
if (date < oldest) oldest = date
|
||||
})
|
||||
|
||||
// Calculate date range
|
||||
const today = normalizeDate(new Date())
|
||||
const endDate = showAllAnnouncements.value
|
||||
? normalizeDate(oldest)
|
||||
: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Build result: today (if has announcements) + yesterday backwards
|
||||
const result = {}
|
||||
const todayKey = today.toDateString()
|
||||
if (grouped[todayKey]) result[todayKey] = grouped[todayKey]
|
||||
|
||||
for (let date = new Date(today.getTime() - 24 * 60 * 60 * 1000); date >= endDate; date.setDate(date.getDate() - 1)) {
|
||||
result[date.toDateString()] = grouped[date.toDateString()] || []
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Check if there are announcements older than 14 days
|
||||
const hasOlderAnnouncements = computed(() => {
|
||||
if (!props.announcements?.length) return false
|
||||
const fourteenDaysAgo = new Date(normalizeDate(new Date()).getTime() - 14 * 24 * 60 * 60 * 1000)
|
||||
return props.announcements.some(a => new Date(a.timestamp) < fourteenDaysAgo)
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const getTypeIcon = (type) => {
|
||||
return typeConfigs[type]?.icon || Circle
|
||||
}
|
||||
|
||||
const getTypeClasses = (type) => {
|
||||
return typeConfigs[type] || typeConfigs.none
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
const formatFullTimestamp = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="relative w-full" style="height: 300px;">
|
||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loading />
|
||||
</div>
|
||||
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
{{ error }}
|
||||
</div>
|
||||
<Line v-else :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale } from 'chart.js'
|
||||
import annotationPlugin from 'chartjs-plugin-annotation'
|
||||
import 'chartjs-adapter-date-fns'
|
||||
import { generatePrettyTimeDifference } from '@/utils/time'
|
||||
import Loading from './Loading.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale, annotationPlugin)
|
||||
|
||||
const props = defineProps({
|
||||
endpointKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
duration: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['24h', '7d', '30d'].includes(value)
|
||||
},
|
||||
serverUrl: {
|
||||
type: String,
|
||||
default: '..'
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const timestamps = ref([])
|
||||
const values = ref([])
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
const hoveredEventIndex = ref(null)
|
||||
|
||||
// Helper function to get color for unhealthy events
|
||||
const getEventColor = () => {
|
||||
// Only UNHEALTHY events are displayed on the chart
|
||||
return 'rgba(239, 68, 68, 0.8)' // Red
|
||||
}
|
||||
|
||||
// Filter events based on selected duration and calculate durations
|
||||
const filteredEvents = computed(() => {
|
||||
if (!props.events || props.events.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let fromTime
|
||||
switch (props.duration) {
|
||||
case '24h':
|
||||
fromTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '7d':
|
||||
fromTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '30d':
|
||||
fromTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
return []
|
||||
}
|
||||
|
||||
// Only include UNHEALTHY events and calculate their duration
|
||||
const unhealthyEvents = []
|
||||
for (let i = 0; i < props.events.length; i++) {
|
||||
const event = props.events[i]
|
||||
if (event.type !== 'UNHEALTHY') continue
|
||||
|
||||
const eventTime = new Date(event.timestamp)
|
||||
if (eventTime < fromTime || eventTime > now) continue
|
||||
|
||||
// Find the next event to calculate duration
|
||||
let duration = null
|
||||
let isOngoing = false
|
||||
if (i + 1 < props.events.length) {
|
||||
const nextEvent = props.events[i + 1]
|
||||
duration = generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
} else {
|
||||
// Still ongoing - calculate duration from event time to now
|
||||
duration = generatePrettyTimeDifference(now, event.timestamp)
|
||||
isOngoing = true
|
||||
}
|
||||
|
||||
unhealthyEvents.push({
|
||||
...event,
|
||||
duration,
|
||||
isOngoing
|
||||
})
|
||||
}
|
||||
|
||||
return unhealthyEvents
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (timestamps.value.length === 0) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: []
|
||||
}
|
||||
}
|
||||
const labels = timestamps.value.map(ts => new Date(ts))
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Response Time (ms)',
|
||||
data: values.value,
|
||||
borderColor: isDark.value ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)',
|
||||
backgroundColor: isDark.value ? 'rgba(96, 165, 250, 0.1)' : 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
// Include hoveredEventIndex in dependency tracking
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const _ = hoveredEventIndex.value
|
||||
|
||||
// Calculate max Y value for positioning annotations
|
||||
const maxY = values.value.length > 0 ? Math.max(...values.value) : 0
|
||||
const midY = maxY / 2
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark.value ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: isDark.value ? '#f9fafb' : '#111827',
|
||||
bodyColor: isDark.value ? '#d1d5db' : '#374151',
|
||||
borderColor: isDark.value ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => {
|
||||
if (tooltipItems.length > 0) {
|
||||
const date = new Date(tooltipItems[0].parsed.x)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
return ''
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
return `${value}ms`
|
||||
}
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: filteredEvents.value.reduce((acc, event, index) => {
|
||||
// Find closest data point to determine annotation position
|
||||
const eventTimestamp = new Date(event.timestamp).getTime()
|
||||
let closestValue = 0
|
||||
|
||||
if (timestamps.value.length > 0 && values.value.length > 0) {
|
||||
const closestIndex = timestamps.value.reduce((closest, ts, idx) => {
|
||||
const tsTime = new Date(ts).getTime()
|
||||
const currentDistance = Math.abs(tsTime - eventTimestamp)
|
||||
const closestDistance = Math.abs(new Date(timestamps.value[closest]).getTime() - eventTimestamp)
|
||||
return currentDistance < closestDistance ? idx : closest
|
||||
}, 0)
|
||||
closestValue = values.value[closestIndex]
|
||||
}
|
||||
|
||||
// Position annotation at bottom if data point is in lower half, at top if in upper half
|
||||
const position = closestValue <= midY ? 'end' : 'start'
|
||||
|
||||
acc[`event-${index}`] = {
|
||||
type: 'line',
|
||||
xMin: new Date(event.timestamp),
|
||||
xMax: new Date(event.timestamp),
|
||||
borderColor: getEventColor(),
|
||||
borderWidth: 1,
|
||||
borderDash: [5, 5],
|
||||
enter() {
|
||||
hoveredEventIndex.value = index
|
||||
},
|
||||
leave() {
|
||||
hoveredEventIndex.value = null
|
||||
},
|
||||
label: {
|
||||
display: () => hoveredEventIndex.value === index,
|
||||
content: [event.isOngoing ? `Status: ONGOING` : `Status: RESOLVED`, `Unhealthy for ${event.duration}`, `Started at ${new Date(event.timestamp).toLocaleString()}`],
|
||||
backgroundColor: getEventColor(),
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
padding: 6,
|
||||
position
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: props.duration === '24h' ? 'hour' : props.duration === '7d' ? 'day' : 'day',
|
||||
displayFormats: {
|
||||
hour: 'MMM d, ha',
|
||||
day: 'MMM d'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: isDark.value ? '#9ca3af' : '#6b7280',
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 20
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: isDark.value ? '#9ca3af' : '#6b7280',
|
||||
callback: (value) => `${value}ms`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
timestamps.value = data.timestamps || []
|
||||
values.value = data.values || []
|
||||
} else {
|
||||
error.value = 'Failed to load chart data'
|
||||
console.error('[ResponseTimeChart] Error:', await response.text())
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load chart data'
|
||||
console.error('[ResponseTimeChart] Error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.duration, () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
const observer = new MutationObserver(() => {
|
||||
isDark.value = document.documentElement.classList.contains('dark')
|
||||
})
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
@@ -64,9 +64,10 @@ const sortOptions = [
|
||||
|
||||
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
|
||||
|
||||
const handleFilterChange = (value) => {
|
||||
const handleFilterChange = (value, store = true) => {
|
||||
filterBy.value = value
|
||||
localStorage.setItem('gatus:filter-by', value)
|
||||
if (store)
|
||||
localStorage.setItem('gatus:filter-by', value)
|
||||
|
||||
// Reset all filter states first
|
||||
emit('update:showOnlyFailing', false)
|
||||
@@ -80,9 +81,11 @@ const handleFilterChange = (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (value) => {
|
||||
const handleSortChange = (value, store = true) => {
|
||||
sortBy.value = value
|
||||
localStorage.setItem('gatus:sort-by', value)
|
||||
if (store)
|
||||
localStorage.setItem('gatus:sort-by', value)
|
||||
|
||||
emit('update:sortBy', value)
|
||||
emit('update:groupByGroup', value === 'group')
|
||||
|
||||
@@ -93,8 +96,8 @@ const handleSortChange = (value) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apply saved filter/sort state on load
|
||||
handleFilterChange(filterBy.value)
|
||||
handleSortChange(sortBy.value)
|
||||
// Apply saved or application wide filter/sort state on load but do not store it in localstorage
|
||||
handleFilterChange(filterBy.value, false)
|
||||
handleSortChange(sortBy.value, false)
|
||||
})
|
||||
</script>
|
||||
@@ -54,7 +54,6 @@
|
||||
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Sun, Moon, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="text-xs text-muted-foreground">Success Rate: {{ successRate }}%</p>
|
||||
<p class="text-xs text-muted-foreground" v-if="averageDuration">{{ averageDuration }}ms avg</p>
|
||||
<p class="text-xs text-muted-foreground" v-if="averageDuration !== null">{{ averageDuration }}ms avg</p>
|
||||
</div>
|
||||
<div class="flex gap-0.5">
|
||||
<div
|
||||
@@ -126,7 +126,7 @@ const averageDuration = computed(() => {
|
||||
|
||||
const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0)
|
||||
// Convert nanoseconds to milliseconds
|
||||
return Math.round((total / props.suite.results.length) / 1000000)
|
||||
return Math.trunc((total / props.suite.results.length) / 1000000)
|
||||
})
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
{{ endpoint.success ? '✓' : '✗' }}
|
||||
</span>
|
||||
<span class="truncate">{{ endpoint.name }}</span>
|
||||
<span class="text-muted-foreground">({{ (endpoint.duration / 1000000).toFixed(0) }}ms)</span>
|
||||
<span class="text-muted-foreground">({{ Math.trunc(endpoint.duration / 1000000) }}ms)</span>
|
||||
</div>
|
||||
<div v-if="result.endpointResults.length > 5" class="text-xs text-muted-foreground">
|
||||
... and {{ result.endpointResults.length - 5 }} more
|
||||
@@ -60,7 +60,7 @@
|
||||
{{ isSuiteResult ? 'Total Duration' : 'Response Time' }}
|
||||
</div>
|
||||
<div class="font-mono text-xs">
|
||||
{{ isSuiteResult ? (result.duration / 1000000).toFixed(0) : (result.duration / 1000000).toFixed(0) }}ms
|
||||
{{ Math.trunc(result.duration / 1000000) }}ms
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { prettifyTimestamp } from '@/utils/time'
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -10,8 +10,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronDown, Check } from 'lucide-vue-next'
|
||||
|
||||
|
||||
@@ -3,6 +3,4 @@ import App from './App.vue'
|
||||
import './index.css'
|
||||
import router from './router'
|
||||
|
||||
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
@@ -10,7 +10,7 @@ export const formatDuration = (duration) => {
|
||||
const durationMs = duration / 1000000
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs.toFixed(0)}ms`
|
||||
return `${Math.trunc(durationMs)}ms`
|
||||
} else {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const escapeHtml = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.link = (tokenOrHref, title, text) => {
|
||||
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
|
||||
? tokenOrHref
|
||||
: null
|
||||
const href = tokenObject ? tokenObject.href : tokenOrHref
|
||||
const resolvedTitle = tokenObject ? tokenObject.title : title
|
||||
const resolvedText = tokenObject ? tokenObject.text : text
|
||||
const url = escapeHtml(href || '')
|
||||
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
|
||||
const linkText = resolvedText || ''
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium"${titleAttribute}>${linkText}</a>`
|
||||
}
|
||||
|
||||
marked.use({
|
||||
renderer,
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
headerIds: false,
|
||||
mangle: false
|
||||
})
|
||||
|
||||
export const formatAnnouncementMessage = (message) => {
|
||||
if (!message) {
|
||||
return ''
|
||||
}
|
||||
const markdown = String(message)
|
||||
const html = marked.parse(markdown)
|
||||
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
|
||||
}
|
||||
@@ -31,8 +31,28 @@ export const generatePrettyTimeAgo = (timestamp) => {
|
||||
* @returns {string} Time difference string
|
||||
*/
|
||||
export const generatePrettyTimeDifference = (start, end) => {
|
||||
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
|
||||
return minutes + (minutes === 1 ? ' minute' : ' minutes');
|
||||
const ms = new Date(start) - new Date(end)
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60
|
||||
const hoursText = hours + (hours === 1 ? ' hour' : ' hours')
|
||||
if (remainingMinutes > 0) {
|
||||
return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes')
|
||||
}
|
||||
return hoursText
|
||||
} else if (minutes > 0) {
|
||||
const remainingSeconds = seconds % 60
|
||||
const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes')
|
||||
if (remainingSeconds > 0) {
|
||||
return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds')
|
||||
}
|
||||
return minutesText
|
||||
} else {
|
||||
return seconds + (seconds === 1 ? ' second' : ' seconds')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
|
||||
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -89,13 +89,13 @@
|
||||
<EndpointCard
|
||||
v-if="endpointStatus"
|
||||
:endpoint="endpointStatus"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
class="border-0 shadow-none bg-transparent p-0"
|
||||
/>
|
||||
<div v-if="endpointStatus && endpointStatus.key" class="pt-4 border-t">
|
||||
<Pagination @page="changePage" :numberOfResultsPerPage="50" :currentPageProp="currentPage" />
|
||||
<Pagination @page="changePage" :numberOfResultsPerPage="resultPageSize" :currentPageProp="currentPage" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -117,7 +117,13 @@
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
|
||||
<ResponseTimeChart
|
||||
v-if="endpointStatus && endpointStatus.key"
|
||||
:endpointKey="endpointStatus.key"
|
||||
:duration="selectedChartDuration"
|
||||
:serverUrl="serverUrl"
|
||||
:events="endpointStatus.events || []"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -195,7 +201,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, ArrowUpCircle, ArrowDownCircle, PlayCircle, Activity, Timer } from 'lucide-vue-next'
|
||||
@@ -206,7 +211,7 @@ import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
|
||||
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -217,10 +222,10 @@ const endpointStatus = ref(null) // For paginated historical data
|
||||
const currentStatus = ref(null) // For current/latest status (always page 1)
|
||||
const events = ref([])
|
||||
const currentPage = ref(1)
|
||||
const resultPageSize = 50
|
||||
const showResponseTimeChartAndBadges = ref(false)
|
||||
const showAverageResponseTime = ref(false)
|
||||
const selectedChartDuration = ref('24h')
|
||||
const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const latestResult = computed(() => {
|
||||
@@ -254,7 +259,7 @@ const pageAverageResponseTime = computed(() => {
|
||||
}
|
||||
}
|
||||
if (count === 0) return 'N/A'
|
||||
return Math.round(total / count / 1000000)
|
||||
return `${Math.round(total / count / 1000000)}ms`
|
||||
})
|
||||
|
||||
const pageResponseTimeRange = computed(() => {
|
||||
@@ -267,17 +272,17 @@ const pageResponseTimeRange = computed(() => {
|
||||
let hasData = false
|
||||
|
||||
for (const result of endpointStatus.value.results) {
|
||||
if (result.duration) {
|
||||
const durationMs = result.duration / 1000000
|
||||
min = Math.min(min, durationMs)
|
||||
max = Math.max(max, durationMs)
|
||||
const duration = result.duration
|
||||
if (duration) {
|
||||
min = Math.min(min, duration)
|
||||
max = Math.max(max, duration)
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasData) return 'N/A'
|
||||
const minMs = Math.round(min)
|
||||
const maxMs = Math.round(max)
|
||||
const minMs = Math.trunc(min / 1000000)
|
||||
const maxMs = Math.trunc(max / 1000000)
|
||||
// If min and max are the same, show single value
|
||||
if (minMs === maxMs) {
|
||||
return `${minMs}ms`
|
||||
@@ -297,7 +302,7 @@ const lastCheckTime = computed(() => {
|
||||
const fetchData = async () => {
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
|
||||
const response = await fetch(`/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
@@ -378,19 +383,15 @@ const prettifyTimestamp = (timestamp) => {
|
||||
}
|
||||
|
||||
const generateHealthBadgeImageURL = () => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
|
||||
return `/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
|
||||
}
|
||||
|
||||
const generateUptimeBadgeImageURL = (duration) => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
|
||||
return `/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
|
||||
}
|
||||
|
||||
const generateResponseTimeBadgeImageURL = (duration) => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
|
||||
}
|
||||
|
||||
const generateResponseTimeChartImageURL = (duration) => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
|
||||
return `/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
+34
-12
@@ -4,8 +4,8 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
|
||||
<h1 class="text-4xl font-bold tracking-tight">{{ dashboardHeading }}</h1>
|
||||
<p class="text-muted-foreground mt-2">{{ dashboardSubheading }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
@@ -22,8 +22,8 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Announcement Banner -->
|
||||
<AnnouncementBanner :announcements="props.announcements" />
|
||||
<!-- Announcement Banner (Active Announcements) -->
|
||||
<AnnouncementBanner :announcements="activeAnnouncements" />
|
||||
<!-- Search bar -->
|
||||
<SearchBar
|
||||
@search="handleSearch"
|
||||
@@ -82,7 +82,7 @@
|
||||
v-for="suite in items.suites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@
|
||||
v-for="endpoint in items.endpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
@@ -116,7 +116,7 @@
|
||||
v-for="suite in paginatedSuites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@
|
||||
v-for="endpoint in paginatedEndpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
@@ -170,6 +170,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past Announcements Section -->
|
||||
<div v-if="archivedAnnouncements.length > 0" class="mt-12 pb-8">
|
||||
<PastAnnouncements :announcements="archivedAnnouncements" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Settings @refreshData="fetchData" />
|
||||
@@ -177,7 +182,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -187,7 +191,7 @@ import SearchBar from '@/components/SearchBar.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
import PastAnnouncements from '@/components/PastAnnouncements.vue'
|
||||
|
||||
const props = defineProps({
|
||||
announcements: {
|
||||
@@ -196,6 +200,15 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// Computed properties for active and archived announcements
|
||||
const activeAnnouncements = computed(() => {
|
||||
return props.announcements ? props.announcements.filter(a => !a.archived) : []
|
||||
})
|
||||
|
||||
const archivedAnnouncements = computed(() => {
|
||||
return props.announcements ? props.announcements.filter(a => a.archived) : []
|
||||
})
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const endpointStatuses = ref([])
|
||||
@@ -210,6 +223,7 @@ const showAverageResponseTime = ref(true)
|
||||
const groupByGroup = ref(false)
|
||||
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
|
||||
const uncollapsedGroups = ref(new Set())
|
||||
const resultPageSize = 50
|
||||
|
||||
const filteredEndpoints = computed(() => {
|
||||
let filtered = [...endpointStatuses.value]
|
||||
@@ -418,7 +432,7 @@ const fetchData = async () => {
|
||||
}
|
||||
try {
|
||||
// Fetch endpoints
|
||||
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
|
||||
const endpointResponse = await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (endpointResponse.status === 200) {
|
||||
@@ -429,7 +443,7 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
// Fetch suites
|
||||
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=100`, {
|
||||
const suiteResponse = await fetch(`/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (suiteResponse.status === 200) {
|
||||
@@ -517,6 +531,14 @@ const initializeCollapsedGroups = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const dashboardHeading = computed(() => {
|
||||
return window.config && window.config.dashboardHeading && window.config.dashboardHeading !== '{{ .UI.DashboardHeading }}' ? window.config.dashboardHeading : "Health Dashboard"
|
||||
})
|
||||
|
||||
const dashboardSubheading = computed(() => {
|
||||
return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.DashboardSubheading }}' ? window.config.dashboardSubheading : "Monitor the health of your endpoints in real-time"
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
@@ -142,7 +142,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, AlertCircle, ChevronRight } from 'lucide-vue-next'
|
||||
@@ -154,7 +153,7 @@ import StepDetailsModal from '@/components/StepDetailsModal.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { generatePrettyTimeAgo } from '@/utils/time'
|
||||
import { SERVER_URL } from '@/main'
|
||||
import { formatDuration } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -191,7 +190,7 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
|
||||
const response = await fetch(`/api/v1/suites/${route.params.key}/statuses`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
@@ -240,19 +239,6 @@ const formatTimestamp = (timestamp) => {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (duration) => {
|
||||
if (!duration && duration !== 0) return 'N/A'
|
||||
|
||||
// Convert nanoseconds to milliseconds
|
||||
const durationMs = duration / 1000000
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs.toFixed(0)}ms`
|
||||
} else {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
}
|
||||
|
||||
const calculateSuccessRate = (result) => {
|
||||
if (!result || !result.endpointResults || result.endpointResults.length === 0) {
|
||||
return 0
|
||||
|
||||
+16
-2
@@ -6,5 +6,19 @@ module.exports = {
|
||||
filenameHashing: false,
|
||||
productionSourceMap: false,
|
||||
outputDir: '../static',
|
||||
publicPath: '/'
|
||||
}
|
||||
publicPath: '/',
|
||||
devServer: {
|
||||
port: 8081,
|
||||
https: false,
|
||||
client: {
|
||||
webSocketURL:'auto://0.0.0.0/ws'
|
||||
},
|
||||
proxy: {
|
||||
'^/api|^/css|^/oicd': {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
// Initialize theme immediately to prevent flash
|
||||
(function() {
|
||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user