mirror of
https://github.com/TwiN/gatus.git
synced 2026-09-24 00:34:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d626f2934 | ||
|
|
75c1b290f6 | ||
|
|
fe7b74f555 | ||
|
|
ed4c270a25 | ||
|
|
379ec2983d | ||
|
|
907716289c | ||
|
|
7c6b5539c1 | ||
|
|
607f3c5549 | ||
|
|
9e97efaba1 | ||
|
|
8912b4b3e3 | ||
|
|
5fdc489113 | ||
|
|
2ebb74ae1e | ||
|
|
e2f06e9ede | ||
|
|
beb9a2f3d9 | ||
|
|
e469b6adf4 | ||
|
|
2f8a3d2a02 |
@@ -17,3 +17,9 @@ endpoints:
|
|||||||
interval: 5m
|
interval: 5m
|
||||||
conditions:
|
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:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
container_name: gatus
|
container_name: gatus
|
||||||
@@ -13,7 +12,7 @@ services:
|
|||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
container_name: prometheus
|
container_name: prometheus
|
||||||
image: prom/prometheus:v2.14.0
|
image: prom/prometheus:v3.5.0
|
||||||
restart: always
|
restart: always
|
||||||
command: --config.file=/etc/prometheus/prometheus.yml
|
command: --config.file=/etc/prometheus/prometheus.yml
|
||||||
ports:
|
ports:
|
||||||
@@ -25,7 +24,7 @@ services:
|
|||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
container_name: grafana
|
container_name: grafana
|
||||||
image: grafana/grafana:6.4.4
|
image: grafana/grafana:12.1.0
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: secret
|
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||||
|
|||||||
+265
-128
@@ -19,7 +19,7 @@
|
|||||||
"editable": true,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 0,
|
"graphTooltip": 0,
|
||||||
"id": 566,
|
"id": 41,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "green"
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
"textMode": "auto",
|
"textMode": "auto",
|
||||||
"wideLayout": true
|
"wideLayout": true
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -122,7 +123,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "red"
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
@@ -162,7 +164,7 @@
|
|||||||
"textMode": "auto",
|
"textMode": "auto",
|
||||||
"wideLayout": true
|
"wideLayout": true
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -193,7 +195,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "green"
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -225,7 +228,7 @@
|
|||||||
"textMode": "auto",
|
"textMode": "auto",
|
||||||
"wideLayout": true
|
"wideLayout": true
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -292,7 +295,7 @@
|
|||||||
"sort": "none"
|
"sort": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -321,7 +324,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "$datasource"
|
"uid": "$datasource"
|
||||||
},
|
},
|
||||||
"description": "SSL certificate expiration times for all services",
|
"description": "Domain expiration times for all domains",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -339,7 +342,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "red"
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "#EAB839",
|
"color": "#EAB839",
|
||||||
@@ -395,7 +399,137 @@
|
|||||||
"showHeader": true,
|
"showHeader": true,
|
||||||
"sortBy": []
|
"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": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -444,6 +578,113 @@
|
|||||||
],
|
],
|
||||||
"type": "table"
|
"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": {
|
"datasource": {
|
||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
@@ -483,7 +724,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "red"
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "green",
|
"color": "green",
|
||||||
@@ -525,7 +767,7 @@
|
|||||||
"h": 8,
|
"h": 8,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 8
|
"y": 16
|
||||||
},
|
},
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -546,7 +788,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -689,7 +931,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "green"
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "red",
|
"color": "red",
|
||||||
@@ -703,9 +946,9 @@
|
|||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 8,
|
"h": 8,
|
||||||
"w": 12,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 16
|
"y": 24
|
||||||
},
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -721,7 +964,7 @@
|
|||||||
"sort": "none"
|
"sort": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -735,112 +978,6 @@
|
|||||||
],
|
],
|
||||||
"title": "Response Times",
|
"title": "Response Times",
|
||||||
"type": "timeseries"
|
"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,
|
"preload": false,
|
||||||
@@ -855,8 +992,8 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"current": {
|
"current": {
|
||||||
"text": "myprom",
|
"text": "prometheus",
|
||||||
"value": "PA04845DA3A4B088E"
|
"value": "cedv077q7bbwgd"
|
||||||
},
|
},
|
||||||
"description": "Select your Prometheus datasource",
|
"description": "Select your Prometheus datasource",
|
||||||
"includeAll": false,
|
"includeAll": false,
|
||||||
@@ -877,6 +1014,6 @@
|
|||||||
"timepicker": {},
|
"timepicker": {},
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "Gatus - Service Monitoring Dashboard",
|
"title": "Gatus - Service Monitoring Dashboard",
|
||||||
"uid": "gatus-monitoring2",
|
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
|
||||||
"version": 10
|
"version": 1
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -109,6 +109,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Docker](#docker)
|
- [Docker](#docker)
|
||||||
- [Helm Chart](#helm-chart)
|
- [Helm Chart](#helm-chart)
|
||||||
- [Terraform](#terraform)
|
- [Terraform](#terraform)
|
||||||
|
- [Kubernetes](#kubernetes)
|
||||||
- [Running the tests](#running-the-tests)
|
- [Running the tests](#running-the-tests)
|
||||||
- [Using in Production](#using-in-production)
|
- [Using in Production](#using-in-production)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
@@ -119,6 +120,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
|
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
|
||||||
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
|
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
|
||||||
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-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 ICMP](#monitoring-an-endpoint-using-icmp)
|
||||||
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||||
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
|
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
|
||||||
@@ -261,6 +263,8 @@ If you want to test it locally, see [Docker](#docker).
|
|||||||
| `ui` | UI configuration. | `{}` |
|
| `ui` | UI configuration. | `{}` |
|
||||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||||
|
| `ui.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` |
|
||||||
|
| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` |
|
||||||
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
||||||
| `ui.logo` | URL to the logo to display. | `""` |
|
| `ui.logo` | URL to the logo to display. | `""` |
|
||||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||||
@@ -523,14 +527,17 @@ Here are some examples of conditions you can use:
|
|||||||
|
|
||||||
|
|
||||||
### Announcements
|
### 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 |
|
This is essentially what some status page calls "incident communications".
|
||||||
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
|
|
||||||
| `announcements` | List of announcements to display | `[]` |
|
| Parameter | Description | Default |
|
||||||
| `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` | List of announcements to display | `[]` |
|
||||||
| `announcements[].message` | The message to display to users | Required |
|
| `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:
|
Types:
|
||||||
- **outage**: Indicates service disruptions or critical issues (red theme)
|
- **outage**: Indicates service disruptions or critical issues (red theme)
|
||||||
@@ -542,17 +549,24 @@ Types:
|
|||||||
Example Configuration:
|
Example Configuration:
|
||||||
```yaml
|
```yaml
|
||||||
announcements:
|
announcements:
|
||||||
- timestamp: 2025-08-15T14:00:00Z
|
- timestamp: 2025-11-07T14:00:00Z
|
||||||
type: outage
|
type: outage
|
||||||
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
||||||
- timestamp: 2025-08-15T16:15:00Z
|
- timestamp: 2025-11-07T16:15:00Z
|
||||||
type: operational
|
type: operational
|
||||||
message: "Database maintenance completed successfully. All systems operational."
|
message: "Database maintenance completed successfully. All systems operational."
|
||||||
- timestamp: 2025-08-15T12:00:00Z
|
- timestamp: 2025-11-07T12:00:00Z
|
||||||
type: information
|
type: information
|
||||||
message: "New monitoring dashboard features will be deployed next week"
|
message: "New monitoring dashboard features will be deployed next week"
|
||||||
|
- timestamp: 2025-11-06T09:00:00Z
|
||||||
|
type: warning
|
||||||
|
message: "Elevated API response times observed for US customers"
|
||||||
|
archived: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
@@ -2676,6 +2690,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_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_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_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 |
|
| 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.
|
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
|
||||||
@@ -2790,8 +2805,10 @@ To get more details, please check [chart's configuration](https://github.com/Twi
|
|||||||
|
|
||||||
|
|
||||||
### Terraform
|
### Terraform
|
||||||
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
|
|
||||||
|
|
||||||
|
#### Kubernetes
|
||||||
|
|
||||||
|
Gatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
|
||||||
|
|
||||||
## Running the tests
|
## Running the tests
|
||||||
```console
|
```console
|
||||||
@@ -2947,6 +2964,45 @@ shows whether the connection was successfully established. You can use Go templa
|
|||||||
syntax.
|
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
|
### Monitoring an endpoint using ICMP
|
||||||
By prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more
|
By prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more
|
||||||
commonly known as "ping" or "echo":
|
commonly known as "ping" or "echo":
|
||||||
@@ -2961,9 +3017,12 @@ endpoints:
|
|||||||
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
|
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://`.
|
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.
|
if you encounter any problems.
|
||||||
|
|
||||||
|
Prior to `v5.31.0`, some environment setups required adding `CAP_NET_RAW` capabilities to allow pings to work.
|
||||||
|
As of `v5.31.0`, this is no longer necessary, and ICMP checks will work with unprivileged pings unless running as root. See #1346 for details.
|
||||||
|
|
||||||
|
|
||||||
### Monitoring an endpoint using DNS queries
|
### Monitoring an endpoint using DNS queries
|
||||||
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
|
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
|
||||||
|
|||||||
@@ -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", ResponseTimeRaw)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
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/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
|
// This endpoint requires authz with bearer token, so technically it is protected
|
||||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||||
// SPA
|
// SPA
|
||||||
|
|||||||
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResponseTimeHistory(c *fiber.Ctx) error {
|
||||||
|
duration := c.Params("duration")
|
||||||
|
var from time.Time
|
||||||
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
|
||||||
|
case "7d":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
|
||||||
|
case "24h":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||||
|
default:
|
||||||
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||||
|
}
|
||||||
|
endpointKey, err := url.QueryUnescape(c.Params("key"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).SendString("invalid key encoding")
|
||||||
|
}
|
||||||
|
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
}
|
||||||
|
if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
if len(hourlyAverageResponseTime) == 0 {
|
||||||
|
return c.Status(200).JSON(map[string]interface{}{
|
||||||
|
"timestamps": []int64{},
|
||||||
|
"values": []int{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
|
||||||
|
earliestTimestamp := int64(0)
|
||||||
|
for hourlyTimestamp := range hourlyAverageResponseTime {
|
||||||
|
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
|
||||||
|
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
|
||||||
|
earliestTimestamp = hourlyTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for earliestTimestamp > from.Unix() {
|
||||||
|
earliestTimestamp -= int64(time.Hour.Seconds())
|
||||||
|
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
|
||||||
|
}
|
||||||
|
sort.Ints(hourlyTimestamps)
|
||||||
|
timestamps := make([]int64, 0, len(hourlyTimestamps))
|
||||||
|
values := make([]int, 0, len(hourlyTimestamps))
|
||||||
|
for _, hourlyTimestamp := range hourlyTimestamps {
|
||||||
|
timestamp := int64(hourlyTimestamp)
|
||||||
|
averageResponseTime := hourlyAverageResponseTime[timestamp]
|
||||||
|
timestamps = append(timestamps, timestamp*1000)
|
||||||
|
values = append(values, averageResponseTime)
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]interface{}{
|
||||||
|
"timestamps": timestamps,
|
||||||
|
"values": values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseTimeHistory(t *testing.T) {
|
||||||
|
defer store.Get().Clear()
|
||||||
|
defer cache.Clear()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Metrics: true,
|
||||||
|
Endpoints: []*endpoint.Endpoint{
|
||||||
|
{
|
||||||
|
Name: "frontend",
|
||||||
|
Group: "core",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "backend",
|
||||||
|
Group: "core",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
|
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
|
api := New(cfg)
|
||||||
|
router := api.Router()
|
||||||
|
type Scenario struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
ExpectedCode int
|
||||||
|
}
|
||||||
|
scenarios := []Scenario{
|
||||||
|
{
|
||||||
|
Name: "history-response-time-24h",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-7d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-30d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-with-invalid-duration",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
|
||||||
|
ExpectedCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||||
|
response, err := router.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != scenario.ExpectedCode {
|
||||||
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
|||||||
return c.Status(500).SendString(err.Error())
|
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)
|
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
|
// Check if an alert should be triggered or resolved
|
||||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
||||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||||
|
} else {
|
||||||
|
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
|
||||||
}
|
}
|
||||||
if cfg.Metrics {
|
if cfg.Metrics {
|
||||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||||
|
|||||||
+9
-2
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
|
|||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
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())
|
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
|
|||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
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())
|
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
|||||||
+21
-6
@@ -13,6 +13,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -343,12 +344,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
pinger := ping.New(address)
|
pinger := ping.New(address)
|
||||||
pinger.Count = 1
|
pinger.Count = 1
|
||||||
pinger.Timeout = config.Timeout
|
pinger.Timeout = config.Timeout
|
||||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
|
||||||
// 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.SetNetwork(config.Network)
|
pinger.SetNetwork(config.Network)
|
||||||
err := pinger.Run()
|
err := pinger.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -364,6 +360,25 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
return true, 0
|
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
|
// 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) {
|
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestCanPerformStartTLS(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
address string
|
address string
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
health "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.
|
||||||
|
// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.
|
||||||
|
func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = GetDefaultConfig()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var opts []grpc.DialOption
|
||||||
|
// Transport credentials
|
||||||
|
if useTLS {
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}
|
||||||
|
if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {
|
||||||
|
tlsCfg = configureTLS(tlsCfg, *cfg.TLS)
|
||||||
|
}
|
||||||
|
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
}
|
||||||
|
// Custom dialer for DNS resolver or SSH tunnel
|
||||||
|
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||||
|
if cfg.ResolvedTunnel != nil {
|
||||||
|
return cfg.ResolvedTunnel.Dial("tcp", addr)
|
||||||
|
}
|
||||||
|
if cfg.HasCustomDNSResolver() {
|
||||||
|
resolverCfg, err := cfg.parseDNSResolver()
|
||||||
|
if err != nil {
|
||||||
|
// Shouldn't happen because already validated; log and fall back
|
||||||
|
logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err)
|
||||||
|
} else {
|
||||||
|
d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{}
|
||||||
|
return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port)
|
||||||
|
}}}
|
||||||
|
return d.DialContext(ctx, "tcp", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "tcp", addr)
|
||||||
|
}))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := grpc.DialContext(ctx, address, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err, time.Since(start)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client := health.NewHealthClient(conn)
|
||||||
|
resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""})
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err, time.Since(start)
|
||||||
|
}
|
||||||
|
return true, resp.GetStatus().String(), nil, time.Since(start)
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@ type Announcement struct {
|
|||||||
|
|
||||||
// Message is the user-facing text describing the announcement
|
// Message is the user-facing text describing the announcement
|
||||||
Message string `yaml:"message" json:"message"`
|
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
|
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package announcement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
announcement *Announcement
|
||||||
|
expectedError error
|
||||||
|
expectedType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid-announcement-with-all-fields",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "This is a test announcement",
|
||||||
|
Archived: false,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedType: TypeWarning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-announcement-with-archived-true",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeOperational,
|
||||||
|
Message: "This is an archived announcement",
|
||||||
|
Archived: true,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedType: TypeOperational,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-announcement-with-empty-type-should-default-to-none",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Message: "This announcement has no type",
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedType: TypeNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-announcement-with-empty-message",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "",
|
||||||
|
},
|
||||||
|
expectedError: ErrEmptyMessage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-announcement-with-zero-timestamp",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: time.Time{},
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "Test message",
|
||||||
|
},
|
||||||
|
expectedError: ErrMissingTimestamp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-announcement-with-invalid-type",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: "invalid-type",
|
||||||
|
Message: "Test message",
|
||||||
|
},
|
||||||
|
expectedError: ErrInvalidAnnouncementType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.announcement.ValidateAndSetDefaults()
|
||||||
|
if !errors.Is(err, scenario.expectedError) {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
|
||||||
|
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
|
||||||
|
for _, validType := range validTypes {
|
||||||
|
t.Run("type-"+validType, func(t *testing.T) {
|
||||||
|
announcement := &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: validType,
|
||||||
|
Message: "Test message",
|
||||||
|
}
|
||||||
|
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Errorf("expected no error for type %s, got %v", validType, err)
|
||||||
|
}
|
||||||
|
if announcement.Type != validType {
|
||||||
|
t.Errorf("expected type %s, got %s", validType, announcement.Type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortByTimestamp(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
earlier := now.Add(-1 * time.Hour)
|
||||||
|
later := now.Add(1 * time.Hour)
|
||||||
|
announcements := []*Announcement{
|
||||||
|
{Timestamp: now, Message: "now"},
|
||||||
|
{Timestamp: later, Message: "later"},
|
||||||
|
{Timestamp: earlier, Message: "earlier"},
|
||||||
|
}
|
||||||
|
SortByTimestamp(announcements)
|
||||||
|
if announcements[0].Timestamp != later {
|
||||||
|
t.Error("expected first announcement to be the latest")
|
||||||
|
}
|
||||||
|
if announcements[1].Timestamp != now {
|
||||||
|
t.Error("expected second announcement to be the middle one")
|
||||||
|
}
|
||||||
|
if announcements[2].Timestamp != earlier {
|
||||||
|
t.Error("expected third announcement to be the earliest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
earlier := now.Add(-1 * time.Hour)
|
||||||
|
later := now.Add(1 * time.Hour)
|
||||||
|
announcements := []*Announcement{
|
||||||
|
{Timestamp: now, Message: "now", Archived: false},
|
||||||
|
{Timestamp: later, Message: "later", Archived: true},
|
||||||
|
{Timestamp: earlier, Message: "earlier", Archived: false},
|
||||||
|
}
|
||||||
|
SortByTimestamp(announcements)
|
||||||
|
// Sorting should be by timestamp only, not affected by archived status
|
||||||
|
if announcements[0].Timestamp != later {
|
||||||
|
t.Error("expected first announcement to be the latest, regardless of archived status")
|
||||||
|
}
|
||||||
|
if !announcements[0].Archived {
|
||||||
|
t.Error("expected first announcement to be archived")
|
||||||
|
}
|
||||||
|
if announcements[1].Timestamp != now {
|
||||||
|
t.Error("expected second announcement to be the middle one")
|
||||||
|
}
|
||||||
|
if announcements[2].Timestamp != earlier {
|
||||||
|
t.Error("expected third announcement to be the earliest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAndSetDefaults_Slice(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
announcements []*Announcement
|
||||||
|
expectedError error
|
||||||
|
shouldValidate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all-valid-announcements",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
|
||||||
|
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed-archived-announcements",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
|
||||||
|
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one-invalid-announcement-in-slice",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||||
|
{Timestamp: now, Type: TypeOperational, Message: ""},
|
||||||
|
},
|
||||||
|
expectedError: ErrEmptyMessage,
|
||||||
|
shouldValidate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "announcement-with-missing-timestamp",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||||
|
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
|
||||||
|
},
|
||||||
|
expectedError: ErrMissingTimestamp,
|
||||||
|
shouldValidate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "announcement-with-invalid-type",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||||
|
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
|
||||||
|
},
|
||||||
|
expectedError: ErrInvalidAnnouncementType,
|
||||||
|
shouldValidate: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := ValidateAndSetDefaults(scenario.announcements)
|
||||||
|
if !errors.Is(err, scenario.expectedError) {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
announcement := &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "Test announcement",
|
||||||
|
// Archived not set, should default to false
|
||||||
|
}
|
||||||
|
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
// Zero value for bool is false
|
||||||
|
if announcement.Archived {
|
||||||
|
t.Error("expected Archived to default to false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
|
||||||
|
announcements := []*Announcement{}
|
||||||
|
if err := ValidateAndSetDefaults(announcements); err != nil {
|
||||||
|
t.Errorf("expected no error for empty slice, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,7 +209,46 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
|
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
|
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
||||||
ExpectedSuccess: false,
|
ExpectedSuccess: false,
|
||||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
|
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "certificate-expiration-greater-than-duration",
|
Name: "certificate-expiration-greater-than-duration",
|
||||||
@@ -490,7 +490,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
||||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||||
ExpectedSuccess: false,
|
ExpectedSuccess: false,
|
||||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
|
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "no-placeholders",
|
Name: "no-placeholders",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const (
|
|||||||
TypeSTARTTLS Type = "STARTTLS"
|
TypeSTARTTLS Type = "STARTTLS"
|
||||||
TypeTLS Type = "TLS"
|
TypeTLS Type = "TLS"
|
||||||
TypeHTTP Type = "HTTP"
|
TypeHTTP Type = "HTTP"
|
||||||
|
TypeGRPC Type = "GRPC"
|
||||||
TypeWS Type = "WEBSOCKET"
|
TypeWS Type = "WEBSOCKET"
|
||||||
TypeSSH Type = "SSH"
|
TypeSSH Type = "SSH"
|
||||||
TypeUNKNOWN Type = "UNKNOWN"
|
TypeUNKNOWN Type = "UNKNOWN"
|
||||||
@@ -177,6 +178,8 @@ func (e *Endpoint) Type() Type {
|
|||||||
return TypeTLS
|
return TypeTLS
|
||||||
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
||||||
return TypeHTTP
|
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://"):
|
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
|
||||||
return TypeWS
|
return TypeWS
|
||||||
case strings.HasPrefix(e.URL, "ssh://"):
|
case strings.HasPrefix(e.URL, "ssh://"):
|
||||||
@@ -528,6 +531,19 @@ func (e *Endpoint) call(result *Result) {
|
|||||||
result.Body = output
|
result.Body = output
|
||||||
}
|
}
|
||||||
result.Duration = time.Since(startTime)
|
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 {
|
} else {
|
||||||
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
|
|||||||
+30
-19
@@ -10,14 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultTitle = "Health Dashboard | Gatus"
|
defaultTitle = "Health Dashboard | Gatus"
|
||||||
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
|
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
|
||||||
defaultHeader = "Gatus"
|
defaultHeader = "Gatus"
|
||||||
defaultLogo = ""
|
defaultDashboardHeading = "Health Dashboard"
|
||||||
defaultLink = ""
|
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
|
||||||
defaultCustomCSS = ""
|
defaultLogo = ""
|
||||||
defaultSortBy = "name"
|
defaultLink = ""
|
||||||
defaultFilterBy = "none"
|
defaultCustomCSS = ""
|
||||||
|
defaultSortBy = "name"
|
||||||
|
defaultFilterBy = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -30,17 +32,18 @@ var (
|
|||||||
|
|
||||||
// Config is the configuration for the UI of Gatus
|
// Config is the configuration for the UI of Gatus
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Title string `yaml:"title,omitempty"` // Title of the page
|
Title string `yaml:"title,omitempty"` // Title of the page
|
||||||
Description string `yaml:"description,omitempty"` // Meta description of the page
|
Description string `yaml:"description,omitempty"` // Meta description of the page
|
||||||
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
|
||||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
|
||||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||||
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
|
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||||
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
|
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||||
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
|
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
|
||||||
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
|
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
|
||||||
|
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
|
||||||
|
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
|
||||||
//////////////////////////////////////////////
|
//////////////////////////////////////////////
|
||||||
// Non-configurable - used for UI rendering //
|
// Non-configurable - used for UI rendering //
|
||||||
//////////////////////////////////////////////
|
//////////////////////////////////////////////
|
||||||
@@ -73,6 +76,8 @@ func GetDefaultConfig() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Title: defaultTitle,
|
Title: defaultTitle,
|
||||||
Description: defaultDescription,
|
Description: defaultDescription,
|
||||||
|
DashboardHeading: defaultDashboardHeading,
|
||||||
|
DashboardSubheading: defaultDashboardSubheading,
|
||||||
Header: defaultHeader,
|
Header: defaultHeader,
|
||||||
Logo: defaultLogo,
|
Logo: defaultLogo,
|
||||||
Link: defaultLink,
|
Link: defaultLink,
|
||||||
@@ -92,6 +97,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
|||||||
if len(cfg.Description) == 0 {
|
if len(cfg.Description) == 0 {
|
||||||
cfg.Description = defaultDescription
|
cfg.Description = defaultDescription
|
||||||
}
|
}
|
||||||
|
if len(cfg.DashboardHeading) == 0 {
|
||||||
|
cfg.DashboardHeading = defaultDashboardHeading
|
||||||
|
}
|
||||||
|
if len(cfg.DashboardSubheading) == 0 {
|
||||||
|
cfg.DashboardSubheading = defaultDashboardSubheading
|
||||||
|
}
|
||||||
if len(cfg.Header) == 0 {
|
if len(cfg.Header) == 0 {
|
||||||
cfg.Header = defaultHeader
|
cfg.Header = defaultHeader
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-25
@@ -7,31 +7,110 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
cfg := &Config{
|
t.Run("empty-config", func(t *testing.T) {
|
||||||
Title: "",
|
cfg := &Config{
|
||||||
Description: "",
|
Title: "",
|
||||||
Header: "",
|
Description: "",
|
||||||
Logo: "",
|
DashboardHeading: "",
|
||||||
Link: "",
|
DashboardSubheading: "",
|
||||||
}
|
Header: "",
|
||||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
Logo: "",
|
||||||
t.Error("expected no error, got", err.Error())
|
Link: "",
|
||||||
}
|
}
|
||||||
if cfg.Title != defaultTitle {
|
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
if cfg.Description != defaultDescription {
|
if cfg.Title != defaultTitle {
|
||||||
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||||
}
|
}
|
||||||
if cfg.Header != defaultHeader {
|
if cfg.Description != defaultDescription {
|
||||||
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
||||||
}
|
}
|
||||||
if cfg.DefaultSortBy != defaultSortBy {
|
if cfg.DashboardHeading != defaultDashboardHeading {
|
||||||
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
|
t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
|
||||||
}
|
}
|
||||||
if cfg.DefaultFilterBy != defaultFilterBy {
|
if cfg.DashboardSubheading != defaultDashboardSubheading {
|
||||||
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
t.Errorf("expected DashboardSubheading to be %s, got %s", defaultDashboardSubheading, cfg.DashboardSubheading)
|
||||||
}
|
}
|
||||||
|
if cfg.Header != defaultHeader {
|
||||||
|
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||||
|
}
|
||||||
|
if cfg.DefaultSortBy != defaultSortBy {
|
||||||
|
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
|
||||||
|
}
|
||||||
|
if cfg.DefaultFilterBy != defaultFilterBy {
|
||||||
|
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("custom-values", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Title: "Custom Title",
|
||||||
|
Description: "Custom Description",
|
||||||
|
DashboardHeading: "Production Status",
|
||||||
|
DashboardSubheading: "Monitor all production endpoints",
|
||||||
|
Header: "My Company",
|
||||||
|
Logo: "https://example.com/logo.png",
|
||||||
|
Link: "https://example.com",
|
||||||
|
DefaultSortBy: "health",
|
||||||
|
DefaultFilterBy: "failing",
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if cfg.Title != "Custom Title" {
|
||||||
|
t.Errorf("expected title to be preserved, got %s", cfg.Title)
|
||||||
|
}
|
||||||
|
if cfg.Description != "Custom Description" {
|
||||||
|
t.Errorf("expected description to be preserved, got %s", cfg.Description)
|
||||||
|
}
|
||||||
|
if cfg.DashboardHeading != "Production Status" {
|
||||||
|
t.Errorf("expected DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
|
||||||
|
}
|
||||||
|
if cfg.DashboardSubheading != "Monitor all production endpoints" {
|
||||||
|
t.Errorf("expected DashboardSubheading to be preserved, got %s", cfg.DashboardSubheading)
|
||||||
|
}
|
||||||
|
if cfg.Header != "My Company" {
|
||||||
|
t.Errorf("expected header to be preserved, got %s", cfg.Header)
|
||||||
|
}
|
||||||
|
if cfg.Logo != "https://example.com/logo.png" {
|
||||||
|
t.Errorf("expected logo to be preserved, got %s", cfg.Logo)
|
||||||
|
}
|
||||||
|
if cfg.Link != "https://example.com" {
|
||||||
|
t.Errorf("expected link to be preserved, got %s", cfg.Link)
|
||||||
|
}
|
||||||
|
if cfg.DefaultSortBy != "health" {
|
||||||
|
t.Errorf("expected defaultSortBy to be preserved, got %s", cfg.DefaultSortBy)
|
||||||
|
}
|
||||||
|
if cfg.DefaultFilterBy != "failing" {
|
||||||
|
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("partial-custom-values", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Title: "Custom Title",
|
||||||
|
DashboardHeading: "My Dashboard",
|
||||||
|
Header: "",
|
||||||
|
DashboardSubheading: "",
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if cfg.Title != "Custom Title" {
|
||||||
|
t.Errorf("expected custom title to be preserved, got %s", cfg.Title)
|
||||||
|
}
|
||||||
|
if cfg.DashboardHeading != "My Dashboard" {
|
||||||
|
t.Errorf("expected custom DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
|
||||||
|
}
|
||||||
|
if cfg.DashboardSubheading != defaultDashboardSubheading {
|
||||||
|
t.Errorf("expected DashboardSubheading to use default, got %s", cfg.DashboardSubheading)
|
||||||
|
}
|
||||||
|
if cfg.Header != defaultHeader {
|
||||||
|
t.Errorf("expected header to use default, got %s", cfg.Header)
|
||||||
|
}
|
||||||
|
if cfg.Description != defaultDescription {
|
||||||
|
t.Errorf("expected description to use default, got %s", cfg.Description)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestButton_Validate(t *testing.T) {
|
func TestButton_Validate(t *testing.T) {
|
||||||
@@ -78,6 +157,12 @@ func TestGetDefaultConfig(t *testing.T) {
|
|||||||
if defaultConfig.Title != defaultTitle {
|
if defaultConfig.Title != defaultTitle {
|
||||||
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
||||||
}
|
}
|
||||||
|
if defaultConfig.DashboardHeading != defaultDashboardHeading {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultDashboardHeading, got", defaultConfig.DashboardHeading)
|
||||||
|
}
|
||||||
|
if defaultConfig.DashboardSubheading != defaultDashboardSubheading {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultDashboardSubheading, got", defaultConfig.DashboardSubheading)
|
||||||
|
}
|
||||||
if defaultConfig.Logo != defaultLogo {
|
if defaultConfig.Logo != defaultLogo {
|
||||||
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ require (
|
|||||||
golang.org/x/oauth2 v0.32.0
|
golang.org/x/oauth2 v0.32.0
|
||||||
golang.org/x/sync v0.17.0
|
golang.org/x/sync v0.17.0
|
||||||
google.golang.org/api v0.252.0
|
google.golang.org/api v0.252.0
|
||||||
|
google.golang.org/grpc v1.75.1
|
||||||
gopkg.in/mail.v2 v2.3.1
|
gopkg.in/mail.v2 v2.3.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.39.1
|
modernc.org/sqlite v1.39.1
|
||||||
@@ -95,7 +96,6 @@ require (
|
|||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.37.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/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
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var (
|
|||||||
resultConnectedTotal *prometheus.CounterVec
|
resultConnectedTotal *prometheus.CounterVec
|
||||||
resultCodeTotal *prometheus.CounterVec
|
resultCodeTotal *prometheus.CounterVec
|
||||||
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
||||||
|
resultDomainExpirationSeconds *prometheus.GaugeVec
|
||||||
resultEndpointSuccess *prometheus.GaugeVec
|
resultEndpointSuccess *prometheus.GaugeVec
|
||||||
|
|
||||||
// Suite metrics
|
// Suite metrics
|
||||||
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
|
|||||||
if resultCertificateExpirationSeconds != nil {
|
if resultCertificateExpirationSeconds != nil {
|
||||||
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
|
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
|
||||||
}
|
}
|
||||||
|
if resultDomainExpirationSeconds != nil {
|
||||||
|
currentRegisterer.Unregister(resultDomainExpirationSeconds)
|
||||||
|
}
|
||||||
if resultEndpointSuccess != nil {
|
if resultEndpointSuccess != nil {
|
||||||
currentRegisterer.Unregister(resultEndpointSuccess)
|
currentRegisterer.Unregister(resultEndpointSuccess)
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
|
|||||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||||
reg.MustRegister(resultCertificateExpirationSeconds)
|
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{
|
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Name: "results_endpoint_success",
|
Name: "results_endpoint_success",
|
||||||
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
|
|||||||
if result.CertificateExpiration != 0 {
|
if result.CertificateExpiration != 0 {
|
||||||
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
|
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 {
|
if result.Success {
|
||||||
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
|
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
|
|||||||
if resultCertificateExpirationSeconds == nil {
|
if resultCertificateExpirationSeconds == nil {
|
||||||
t.Error("resultCertificateExpirationSeconds metric not initialized")
|
t.Error("resultCertificateExpirationSeconds metric not initialized")
|
||||||
}
|
}
|
||||||
|
if resultDomainExpirationSeconds == nil {
|
||||||
|
t.Error("resultDomainExpirationSeconds metric not initialized")
|
||||||
|
}
|
||||||
if resultEndpointSuccess == nil {
|
if resultEndpointSuccess == nil {
|
||||||
t.Error("resultEndpointSuccess metric not initialized")
|
t.Error("resultEndpointSuccess metric not initialized")
|
||||||
}
|
}
|
||||||
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
|
|||||||
ConditionResults: []*endpoint.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[STATUS] == 200", Success: true},
|
{Condition: "[STATUS] == 200", Success: true},
|
||||||
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
|
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
|
||||||
|
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
|
||||||
},
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
CertificateExpiration: 49 * time.Hour,
|
CertificateExpiration: 49 * time.Hour,
|
||||||
|
DomainExpiration: 25 * time.Hour,
|
||||||
}, []string{})
|
}, []string{})
|
||||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||||
# HELP gatus_results_code_total Total number of results by code
|
# 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
|
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
# 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
|
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
|
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||||
# TYPE gatus_results_endpoint_success gauge
|
# TYPE gatus_results_endpoint_success gauge
|
||||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
|
gatus_results_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{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[STATUS] == 200", Success: true},
|
{Condition: "[STATUS] == 200", Success: true},
|
||||||
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
|
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
|
||||||
|
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
|
||||||
},
|
},
|
||||||
Success: false,
|
Success: false,
|
||||||
CertificateExpiration: 47 * time.Hour,
|
CertificateExpiration: 47 * time.Hour,
|
||||||
|
DomainExpiration: 24 * time.Hour,
|
||||||
}, []string{})
|
}, []string{})
|
||||||
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||||
# HELP gatus_results_code_total Total number of results by code
|
# 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
|
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
# 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
|
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
|
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||||
# TYPE gatus_results_endpoint_success gauge
|
# TYPE gatus_results_endpoint_success gauge
|
||||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
|
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
|
||||||
|
|||||||
Generated
+146
-464
@@ -8,16 +8,19 @@
|
|||||||
"name": "gatus",
|
"name": "gatus",
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/vue": "^1.7.23",
|
"chart.js": "^4.5.1",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"@vueuse/core": "^13.6.0",
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"core-js": "^3.45.0",
|
"core-js": "^3.45.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.0",
|
||||||
"lucide-vue-next": "^0.539.0",
|
"lucide-vue-next": "^0.539.0",
|
||||||
"radix-vue": "^1.9.17",
|
"marked": "^16.4.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1861,42 +1864,6 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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": {
|
"node_modules/@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
@@ -1912,30 +1879,6 @@
|
|||||||
"@hapi/hoek": "^9.0.0"
|
"@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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
@@ -1974,24 +1917,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||||
@@ -2063,6 +1988,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@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": {
|
"node_modules/@leichtgewicht/ip-codec": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||||
@@ -2244,41 +2175,6 @@
|
|||||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@trysound/sax": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||||
@@ -2472,11 +2368,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "0.0.21",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.3",
|
"version": "8.5.3",
|
||||||
@@ -3221,44 +3118,6 @@
|
|||||||
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||||
@@ -3670,18 +3529,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
||||||
@@ -4170,6 +4017,37 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.5.3",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||||
@@ -5002,6 +4880,16 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
@@ -5146,12 +5034,6 @@
|
|||||||
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -5298,6 +5180,15 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||||
@@ -6237,7 +6128,8 @@
|
|||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.2.11",
|
"version": "3.2.11",
|
||||||
@@ -7798,6 +7690,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/mdn-data": {
|
||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||||
@@ -9535,88 +9439,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -10821,6 +10643,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
@@ -11026,30 +10849,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-demi": {
|
"node_modules/vue-chartjs": {
|
||||||
"version": "0.14.10",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"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": {
|
"peerDependencies": {
|
||||||
"@vue/composition-api": "^1.0.0-rc.1",
|
"chart.js": "^4.1.1",
|
||||||
"vue": "^3.0.0-0 || ^2.6.0"
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@vue/composition-api": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-eslint-parser": {
|
"node_modules/vue-eslint-parser": {
|
||||||
@@ -13323,38 +13130,6 @@
|
|||||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||||
"dev": true
|
"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": {
|
"@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
@@ -13370,20 +13145,6 @@
|
|||||||
"@hapi/hoek": "^9.0.0"
|
"@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": {
|
"@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"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==",
|
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||||
"dev": true
|
"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": {
|
"@jridgewell/gen-mapping": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||||
@@ -13483,6 +13228,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@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": {
|
"@leichtgewicht/ip-codec": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||||
@@ -13629,27 +13379,6 @@
|
|||||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||||
"dev": true
|
"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": {
|
"@trysound/sax": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||||
@@ -13840,10 +13569,11 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/web-bluetooth": {
|
"@types/trusted-types": {
|
||||||
"version": "0.0.21",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"optional": true
|
||||||
},
|
},
|
||||||
"@types/ws": {
|
"@types/ws": {
|
||||||
"version": "8.5.3",
|
"version": "8.5.3",
|
||||||
@@ -14421,27 +14151,6 @@
|
|||||||
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
||||||
"dev": true
|
"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": {
|
"@webassemblyjs/ast": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||||
@@ -14776,14 +14485,6 @@
|
|||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true
|
"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": {
|
"array-flatten": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
||||||
@@ -15123,6 +14824,26 @@
|
|||||||
"supports-color": "^5.3.0"
|
"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": {
|
"chokidar": {
|
||||||
"version": "3.5.3",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
"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": {
|
"debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
@@ -15822,11 +15548,6 @@
|
|||||||
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"defu": {
|
|
||||||
"version": "6.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
|
||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
|
|
||||||
},
|
|
||||||
"depd": {
|
"depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -15936,6 +15657,14 @@
|
|||||||
"domelementtype": "^2.2.0"
|
"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": {
|
"domutils": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||||
@@ -16615,7 +16344,8 @@
|
|||||||
"fast-deep-equal": {
|
"fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"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": {
|
"fast-glob": {
|
||||||
"version": "3.2.11",
|
"version": "3.2.11",
|
||||||
@@ -17784,6 +17514,11 @@
|
|||||||
"semver": "^6.0.0"
|
"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": {
|
"mdn-data": {
|
||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||||
@@ -18970,60 +18705,6 @@
|
|||||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||||
"dev": true
|
"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": {
|
"randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -19954,7 +19635,8 @@
|
|||||||
"tslib": {
|
"tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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": {
|
"type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -20092,10 +19774,10 @@
|
|||||||
"@vue/shared": "3.5.18"
|
"@vue/shared": "3.5.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vue-demi": {
|
"vue-chartjs": {
|
||||||
"version": "0.14.10",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"vue-eslint-parser": {
|
"vue-eslint-parser": {
|
||||||
|
|||||||
@@ -8,22 +8,29 @@
|
|||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"core-js": "^3.45.0",
|
"core-js": "^3.45.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.0",
|
||||||
"lucide-vue-next": "^0.539.0",
|
"lucide-vue-next": "^0.539.0",
|
||||||
|
"marked": "^16.4.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.25.1",
|
||||||
"@vue/cli-plugin-babel": "^5.0.8",
|
"@vue/cli-plugin-babel": "^5.0.8",
|
||||||
"@vue/cli-plugin-eslint": "^5.0.8",
|
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||||
"@vue/cli-plugin-router": "^5.0.8",
|
"@vue/cli-plugin-router": "^5.0.8",
|
||||||
"@vue/cli-service": "^5.0.8",
|
"@vue/cli-service": "^5.0.8",
|
||||||
"@vue/compiler-sfc": "^3.5.18",
|
"@vue/compiler-sfc": "^3.5.18",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"@babel/eslint-parser": "^7.25.1",
|
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-vue": "^9.28.0",
|
"eslint-plugin-vue": "^9.28.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||||
// Initialize theme immediately to prevent flash
|
// Initialize theme immediately to prevent flash
|
||||||
(function() {
|
(function() {
|
||||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -44,17 +44,10 @@
|
|||||||
:key="date"
|
:key="date"
|
||||||
class="relative"
|
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 -->
|
<!-- Date Header -->
|
||||||
<div class="flex items-center gap-3 mb-2 relative">
|
<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">
|
<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) }}
|
{{ formatDate(date) }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +64,8 @@
|
|||||||
<!-- Timeline Icon -->
|
<!-- Timeline Icon -->
|
||||||
<div
|
<div
|
||||||
:class="[
|
: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
|
getTypeClasses(announcement.type).border
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -81,6 +75,24 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Announcement Card -->
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
@@ -88,19 +100,22 @@
|
|||||||
getTypeClasses(announcement.type).background
|
getTypeClasses(announcement.type).background
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center 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
|
<time
|
||||||
:class="[
|
:class="[
|
||||||
'text-xs font-mono whitespace-nowrap',
|
'text-sm font-mono whitespace-nowrap flex-shrink-0',
|
||||||
getTypeClasses(announcement.type).text
|
getTypeClasses(announcement.type).text
|
||||||
]"
|
]"
|
||||||
:title="formatFullTimestamp(announcement.timestamp)"
|
:title="formatFullTimestamp(announcement.timestamp)"
|
||||||
>
|
>
|
||||||
{{ formatTime(announcement.timestamp) }}
|
{{ formatTime(announcement.timestamp) }}
|
||||||
</time>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +131,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
||||||
|
import { formatAnnouncementMessage } from '@/utils/markdown'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -220,14 +236,6 @@ const getTypeClasses = (type) => {
|
|||||||
return typeConfigs[type] || typeConfigs.none
|
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 formatDate = (dateString) => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import { cva } from 'class-variance-authority'
|
import { cva } from 'class-variance-authority'
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import { cva } from 'class-variance-authority'
|
import { cva } from 'class-variance-authority'
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
</script>
|
</script>
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
</script>
|
</script>
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
</script>
|
</script>
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
</script>
|
</script>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import { combineClasses } from '@/lib/utils'
|
import { combineClasses } from '@/utils/misc'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
const escapeHtml = (value) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
|
||||||
|
renderer.link = (tokenOrHref, title, text) => {
|
||||||
|
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
|
||||||
|
? tokenOrHref
|
||||||
|
: null
|
||||||
|
const href = tokenObject ? tokenObject.href : tokenOrHref
|
||||||
|
const resolvedTitle = tokenObject ? tokenObject.title : title
|
||||||
|
const resolvedText = tokenObject ? tokenObject.text : text
|
||||||
|
const url = escapeHtml(href || '')
|
||||||
|
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
|
||||||
|
const linkText = resolvedText || ''
|
||||||
|
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium"${titleAttribute}>${linkText}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
renderer,
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
headerIds: false,
|
||||||
|
mangle: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export const formatAnnouncementMessage = (message) => {
|
||||||
|
if (!message) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const markdown = String(message)
|
||||||
|
const html = marked.parse(markdown)
|
||||||
|
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
|
||||||
|
}
|
||||||
@@ -31,8 +31,28 @@ export const generatePrettyTimeAgo = (timestamp) => {
|
|||||||
* @returns {string} Time difference string
|
* @returns {string} Time difference string
|
||||||
*/
|
*/
|
||||||
export const generatePrettyTimeDifference = (start, end) => {
|
export const generatePrettyTimeDifference = (start, end) => {
|
||||||
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
|
const ms = new Date(start) - new Date(end)
|
||||||
return minutes + (minutes === 1 ? ' minute' : ' minutes');
|
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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,7 +117,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -206,6 +212,7 @@ import EndpointCard from '@/components/EndpointCard.vue'
|
|||||||
import Settings from '@/components/Settings.vue'
|
import Settings from '@/components/Settings.vue'
|
||||||
import Pagination from '@/components/Pagination.vue'
|
import Pagination from '@/components/Pagination.vue'
|
||||||
import Loading from '@/components/Loading.vue'
|
import Loading from '@/components/Loading.vue'
|
||||||
|
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
|
||||||
import { SERVER_URL } from '@/main.js'
|
import { SERVER_URL } from '@/main.js'
|
||||||
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
|
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`
|
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(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
|
<h1 class="text-4xl font-bold tracking-tight">{{ dashboardHeading }}</h1>
|
||||||
<p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
|
<p class="text-muted-foreground mt-2">{{ dashboardSubheading }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Announcement Banner -->
|
<!-- Announcement Banner (Active Announcements) -->
|
||||||
<AnnouncementBanner :announcements="props.announcements" />
|
<AnnouncementBanner :announcements="activeAnnouncements" />
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<SearchBar
|
<SearchBar
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@@ -170,6 +170,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Past Announcements Section -->
|
||||||
|
<div v-if="archivedAnnouncements.length > 0" class="mt-12 pb-8">
|
||||||
|
<PastAnnouncements :announcements="archivedAnnouncements" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Settings @refreshData="fetchData" />
|
<Settings @refreshData="fetchData" />
|
||||||
@@ -187,6 +192,7 @@ import SearchBar from '@/components/SearchBar.vue'
|
|||||||
import Settings from '@/components/Settings.vue'
|
import Settings from '@/components/Settings.vue'
|
||||||
import Loading from '@/components/Loading.vue'
|
import Loading from '@/components/Loading.vue'
|
||||||
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
|
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
|
||||||
|
import PastAnnouncements from '@/components/PastAnnouncements.vue'
|
||||||
import { SERVER_URL } from '@/main.js'
|
import { SERVER_URL } from '@/main.js'
|
||||||
|
|
||||||
const props = defineProps({
|
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 emit = defineEmits(['showTooltip'])
|
||||||
|
|
||||||
const endpointStatuses = ref([])
|
const endpointStatuses = ref([])
|
||||||
@@ -517,6 +532,14 @@ const initializeCollapsedGroups = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dashboardHeading = computed(() => {
|
||||||
|
return window.config && window.config.dashboardHeading && window.config.dashboardHeading !== '{{ .UI.DashboardHeading }}' ? window.config.dashboardHeading : "Health Dashboard"
|
||||||
|
})
|
||||||
|
|
||||||
|
const dashboardSubheading = computed(() => {
|
||||||
|
return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.dashboardSubheading }}' ? window.config.dashboardSubheading : "Monitor the health of your endpoints in real-time"
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||||
// Initialize theme immediately to prevent flash
|
// Initialize theme immediately to prevent flash
|
||||||
(function() {
|
(function() {
|
||||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user