Compare commits

...
12 Commits
Author SHA1 Message Date
TwiN 379ec2983d refactor(announcements): Move duplicate markdown code into utils/markdown.js 2025-11-08 13:54:36 -05:00
SworyzandGitHub 907716289c feat(announcements): add markdown support (#1371)
* feat(announcements): add markdown support

* feat(announcements): add information about announcement formatting in readme

* feat(announcements): bump packages versions for marked and dompurify

* feat(announcements): bump versions for marked and dompurify in package-lock.json

* fix(announcements): md to link was not working since the conflict merge

* fix(announcements): fix time before message and not after

* feat(announcements): past announcements add markdown support

* feat(announcements): static files
2025-11-08 13:28:57 -05:00
TwiN 7c6b5539c1 docs: Add past-announcements.jpg 2025-11-07 19:42:48 -05:00
TwiNandGitHub 607f3c5549 feat(announcements): Add support for archived announcements and add past announcement section in UI (#1382)
* feat(announcements): Add support for archived announcements and add past announcement section in UI

* Add missing field
2025-11-07 19:35:39 -05:00
Arden RasmussenandGitHub 9e97efaba1 fix(api): Escape endpoint key in URL for raw APIs (#1381) 2025-11-07 18:20:10 -05:00
diamanatandGitHub 8912b4b3e3 feat(client): Add support for monitoring gRPC endpoints (#1376)
* add grpc

* add gRPC to readme
2025-11-07 15:19:13 -05:00
5fdc489113 fix(client): update icmp/ping logic to determine pinger privileged mode (#1346)
* fix(pinger): update logic to determine pinger privileged mode

* add some unit tests for pinger

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* undo accidental removal

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* check for cap_net_raw by trying to open a raw socket and checking for permission error

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* revert syscall after testing. It is unable to build a binary on windows

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* remove extra import

* review icmp section of readme. No changes required

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* Update client/client.go

Co-authored-by: TwiN <twin@linux.com>

* Update client/client.go

Match function name

Co-authored-by: TwiN <twin@linux.com>

* Update client/client.go

Remove extra line

Co-authored-by: TwiN <twin@linux.com>

---------

Signed-off-by: Zee Aslam <zeet6613@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-11-04 20:42:20 -05:00
GiampaoloandGitHub 2ebb74ae1e docs: Add ECS fargate module in README (#1377) 2025-11-02 00:04:28 -04:00
TwiNandGitHub e2f06e9ede fix(ui): Modernize response time chart (#1373) 2025-10-29 14:15:59 -04:00
TwiNandGitHub beb9a2f3d9 feat(condition): Format certificate and domain expiration durations in human-readable format (#1370) 2025-10-27 16:30:12 -04:00
MaxandGitHub e469b6adf4 fix(external-endpoint): check per-endpoint maintenance windows (#1369)
* fix(external-endpoint): check per-endpoint maintenance windows

* refactor(external-endpoint): use tabs for indentation
2025-10-27 12:21:20 -04:00
2f8a3d2a02 feat(metrics): Add metrics for domain expiration (#1244)
* Add metrics for domain expiration

* Update grafana and prometheus versions and extend grafana dashboard with Domain expiration

* feat(deps) update whois version

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-25 12:45:15 -04:00
42 changed files with 1836 additions and 727 deletions
@@ -16,4 +16,10 @@ endpoints:
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- "[STATUS] == 200"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus
@@ -13,7 +12,7 @@ services:
prometheus:
container_name: prometheus
image: prom/prometheus:v2.14.0
image: prom/prometheus:v3.5.0
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
@@ -25,7 +24,7 @@ services:
grafana:
container_name: grafana
image: grafana/grafana:6.4.4
image: grafana/grafana:12.1.0
restart: always
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
@@ -19,7 +19,7 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 566,
"id": 41,
"links": [],
"panels": [
{
@@ -39,7 +39,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "yellow",
@@ -79,7 +80,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -122,7 +123,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "yellow",
@@ -162,7 +164,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -193,7 +195,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
}
]
},
@@ -225,7 +228,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -292,7 +295,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -321,7 +324,7 @@
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"description": "Domain expiration times for all domains",
"fieldConfig": {
"defaults": {
"color": {
@@ -339,7 +342,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "#EAB839",
@@ -395,7 +399,137 @@
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"editorMode": "code",
"expr": "gatus_results_domain_expiration_seconds",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
}
],
"title": "Domain Expiration",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": false,
"__name__": true,
"app_kubernetes_io_instance": true,
"app_kubernetes_io_managed_by": true,
"app_kubernetes_io_name": true,
"app_kubernetes_io_service": true,
"helm_sh_chart": true,
"instance": true,
"job": true,
"key": true,
"type": true
},
"includeByName": {},
"indexByName": {
"Value": 2,
"group": 0,
"name": 1
},
"renameByName": {
"Value": "Time Until Expiry",
"group": "Group",
"name": "Service"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "#EAB839",
"value": 172800
},
{
"color": "green",
"value": 604800
}
]
},
"unit": "dtdurations"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Time Until Expiry"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"applyToRow": false,
"type": "color-background"
}
},
{
"id": "unit",
"value": "s"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 11,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -444,6 +578,113 @@
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
@@ -483,7 +724,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "green",
@@ -525,7 +767,7 @@
"h": 8,
"w": 12,
"x": 12,
"y": 8
"y": 16
},
"id": 2,
"options": {
@@ -546,7 +788,7 @@
}
]
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -689,7 +931,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -703,9 +946,9 @@
},
"gridPos": {
"h": 8,
"w": 12,
"w": 24,
"x": 0,
"y": 16
"y": 24
},
"id": 4,
"options": {
@@ -721,7 +964,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -735,112 +978,6 @@
],
"title": "Response Times",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
}
],
"preload": false,
@@ -855,8 +992,8 @@
"list": [
{
"current": {
"text": "myprom",
"value": "PA04845DA3A4B088E"
"text": "prometheus",
"value": "cedv077q7bbwgd"
},
"description": "Select your Prometheus datasource",
"includeAll": false,
@@ -877,6 +1014,6 @@
"timepicker": {},
"timezone": "",
"title": "Gatus - Service Monitoring Dashboard",
"uid": "gatus-monitoring2",
"version": 10
}
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
"version": 1
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+88 -29
View File
@@ -109,6 +109,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Docker](#docker)
- [Helm Chart](#helm-chart)
- [Terraform](#terraform)
- [Kubernetes](#kubernetes)
- [ECS Fargate](#ecs-fargate)
- [Running the tests](#running-the-tests)
- [Using in Production](#using-in-production)
- [FAQ](#faq)
@@ -119,6 +121,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)
@@ -371,13 +374,13 @@ Where:
- 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).
- `{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 +422,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 +436,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 +447,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 +458,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
@@ -523,18 +526,21 @@ Here are some examples of conditions you can use:
### 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 +548,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:
![Gatus past announcements section](.github/assets/past-announcements.jpg)
### Storage
| Parameter | Description | Default |
@@ -707,7 +720,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 +757,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.
@@ -2180,7 +2193,7 @@ Here's an example of what the notifications look like:
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
| `alerting.telegram.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 |
@@ -2676,6 +2689,7 @@ endpoint on the same port your application is configured to run on (`web.port`).
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_domain_expiration_seconds | gauge | Number of seconds until the domains expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
@@ -2790,8 +2804,14 @@ 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).
#### ECS Fargate
Gatus can be deployed on ECS Fargate using Terraform by using the following module: [terraform-aws-gatus-ecs](https://github.com/GiamPy5/terraform-aws-gatus-ecs).
## Running the tests
```console
@@ -2835,7 +2855,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.
@@ -2943,8 +2963,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
@@ -2961,7 +3020,7 @@ 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.
@@ -3079,7 +3138,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`.
@@ -3108,7 +3167,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:**
@@ -3192,7 +3251,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
+1
View File
@@ -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
+60
View File
@@ -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,
})
}
+66
View File
@@ -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)
}
})
}
}
+10 -1
View File
@@ -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
View File
@@ -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) {
+21 -6
View File
@@ -13,6 +13,7 @@ import (
"net"
"net/http"
"net/smtp"
"os"
"runtime"
"strings"
"time"
@@ -343,12 +344,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,6 +360,25 @@ 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 (
+29
View File
@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/netip"
"os"
"runtime"
"testing"
"time"
@@ -129,6 +131,33 @@ 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
+71
View File
@@ -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)
}
+4
View File
@@ -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
+241
View File
@@ -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)
}
}
+40 -1
View File
@@ -209,7 +209,46 @@ 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"
if strings.HasSuffix(s, "0m") {
s = strings.TrimSuffix(s, "0m")
}
}
return s
}
// prettify returns a string representation of a condition with its parameters resolved between parentheses
+2 -2
View File
@@ -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",
+16
View File
@@ -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://"):
@@ -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)
+1 -1
View File
@@ -33,6 +33,7 @@ require (
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.17.0
google.golang.org/api v0.252.0
google.golang.org/grpc v1.75.1
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.39.1
@@ -95,7 +96,6 @@ require (
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
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
+14
View File
@@ -17,6 +17,7 @@ var (
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultDomainExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Suite metrics
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultDomainExpirationSeconds != nil {
currentRegisterer.Unregister(resultDomainExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds)
resultDomainExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_domain_expiration_seconds",
Help: "Number of seconds until the domain expires",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDomainExpirationSeconds)
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_endpoint_success",
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
}
if result.DomainExpiration != 0 {
resultDomainExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.DomainExpiration.Seconds())
}
if result.Success {
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else {
+13
View File
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized")
}
if resultDomainExpirationSeconds == nil {
t.Error("resultDomainExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized")
}
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: true,
CertificateExpiration: 49 * time.Hour,
DomainExpiration: 25 * time.Hour,
}, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -140,6 +145,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 90000
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
@@ -154,9 +162,11 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: false,
CertificateExpiration: 47 * time.Hour,
DomainExpiration: 24 * time.Hour,
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -175,6 +185,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 86400
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
+146 -464
View File
@@ -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 -1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+36 -28
View File
@@ -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>
@@ -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>
+1 -1
View File
@@ -7,7 +7,7 @@
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
variant: {
+1 -1
View File
@@ -10,7 +10,7 @@
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
variant: {
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
+1 -1
View File
@@ -11,7 +11,7 @@
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
modelValue: {
+46
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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'] })
}
+22 -2
View File
@@ -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')
}
}
/**
+8 -5
View File
@@ -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>
@@ -206,6 +212,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 ResponseTimeChart from '@/components/ResponseTimeChart.vue'
import { SERVER_URL } from '@/main.js'
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
@@ -389,10 +396,6 @@ 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`
}
onMounted(() => {
fetchData()
})
+17 -2
View File
@@ -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"
@@ -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" />
@@ -187,6 +192,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 PastAnnouncements from '@/components/PastAnnouncements.vue'
import { SERVER_URL } from '@/main.js'
const props = defineProps({
@@ -196,6 +202,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([])
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long